diff --git a/.prettierignore b/.prettierignore index 243e1cfc..ed2c9d6e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -13,6 +13,7 @@ bun.lockb # Generated code (queries.gen.ts, routes.gen.ts, etc.) **/*.gen.ts **/*.generated.d.ts +**/*.generated.api-checks.ts # Bundled .wrn compiler for the VS Code extension (generated) editors/vscode/src/compiler.cjs diff --git a/docs/public-api-0.8.json b/docs/public-api-0.8.json index b5bba72d..d9f11fcd 100644 --- a/docs/public-api-0.8.json +++ b/docs/public-api-0.8.json @@ -2854,7 +2854,10 @@ "LexError", "Lexer", "Token", - "TokenType" + "TokenType", + "isIdentPart", + "isIdentStart", + "skipLiteralOrComment" ], "./types": [ "RuntimeType", diff --git a/editors/vscode/src/compiler.cjs b/editors/vscode/src/compiler.cjs index 1439485b..c7c081e0 100644 --- a/editors/vscode/src/compiler.cjs +++ b/editors/vscode/src/compiler.cjs @@ -1,6 +1,6 @@ "use strict"; // Generated by scripts/build-editor-compiler.mjs. Do not edit directly. -// WRN editor compiler source hash: f474a2710a8861a4e863af13954e202e41a94b160c43c20297a7dfdd94b9ea8b +// WRN editor compiler source hash: 38942756eaa627215931b5268592f6e0825f8a8011a25ee7d532f2547176757e // WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8 // Generated with TypeScript: 6.0.3 const __nodeRequire = require; @@ -511,6 +511,7 @@ const RUNTIME_BINDINGS = new Set([ "server", "props", "refs", + "api", "event", "payload", ]); @@ -711,6 +712,27 @@ function _functionEntry(ast, fn, availableFunctions) { } }`; } +/** + * Client-mode api blocks become members of an `api` object in client scope. + * + * Only the response and error bodies are emitted; the declared field types are + * type-only and are consumed by the types generator instead. Anything + * TypeScript reaching this module would be a syntax error in the .mjs artifact. + */ +function apiBindings(ast) { + const members = ast.dataApis + .filter((block) => block.mode === "client" && block.sections) + .map((block) => { + const sections = block.sections; + const response = sections.response.trim() || "return data;"; + const error = sections.error.trim(); + const failure = error + ? `(error) => { const status = error.status; const message = error.message; const data = error.data; ${error} }` + : `(error) => { throw error; }`; + return ` ${JSON.stringify(block.name)}: async (input) => context.callApi(${JSON.stringify(block.path)}, ${JSON.stringify(block.method)}, input).then((data) => { ${response} }).catch(${failure})`; + }); + return members.length ? `const api = {\n${members.join(",\n")}\n };` : ""; +} function generateBrowserModule(ast) { const functions = ast.runtimeFunctions.filter((fn) => ["legacy", "client", "shared"].includes(fn.runtime)); const functionNames = functions.map((fn) => fn.name); @@ -758,6 +780,7 @@ function __wrnexusCreateClientFunctions(context) { const server = context.server; const props = context.props; const refs = context.refs; + ${apiBindings(ast)} const __wrnexusCommit = () => { ${sharedCommit} }; const __wrnexusRestore = () => { ${sharedRestore} }; ${!hasAuthoredCommit ? "const commit = __wrnexusCommit;" : ""} @@ -1489,6 +1512,7 @@ function renderBinding(binding) { path: binding.path, body: binding.body, helpers: binding.helpers, + ...(binding.errorBody ? { errorBody: binding.errorBody } : {}), }; } function hasClientBehavior(nodes) { @@ -1545,18 +1569,28 @@ function apiBindingMap(ast, sharedHelpers) { if (bindings.has(block.name)) { throw new Error(`Duplicate .wrn api binding "${block.name}"`); } + const sectioned = block.sections; + const errorSection = sectioned?.error.trim(); bindings.set(block.name, { mode: block.mode, method: block.method, path: apiRoutePath(block.path), - body: dataBody(block.body), + // A sectioned block binds the payload to `data`; the legacy form keeps + // the `with ($data)` injection, which cannot be typed. + body: sectioned + ? `const data = $data; ${sectioned.response.trim() || "return data;"}` + : dataBody(block.body), + // Only a sectioned block with a non-empty `error {}` gets a fallback — + // legacy blocks and sectioned blocks without `error` keep failures + // propagating exactly as before. + ...(errorSection ? { errorBody: errorSection } : {}), helpers: modeHelpers(ast, block.mode, sharedHelpers), }); } return bindings; } function ssrRuntimeSource() { - return `const __wrnexusHtmlEscapes = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" }; + return `const __wrnexusHtmlEscapes: Record = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" }; function __wrnexusEscapeHtml(value: unknown): string { return String(value).replace(/[&<>"']/g, (ch) => __wrnexusHtmlEscapes[ch] ?? ch); } @@ -1575,6 +1609,18 @@ function __wrnexusEvalData(data: unknown, body: string, helpers = "", ctx: __Wrn return new Function("$data", "$adapters", "const cookies = $adapters.cookies;\\nconst session = $adapters.session;\\nconst localStorage = $adapters.localStorage;\\nwith ($data ?? {}) {\\n" + helpers + "\\n" + body + "\\n}")(data, adapters); } +function __wrnexusEvalError(err: unknown, body: string, helpers = "", ctx: __WrnexusContext): unknown { + const adapters = { + cookies: ctx.cookies, + session: ctx.session, + localStorage: ctx.localStorage, + }; + const status = (err as { status?: unknown } | null | undefined)?.status; + const data = (err as { data?: unknown } | null | undefined)?.data; + const message = err instanceof Error ? err.message : String(err); + return new Function("$status", "$message", "$data", "$adapters", "const cookies = $adapters.cookies;\\nconst session = $adapters.session;\\nconst localStorage = $adapters.localStorage;\\nconst status = $status;\\nconst message = $message;\\nconst data = $data;\\n" + helpers + "\\n" + body)(status, message, data, adapters); +} + function __wrnexusPropAttr( value: unknown, ): string { @@ -1604,18 +1650,52 @@ async function __wrnexusCallApi(path: string, method: string, ctx: __WrnexusCont const url = new URL(path, ctx.req.url); const res = await fetch(new Request(url, { method, headers: ctx.req.headers })); + const type = res.headers.get("content-type") || ""; if (!res.ok) { - throw new Error(".wrn data API request failed with status " + res.status); + const data = type.includes("application/json") + ? await res.json().catch(() => undefined) + : await res.text().catch(() => undefined); + throw Object.assign(new Error(".wrn data API request failed with status " + res.status), { + status: res.status, + data, + }); } - const type = res.headers.get("content-type") || ""; return type.includes("application/json") ? await res.json() : await res.text(); } +type __WrnexusApiCall = { + path: string; + method: string; + body: string; + helpers: string; + errorBody?: string; +}; + +type __WrnexusSsrBinding = __WrnexusApiCall & { marker: string }; + +// Shared by every ssr api-binding consumption site (marker replacement, +// #each loop consts, ...) so the narrow try/catch -- only active when the +// block declared an error section -- cannot drift between call sites. +async function __wrnexusResolveApiBinding( + binding: __WrnexusApiCall, + ctx: __WrnexusContext, +): Promise { + if (binding.errorBody) { + try { + const data = await __wrnexusCallApi(binding.path, binding.method, ctx); + return __wrnexusEvalData(data, binding.body, binding.helpers, ctx); + } catch (err) { + return __wrnexusEvalError(err, binding.errorBody, binding.helpers, ctx); + } + } + const data = await __wrnexusCallApi(binding.path, binding.method, ctx); + return __wrnexusEvalData(data, binding.body, binding.helpers, ctx); +} + async function __wrnexusRenderSsrBindings(html: string, ctx: __WrnexusContext): Promise { for (const binding of __wrnexusSsrBindings) { - const data = await __wrnexusCallApi(binding.path, binding.method, ctx); - const value = __wrnexusEvalData(data, binding.body, binding.helpers, ctx); + const value = await __wrnexusResolveApiBinding(binding, ctx); html = html.replace(binding.marker, __wrnexusEscapeHtml(value)); } return html; @@ -2046,13 +2126,16 @@ function generateInner(ast) { continue; if (!lists.some((expr) => new RegExp(`\\b${name}\\b`).test(expr))) continue; - loopConsts.push(` const ${name} = __wrnexusEvalData(await __wrnexusCallApi(${JSON.stringify(binding.path)}, ${JSON.stringify(binding.method)}, ctx), ${JSON.stringify(binding.body)}, ${JSON.stringify(binding.helpers)}, ctx);`); + const errorBodyProp = binding.errorBody + ? `, errorBody: ${JSON.stringify(binding.errorBody)}` + : ""; + loopConsts.push(` const ${name} = await __wrnexusResolveApiBinding({ path: ${JSON.stringify(binding.path)}, method: ${JSON.stringify(binding.method)}, body: ${JSON.stringify(binding.body)}, helpers: ${JSON.stringify(binding.helpers)}${errorBodyProp} }, ctx);`); } } const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0 || runtimeStateNames.size > 0; if (needsSsrRuntime) { out.push(ssrRuntimeSource()); - out.push(`const __wrnexusSsrBindings = ${JSON.stringify(ssrBindings, null, 2)};`); + out.push(`const __wrnexusSsrBindings: __WrnexusSsrBinding[] = ${JSON.stringify(ssrBindings, null, 2)};`); const decls = loopConsts.length > 0 ? loopConsts.join("\n") + "\n" : ""; out.push(`export default async function ${ast.name}(ctx: __WrnexusContext) { ${storeDeclarations} @@ -4627,6 +4710,151 @@ Object.defineProperty(exports, "__esModule", { value: true }); /** @deprecated Import language type utilities from @wrnexus/syntax. */ __exportStar(require("@wrnexus/syntax/types"), exports); +}, +"packages/syntax/src/api-sections.ts": function (module, exports, require, __filename, __dirname) { +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.parseApiSections = parseApiSections; +exports.hasRequestSection = hasRequestSection; +/** + * Parse the sectioned form of an `api` block body. + * + * Returns null when no section keyword is present, which is how the legacy + * bare-body form stays valid: the caller keeps treating the body as the + * response expression. + * + * Detection and slicing both drive the tokenizer's own string/comment-aware + * scanning (`skipLiteralOrComment`, `Lexer.readBalancedBraces`) instead of a + * second hand-rolled brace counter, so a `}` inside a string or a `request {` + * mentioned in a comment can't be mistaken for a real section. + */ +const tokenizer_ts_1 = require("./tokenizer.js"); +const SECTION_NAMES = ["request", "response", "error"]; +const REQUEST_SUBSECTION_NAMES = ["parameters", "body"]; +/** + * Walk `source` at brace-depth 0, looking for `name { ... }` where `name` is + * one of `names`. Strings, template literals, and comments are skipped via + * `skipLiteralOrComment` — the same rules `readBalancedBraces` uses — so a + * keyword mentioned inside a string or comment, or nested inside an unrelated + * `{ }` (e.g. an object literal in a legacy body), is never mistaken for a + * section. Matched blocks are sliced via `Lexer.readBalancedBraces()` itself, + * not a reimplementation of it. + */ +function scanTopLevelBlocks(source, names) { + const found = new Map(); + const lx = new tokenizer_ts_1.Lexer(source); + let depth = 0; + let i = 0; + let atLineStart = true; + while (i < source.length) { + const c = source[i]; + if (c === "\n") { + atLineStart = true; + i++; + continue; + } + const skipped = (0, tokenizer_ts_1.skipLiteralOrComment)(source, i, atLineStart); + if (skipped !== null) { + i = skipped; + atLineStart = false; + continue; + } + if (c !== " " && c !== "\t" && c !== "\r") + atLineStart = false; + if (depth === 0 && (0, tokenizer_ts_1.isIdentStart)(c)) { + let j = i + 1; + while (j < source.length && (0, tokenizer_ts_1.isIdentPart)(source[j])) + j++; + const word = source.slice(i, j); + // Skip trivia between the identifier and a possible '{' without + // treating anything in between as significant yet. + let k = j; + let lineStartAtK = false; + while (k < source.length) { + const kc = source[k]; + if (kc === " " || kc === "\t" || kc === "\r") { + k++; + continue; + } + if (kc === "\n") { + lineStartAtK = true; + k++; + continue; + } + const kSkipped = (0, tokenizer_ts_1.skipLiteralOrComment)(source, k, lineStartAtK); + if (kSkipped !== null) { + k = kSkipped; + lineStartAtK = false; + continue; + } + break; + } + if (names.includes(word) && source[k] === "{") { + lx.pos = k; + const start = k + 1; + const text = lx.readBalancedBraces(); + if (!found.has(word)) + found.set(word, { text, start }); + i = lx.pos; + continue; + } + i = j; + continue; + } + if (c === "{") + depth++; + else if (c === "}") + depth = Math.max(0, depth - 1); + i++; + } + return found; +} +/** Rebase a span captured from `outer.text` back onto the original source. */ +function absolutize(span, outer) { + if (!span) + return undefined; + return outer ? { text: span.text, start: outer.start + span.start } : span; +} +/** `name?: string` -> { name, optional, type }. Blank lines and comments are skipped. */ +function parseFields(span) { + if (!span) + return []; + const fields = []; + let cursor = 0; + for (const rawLine of span.text.split("\n")) { + const lineOffset = span.start + cursor; + cursor += rawLine.length + 1; + const line = rawLine.trim().replace(/,$/, ""); + if (!line || line.startsWith("//")) + continue; + const match = /^([A-Za-z_$][A-Za-z0-9_$]*)(\?)?\s*:\s*(.+)$/.exec(line); + if (!match) { + throw new tokenizer_ts_1.LexError(`Expected "name: type" in an api request section, got "${line}" at offset ${lineOffset}`); + } + fields.push({ name: match[1], optional: match[2] === "?", type: match[3].trim() }); + } + return fields; +} +function parseApiSections(source) { + const top = scanTopLevelBlocks(source, SECTION_NAMES); + if (top.size === 0) + return null; + const request = top.get("request"); + const sub = request + ? scanTopLevelBlocks(request.text, REQUEST_SUBSECTION_NAMES) + : new Map(); + return { + parameters: parseFields(absolutize(sub.get("parameters"), request)), + body: parseFields(absolutize(sub.get("body"), request)), + response: top.get("response")?.text ?? "", + error: top.get("error")?.text ?? "", + }; +} +/** True when the block declares a `request` section. */ +function hasRequestSection(source) { + return scanTopLevelBlocks(source, SECTION_NAMES).has("request"); +} + }, "packages/syntax/src/diagnostics.ts": function (module, exports, require, __filename, __dirname) { "use strict"; @@ -5975,6 +6203,7 @@ exports.ParseError = exports.VOID_ELEMENTS = void 0; exports.parse = parse; exports.parseHtmlView = parseHtmlView; const spec_ts_1 = require("./spec.js"); +const api_sections_ts_1 = require("./api-sections.js"); /** * Recursive-descent parser for `.wrn`, producing a small AST. * @@ -6447,7 +6676,18 @@ function parse(source) { const method = expect("ident").value.toUpperCase(); const path = lx.readPath(); const body = lx.readBalancedBraces(); - dataApis.push({ mode, name, method, path, body }); + const sections = (0, api_sections_ts_1.parseApiSections)(body); + if (sections && mode !== "client" && (0, api_sections_ts_1.hasRequestSection)(body)) { + throw new ParseError(`An ssr api block cannot declare "request": there is no caller at render time to supply it. Use a client block, or a server function.`); + } + dataApis.push({ + mode, + name, + method, + path, + body: sections ? "" : body, + ...(sections ? { sections } : {}), + }); break; } case "functions": { @@ -7082,13 +7322,55 @@ exports.WRN_DIAGNOSTIC_CODES = { * `readToLineEnd()` for the parser to grab raw spans when grammar demands it. */ Object.defineProperty(exports, "__esModule", { value: true }); -exports.Lexer = exports.LexError = void 0; +exports.Lexer = exports.isIdentPart = exports.isIdentStart = exports.LexError = void 0; +exports.skipLiteralOrComment = skipLiteralOrComment; class LexError extends Error { } exports.LexError = LexError; const isWs = (c) => c === " " || c === "\t" || c === "\n" || c === "\r"; const isIdentStart = (c) => /[A-Za-z_]/.test(c); +exports.isIdentStart = isIdentStart; const isIdentPart = (c) => /[A-Za-z0-9_]/.test(c); +exports.isIdentPart = isIdentPart; +/** + * Skip over a string/template literal or comment starting at `src[i]`, using + * the exact rules `readBalancedBraces` needs to stay comment- and + * string-aware: `/* block *\/` comments anywhere, `//` line comments only at + * the start of a line (so a bare `https://…` in view text isn't mistaken for + * one), and `"`, `'`, `` ` `` strings with backslash escapes. + * + * Returns the index just past what it skipped, or `null` when `src[i]` isn't + * the start of one of those. Exported so any other raw-body scanner that + * needs to walk `.wrn` source without tripping over strings or comments + * (e.g. the `api` section scanner) shares this logic instead of + * reimplementing it — a second hand-rolled scanner is how apostrophes in + * prose used to swallow braces. + */ +function skipLiteralOrComment(src, i, atLineStart) { + const c = src[i]; + if (c === "/" && src[i + 1] === "*") { + const close = src.indexOf("*/", i + 2); + return close === -1 ? src.length : close + 2; + } + if (atLineStart && c === "/" && src[i + 1] === "/") { + const newline = src.indexOf("\n", i + 2); + return newline === -1 ? src.length : newline; + } + if (c === '"' || c === "'" || c === "`") { + let j = i + 1; + while (j < src.length) { + if (src[j] === "\\") { + j += 2; + continue; + } + if (src[j] === c) + return j + 1; + j++; + } + return src.length; + } + return null; +} class Lexer { src; pos = 0; @@ -7157,9 +7439,9 @@ class Lexer { case "'": return this.readString(c, pos); } - if (isIdentStart(c)) { + if ((0, exports.isIdentStart)(c)) { let v = ""; - while (this.pos < src.length && isIdentPart(src[this.pos])) + while (this.pos < src.length && (0, exports.isIdentPart)(src[this.pos])) v += src[this.pos++]; return { type: "ident", value: v, pos }; } @@ -7379,45 +7661,23 @@ class Lexer { const start = this.pos + 1; let depth = 0; let i = this.pos; - let str = null; /** True while only whitespace has been seen since the last newline. */ let atLineStart = false; - for (; i < src.length; i++) { + while (i < src.length) { const c = src[i]; - if (str) { - if (c === "\\") { - i++; - continue; - } - if (c === str) - str = null; - continue; - } if (c === "\n") { atLineStart = true; + i++; continue; } - if (c === "/" && src[i + 1] === "*") { - const close = src.indexOf("*/", i + 2); - if (close === -1) - break; // unterminated: fall through to the error - i = close + 1; + const skipped = skipLiteralOrComment(src, i, atLineStart); + if (skipped !== null) { + i = skipped; atLineStart = false; continue; } - if (atLineStart && c === "/" && src[i + 1] === "/") { - const newline = src.indexOf("\n", i + 2); - if (newline === -1) - break; - i = newline - 1; // let the loop's own increment land on the newline - continue; - } if (c !== " " && c !== "\t" && c !== "\r") atLineStart = false; - if (c === '"' || c === "'" || c === "`") { - str = c; - continue; - } if (c === "{") depth++; else if (c === "}") { @@ -7427,6 +7687,7 @@ class Lexer { return src.slice(start, i); } } + i++; } throw new LexError(`Unbalanced braces starting at offset ${this.pos}`); } diff --git a/editors/vscode/src/extension.bundle.cjs b/editors/vscode/src/extension.bundle.cjs index 8af09d2b..3c21629e 100644 --- a/editors/vscode/src/extension.bundle.cjs +++ b/editors/vscode/src/extension.bundle.cjs @@ -1,4 +1,4 @@ -// WRN editor extension source hash: 0a678785f34a594b64f06ca6e39cf7bea49127ddf344d585e52cc1b3eda9a82a +// WRN editor extension source hash: 7ecb4672607b87fdb848f1b52e80430129a5bfda31c9724e14595e4acc1fb1b7 // WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728 "use strict"; var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports); diff --git a/editors/vscode/src/language-server.cjs b/editors/vscode/src/language-server.cjs index 55dbad09..bcccbcf1 100644 --- a/editors/vscode/src/language-server.cjs +++ b/editors/vscode/src/language-server.cjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// WRN editor language server source hash: afce4195f664e656dc8f38283bda8118637d977cd2eee8ba08c80906681510cd +// WRN editor language server source hash: cf98947fd66392200bdfb18524a13e8bbb77d4920ba65a4d724e1124152571a3 // WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72 // @bun @bun-cjs (function(exports, require, module, __filename, __dirname) {var __create = Object.create; @@ -169631,6 +169631,32 @@ var isWs = (c) => c === " " || c === "\t" || c === ` ` || c === "\r"; var isIdentStart = (c) => /[A-Za-z_]/.test(c); var isIdentPart = (c) => /[A-Za-z0-9_]/.test(c); +function skipLiteralOrComment(src, i, atLineStart) { + const c = src[i]; + if (c === "/" && src[i + 1] === "*") { + const close = src.indexOf("*/", i + 2); + return close === -1 ? src.length : close + 2; + } + if (atLineStart && c === "/" && src[i + 1] === "/") { + const newline = src.indexOf(` +`, i + 2); + return newline === -1 ? src.length : newline; + } + if (c === '"' || c === "'" || c === "`") { + let j = i + 1; + while (j < src.length) { + if (src[j] === "\\") { + j += 2; + continue; + } + if (src[j] === c) + return j + 1; + j++; + } + return src.length; + } + return null; +} class Lexer { src; @@ -169892,46 +169918,23 @@ class Lexer { const start = this.pos + 1; let depth = 0; let i = this.pos; - let str = null; let atLineStart = false; - for (;i < src.length; i++) { + while (i < src.length) { const c = src[i]; - if (str) { - if (c === "\\") { - i++; - continue; - } - if (c === str) - str = null; - continue; - } if (c === ` `) { atLineStart = true; + i++; continue; } - if (c === "/" && src[i + 1] === "*") { - const close = src.indexOf("*/", i + 2); - if (close === -1) - break; - i = close + 1; + const skipped = skipLiteralOrComment(src, i, atLineStart); + if (skipped !== null) { + i = skipped; atLineStart = false; continue; } - if (atLineStart && c === "/" && src[i + 1] === "/") { - const newline = src.indexOf(` -`, i + 2); - if (newline === -1) - break; - i = newline - 1; - continue; - } if (c !== " " && c !== "\t" && c !== "\r") atLineStart = false; - if (c === '"' || c === "'" || c === "`") { - str = c; - continue; - } if (c === "{") depth++; else if (c === "}") { @@ -169941,6 +169944,7 @@ class Lexer { return src.slice(start, i); } } + i++; } throw new LexError(`Unbalanced braces starting at offset ${this.pos}`); } @@ -170752,6 +170756,120 @@ var WRN_DIAGNOSTIC_CODES = { migration: "WRN-MIGRATION-001" }; +// packages/syntax/src/api-sections.ts +var SECTION_NAMES = ["request", "response", "error"]; +var REQUEST_SUBSECTION_NAMES = ["parameters", "body"]; +function scanTopLevelBlocks(source, names) { + const found = new Map; + const lx = new Lexer(source); + let depth = 0; + let i = 0; + let atLineStart = true; + while (i < source.length) { + const c = source[i]; + if (c === ` +`) { + atLineStart = true; + i++; + continue; + } + const skipped = skipLiteralOrComment(source, i, atLineStart); + if (skipped !== null) { + i = skipped; + atLineStart = false; + continue; + } + if (c !== " " && c !== "\t" && c !== "\r") + atLineStart = false; + if (depth === 0 && isIdentStart(c)) { + let j = i + 1; + while (j < source.length && isIdentPart(source[j])) + j++; + const word = source.slice(i, j); + let k = j; + let lineStartAtK = false; + while (k < source.length) { + const kc = source[k]; + if (kc === " " || kc === "\t" || kc === "\r") { + k++; + continue; + } + if (kc === ` +`) { + lineStartAtK = true; + k++; + continue; + } + const kSkipped = skipLiteralOrComment(source, k, lineStartAtK); + if (kSkipped !== null) { + k = kSkipped; + lineStartAtK = false; + continue; + } + break; + } + if (names.includes(word) && source[k] === "{") { + lx.pos = k; + const start = k + 1; + const text = lx.readBalancedBraces(); + if (!found.has(word)) + found.set(word, { text, start }); + i = lx.pos; + continue; + } + i = j; + continue; + } + if (c === "{") + depth++; + else if (c === "}") + depth = Math.max(0, depth - 1); + i++; + } + return found; +} +function absolutize(span, outer) { + if (!span) + return; + return outer ? { text: span.text, start: outer.start + span.start } : span; +} +function parseFields(span) { + if (!span) + return []; + const fields = []; + let cursor = 0; + for (const rawLine of span.text.split(` +`)) { + const lineOffset = span.start + cursor; + cursor += rawLine.length + 1; + const line = rawLine.trim().replace(/,$/, ""); + if (!line || line.startsWith("//")) + continue; + const match = /^([A-Za-z_$][A-Za-z0-9_$]*)(\?)?\s*:\s*(.+)$/.exec(line); + if (!match) { + throw new LexError(`Expected "name: type" in an api request section, got "${line}" at offset ${lineOffset}`); + } + fields.push({ name: match[1], optional: match[2] === "?", type: match[3].trim() }); + } + return fields; +} +function parseApiSections(source) { + const top = scanTopLevelBlocks(source, SECTION_NAMES); + if (top.size === 0) + return null; + const request = top.get("request"); + const sub = request ? scanTopLevelBlocks(request.text, REQUEST_SUBSECTION_NAMES) : new Map; + return { + parameters: parseFields(absolutize(sub.get("parameters"), request)), + body: parseFields(absolutize(sub.get("body"), request)), + response: top.get("response")?.text ?? "", + error: top.get("error")?.text ?? "" + }; +} +function hasRequestSection(source) { + return scanTopLevelBlocks(source, SECTION_NAMES).has("request"); +} + // packages/syntax/src/types.ts function runtimeTypeOf(annotation) { if (!annotation) @@ -171632,7 +171750,18 @@ function parse(source) { const method = expect("ident").value.toUpperCase(); const path = lx.readPath(); const body = lx.readBalancedBraces(); - dataApis.push({ mode, name: name2, method, path, body }); + const sections = parseApiSections(body); + if (sections && mode !== "client" && hasRequestSection(body)) { + throw new ParseError(`An ssr api block cannot declare "request": there is no caller at render time to supply it. Use a client block, or a server function.`); + } + dataApis.push({ + mode, + name: name2, + method, + path, + body: sections ? "" : body, + ...sections ? { sections } : {} + }); break; } case "functions": { diff --git a/examples/basic-app/app/api/directory.ts b/examples/basic-app/app/api/directory.ts new file mode 100644 index 00000000..59a9cf6d --- /dev/null +++ b/examples/basic-app/app/api/directory.ts @@ -0,0 +1,14 @@ +import { defineEndpoint } from "@wrnexus/core"; +import type { Context } from "@wrnexus/core"; + +const ALL = [ + { name: "Ajay", designation: "UI" }, + { name: "Asha", designation: "Backend" }, + { name: "Chen", designation: "UI" }, +]; + +export const POST = async (ctx: Context) => { + const body = (await ctx.req.json().catch(() => ({}))) as { name?: string }; + const needle = String(body.name ?? "").toLowerCase(); + return Response.json({ users: ALL.filter((user) => user.name.toLowerCase().includes(needle)) }); +}; diff --git a/examples/basic-app/app/pages/api-block-demo.wrn b/examples/basic-app/app/pages/api-block-demo.wrn new file mode 100644 index 00000000..f2d999d3 --- /dev/null +++ b/examples/basic-app/app/pages/api-block-demo.wrn @@ -0,0 +1,38 @@ +page ApiBlockDemo { + state nameFilter = "a" + state found = "" + state failed = "" + + client { + api searchDirectory POST /api/directory { + request { + body { + name?: string + } + } + + response { + return data.users + } + + error { + return [] + } + } + } + + functions { + client async function search(): Promise { + const users = await api.searchDirectory({ name: nameFilter }) + found = users.map((user) => user.name).join(", ") + } + } + + view { +
+ +

{found}

+

{failed}

+
+ } +} diff --git a/examples/basic-app/app/routes.gen.ts b/examples/basic-app/app/routes.gen.ts index 86898346..430a660f 100644 --- a/examples/basic-app/app/routes.gen.ts +++ b/examples/basic-app/app/routes.gen.ts @@ -4,6 +4,7 @@ export interface Routes { "/": Record; "/about": Record; + "/api-block-demo": Record; "/async-data": Record; "/chat": Record; "/client-only": Record; @@ -27,6 +28,7 @@ export interface Routes { export interface RouteNames { "index": "/"; "about": "/about"; + "api.block.demo": "/api-block-demo"; "async.data": "/async-data"; "chat": "/chat"; "client.only": "/client-only"; @@ -119,6 +121,7 @@ export function route( const paths: Record = { "index": "/", "about": "/about", + "api.block.demo": "/api-block-demo", "async.data": "/async-data", "chat": "/chat", "client.only": "/client-only", diff --git a/examples/basic-app/app/types/wrnexus.generated.api-checks.ts b/examples/basic-app/app/types/wrnexus.generated.api-checks.ts index 372018ab..a74302f6 100644 --- a/examples/basic-app/app/types/wrnexus.generated.api-checks.ts +++ b/examples/basic-app/app/types/wrnexus.generated.api-checks.ts @@ -3,5 +3,5 @@ // Type-only assertions for sectioned `api` blocks. Kept as a real .ts file (not // wrnexus.generated.d.ts) because `skipLibCheck` exempts .d.ts contents from being // checked; this file is compiled and checked normally by the project's own tsc. - +type __wrn_api_check_searchDirectory = WRNexusGenerated.__wrn_expect_true>>; export {}; diff --git a/examples/basic-app/app/types/wrnexus.generated.d.ts b/examples/basic-app/app/types/wrnexus.generated.d.ts index 13688462..efacf0ae 100644 --- a/examples/basic-app/app/types/wrnexus.generated.d.ts +++ b/examples/basic-app/app/types/wrnexus.generated.d.ts @@ -13,8 +13,8 @@ declare namespace WRNexusGenerated { : never; type RealtimeMessage = T extends import("@wrnexus/core").RoomDefinition ? M : unknown; type QueuePayload = T extends import("@wrnexus/queue").JobDefinition ? I : unknown; - type RouteName = "about" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "index" | "island.demo" | "language.tools" | "layout" | "login" | "modal" | "navigation" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "table" | "test" | "ui"; - type ApiRoute = "/api/accounts" | "/api/echo" | "/api/graphql-example" | "/api/hello" | "/api/invite" | "/api/login" | "/api/logout" | "/api/me" | "/api/typed-user" | "/api/users/csr" | "/api/users/ssr" | "/api/webhooks/payment"; + type RouteName = "about" | "api.block.demo" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "index" | "island.demo" | "language.tools" | "layout" | "login" | "modal" | "navigation" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "table" | "test" | "ui"; + type ApiRoute = "/api/accounts" | "/api/directory" | "/api/echo" | "/api/graphql-example" | "/api/hello" | "/api/invite" | "/api/login" | "/api/logout" | "/api/me" | "/api/typed-user" | "/api/users/csr" | "/api/users/ssr" | "/api/webhooks/payment"; type RealtimeRoute = "/realtime/chat" | "/realtime/hello"; type EnvironmentKey = "APP_LABEL" | "DATABASE_URL" | "DEMO_SHARED" | "HOST" | "NODE_ENV" | "PORT" | "SESSION_SECRET" | "UAT_ONLY"; type TranslationKey = "api.greeting" | "home.intro" | "home.title" | "nav.about" | "nav.chat" | "nav.dashboard" | "nav.home" | "nav.layout" | "nav.navigation" | "nav.ui"; @@ -29,6 +29,7 @@ declare namespace WRNexusGenerated { "/api/users/ssr": { GET: ApiContract }; "/api/graphql-example": { POST: ApiContract }; "/api/typed-user": { POST: ApiContract }; + "/api/directory": { POST: ApiContract }; "/api/accounts": { GET: ApiContract }; "/api/invite": { POST: ApiContract }; "/api/logout": { POST: ApiContract }; diff --git a/packages/csr/test/api-call.test.ts b/packages/csr/test/api-call.test.ts index 18c83a63..006f2556 100644 --- a/packages/csr/test/api-call.test.ts +++ b/packages/csr/test/api-call.test.ts @@ -37,7 +37,8 @@ function harness(response: { status: number; payload: unknown }) { }; (0, eval)(REACTIVE_RUNTIME); - const callApi = (win as unknown as { __wrnexusCallApi: Function }).__wrnexusCallApi; + const callApi = (win as unknown as { __wrnexusCallApi: (...args: any[]) => Promise }) + .__wrnexusCallApi; return { callApi, calls, win }; }