feat(examples): worked example for typed api blocks

- Add examples/basic-app/app/api/directory.ts and app/pages/api-block-demo.wrn
  as the end-to-end worked example for typed api blocks (Task 6).
- Add **/*.generated.api-checks.ts to .prettierignore: this generated file
  must match the CLI's raw output byte-for-byte for check:generated-types,
  and prettier was reformatting it.
- Fix packages/csr/test/api-call.test.ts: no-unsafe-function-type lint error
  from the raw Function type, uncovered while running the full gate.
- Rebuild editors/vscode bundles (packages/compiler and packages/syntax
  changed in Tasks 1-5).
- Regenerate docs/public-api-0.8.json (additive: isIdentPart, isIdentStart,
  skipLiteralOrComment newly exported from packages/syntax).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 17:08:49 +05:30
co-authored by Claude Opus 5
parent 3252b1b20e
commit e5de7b54a5
11 changed files with 527 additions and 76 deletions
+1
View File
@@ -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
+4 -1
View File
@@ -2854,7 +2854,10 @@
"LexError",
"Lexer",
"Token",
"TokenType"
"TokenType",
"isIdentPart",
"isIdentStart",
"skipLiteralOrComment"
],
"./types": [
"RuntimeType",
+301 -40
View File
@@ -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 = { "&": "&amp;", "<": "&lt;", ">": "&gt;", "\\"": "&quot;", "'": "&#39;" };
return `const __wrnexusHtmlEscapes: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", "\\"": "&quot;", "'": "&#39;" };
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<unknown> {
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<string> {
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}`);
}
+1 -1
View File
@@ -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);
+159 -30
View File
@@ -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": {
+14
View File
@@ -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)) });
};
@@ -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<void> {
const users = await api.searchDirectory({ name: nameFilter })
found = users.map((user) => user.name).join(", ")
}
}
view {
<main>
<button @click="search()">Search</button>
<p class="found" data-text="found">{found}</p>
<p class="failed" data-text="failed">{failed}</p>
</main>
}
}
+3
View File
@@ -4,6 +4,7 @@
export interface Routes {
"/": Record<string, never>;
"/about": Record<string, never>;
"/api-block-demo": Record<string, never>;
"/async-data": Record<string, never>;
"/chat": Record<string, never>;
"/client-only": Record<string, never>;
@@ -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<N extends RouteName>(
const paths: Record<RouteName, RoutePath> = {
"index": "/",
"about": "/about",
"api.block.demo": "/api-block-demo",
"async.data": "/async-data",
"chat": "/chat",
"client.only": "/client-only",
@@ -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<WRNexusGenerated.AssertAssignable<{ name?: string }, WRNexusGenerated.ApiInput<"/api/directory", "POST">>>;
export {};
+3 -2
View File
@@ -13,8 +13,8 @@ declare namespace WRNexusGenerated {
: never;
type RealtimeMessage<T> = T extends import("@wrnexus/core").RoomDefinition<any, infer M> ? M : unknown;
type QueuePayload<T> = T extends import("@wrnexus/queue").JobDefinition<infer I> ? 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<typeof import("../api/users/ssr.ts")["GET"]> };
"/api/graphql-example": { POST: ApiContract<typeof import("../api/graphql-example.ts")["POST"]> };
"/api/typed-user": { POST: ApiContract<typeof import("../api/typed-user.ts")["POST"]> };
"/api/directory": { POST: ApiContract<typeof import("../api/directory.ts")["POST"]> };
"/api/accounts": { GET: ApiContract<typeof import("../api/accounts.ts")["GET"]> };
"/api/invite": { POST: ApiContract<typeof import("../api/invite.ts")["POST"]> };
"/api/logout": { POST: ApiContract<typeof import("../api/logout.ts")["POST"]> };
+2 -1
View File
@@ -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<any> })
.__wrnexusCallApi;
return { callApi, calls, win };
}