diff --git a/bun.lock b/bun.lock index 0af423be..410ab2a2 100644 --- a/bun.lock +++ b/bun.lock @@ -292,7 +292,7 @@ }, "packages/cli": { "name": "@wrnexus/cli", - "version": "0.8.55", + "version": "0.8.57", "bin": { "wrnexus": "src/index.ts", }, @@ -320,7 +320,7 @@ }, "packages/compiler": { "name": "@wrnexus/compiler", - "version": "0.8.17", + "version": "0.8.19", "dependencies": { "@wrnexus/csr": "workspace:*", "@wrnexus/store": "workspace:*", @@ -342,7 +342,7 @@ }, "packages/csr": { "name": "@wrnexus/csr", - "version": "0.8.29", + "version": "0.8.31", "dependencies": { "@wrnexus/core": "workspace:*", }, @@ -357,7 +357,7 @@ }, "packages/dev-server": { "name": "@wrnexus/dev-server", - "version": "0.8.48", + "version": "0.8.50", "dependencies": { "@wrnexus/authz": "workspace:*", "@wrnexus/cache": "workspace:*", @@ -499,6 +499,10 @@ "wrnexus-mcp": "src/stdio.ts", }, }, + "packages/metering": { + "name": "@wrnexus/metering", + "version": "0.8.2", + }, "packages/mobile": { "name": "@wrnexus/mobile", "version": "0.8.8", @@ -666,7 +670,7 @@ }, "packages/typecheck": { "name": "@wrnexus/typecheck", - "version": "0.8.11", + "version": "0.8.13", "dependencies": { "@wrnexus/syntax": "workspace:*", "typescript": "^6.0.3", @@ -1052,6 +1056,8 @@ "@wrnexus/mcp": ["@wrnexus/mcp@workspace:packages/mcp"], + "@wrnexus/metering": ["@wrnexus/metering@workspace:packages/metering"], + "@wrnexus/mobile": ["@wrnexus/mobile@workspace:packages/mobile"], "@wrnexus/native": ["@wrnexus/native@workspace:packages/native"], diff --git a/packages/cli/package.json b/packages/cli/package.json index 36a1ace5..5a25d43e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/cli", - "version": "0.8.55", + "version": "0.8.57", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/cli/src/check.ts b/packages/cli/src/check.ts index 14d0481f..0906a658 100644 --- a/packages/cli/src/check.ts +++ b/packages/cli/src/check.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { runBuild } from "./build.ts"; import { runTypecheck } from "./types.ts"; +import { assertReachability } from "./reachability.ts"; async function runScript(root: string, name: string): Promise { const child = Bun.spawn(["bun", "run", name], { @@ -20,6 +21,7 @@ export async function runCheck(appRoot: string): Promise { const manifest = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")) as { scripts?: Record; }; + assertReachability(root); await runBuild(root); if (manifest.scripts?.typecheck) await runScript(root, "typecheck"); else if (!(await runTypecheck(root))) throw new Error("WRN-CHECK: application typecheck failed"); diff --git a/packages/cli/src/reachability.ts b/packages/cli/src/reachability.ts new file mode 100644 index 00000000..9bef797e --- /dev/null +++ b/packages/cli/src/reachability.ts @@ -0,0 +1,80 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { dirname, extname, join, relative, resolve } from "node:path"; + +export interface ReachabilityIssue { + code: "WRN-REACH-MIDDLEWARE" | "WRN-REACH-QUEUE" | "WRN-REACH-WORKER"; + file: string; + message: string; +} + +function sourceFiles(directory: string): string[] { + if (!existsSync(directory)) return []; + const output: string[] = []; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) output.push(...sourceFiles(path)); + else if ([".ts", ".js", ".mts", ".mjs"].includes(extname(entry.name))) output.push(path); + } + return output; +} + +/** Static registration audit for convention-discovered application modules. */ +export function checkReachability(appRoot: string): ReachabilityIssue[] { + const root = resolve(appRoot); + const app = join(root, "app"); + const all = sourceFiles(app).map((file) => ({ file, source: readFileSync(file, "utf8") })); + const issues: ReachabilityIssue[] = []; + + for (const entry of all) { + const path = relative(root, entry.file).replace(/\\/g, "/"); + if (path.startsWith("app/middleware/") && !/\bexport\s+default\b/.test(entry.source)) { + issues.push({ + code: "WRN-REACH-MIDDLEWARE", + file: path, + message: "Middleware discovery requires a default export.", + }); + } + if ( + path.startsWith("app/queues/") && + /\bdefineQueue\s*\(/.test(entry.source) && + !/\bexport\s+default\b/.test(entry.source) + ) { + issues.push({ + code: "WRN-REACH-QUEUE", + file: path, + message: "Queue discovery requires the defined queue to be exported as default.", + }); + } + if ( + /\bdefineWorker\s*(?:<[^>]+>)?\s*\(/.test(entry.source) && + !/\brunWorker\s*\(/.test(entry.source) + ) { + const stem = entry.file.replace(/\.[^.]+$/, ""); + const imported = all.some( + (candidate) => + candidate.file !== entry.file && + candidate.source + .replace(/\\/g, "/") + .includes(relative(dirname(candidate.file), stem).replace(/\\/g, "/")), + ); + if (!imported) { + issues.push({ + code: "WRN-REACH-WORKER", + file: path, + message: "Worker is defined but no application module imports it or calls runWorker().", + }); + } + } + } + return issues; +} + +export function assertReachability(appRoot: string): void { + const issues = checkReachability(appRoot); + if (!issues.length) return; + throw new Error( + `WRN-REACHABILITY: unreachable application exports\n${issues + .map((issue) => `- ${issue.code} ${issue.file}: ${issue.message}`) + .join("\n")}`, + ); +} diff --git a/packages/cli/test/reachability.test.ts b/packages/cli/test/reachability.test.ts new file mode 100644 index 00000000..f3e81cce --- /dev/null +++ b/packages/cli/test/reachability.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { checkReachability } from "../src/reachability.ts"; + +test("reports convention modules and workers that cannot be reached", () => { + const root = mkdtempSync(join(tmpdir(), "wrn-reach-")); + mkdirSync(join(root, "app", "middleware"), { recursive: true }); + mkdirSync(join(root, "app", "queues"), { recursive: true }); + writeFileSync(join(root, "app", "middleware", "audit.ts"), "export const audit = () => {};\n"); + writeFileSync( + join(root, "app", "queues", "mail.ts"), + "export const mail = defineQueue({}); export const worker = defineWorker({});\n", + ); + + expect( + checkReachability(root) + .map((issue) => issue.code) + .sort(), + ).toEqual(["WRN-REACH-MIDDLEWARE", "WRN-REACH-QUEUE", "WRN-REACH-WORKER"]); +}); + +test("accepts discovered middleware and queues", () => { + const root = mkdtempSync(join(tmpdir(), "wrn-reach-")); + mkdirSync(join(root, "app", "middleware"), { recursive: true }); + mkdirSync(join(root, "app", "queues"), { recursive: true }); + writeFileSync(join(root, "app", "middleware", "audit.ts"), "export default () => {};\n"); + writeFileSync(join(root, "app", "queues", "mail.ts"), "export default defineQueue({});\n"); + expect(checkReachability(root)).toEqual([]); +}); diff --git a/packages/compiler/package.json b/packages/compiler/package.json index a93fdd2a..d9db7966 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/compiler", - "version": "0.8.17", + "version": "0.8.19", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/compiler/src/codegen.ts b/packages/compiler/src/codegen.ts index 25d7d38d..cbdc1a53 100644 --- a/packages/compiler/src/codegen.ts +++ b/packages/compiler/src/codegen.ts @@ -283,6 +283,32 @@ function componentEventAttribute(name: string): string { return `data-wrn-out-${name}`; } +/** Expand bind:value/bind:checked into a reactive prop plus assignment handler. */ +function expandBindings(attrs: Attr[]): Attr[] { + const output: Attr[] = []; + for (const attr of attrs) { + if (attr.event || !attr.name.startsWith("bind:")) { + output.push(attr); + continue; + } + const target = attr.name.slice("bind:".length); + if (target !== "value" && target !== "checked") { + throw new Error(`Unknown .wrn binding '${attr.name}'. Use bind:value or bind:checked.`); + } + const expression = wholeAttributeExpression(attr.value) ?? attr.value.trim(); + if (!/^[A-Za-z_$][\w$]*$/.test(expression)) { + throw new Error(`${attr.name} requires a writable state name, received '${expression}'.`); + } + output.push({ name: target, value: `{${expression}}`, event: false }); + output.push({ + name: target === "checked" ? "change" : "input", + value: `${expression} = payload.${target}`, + event: true, + }); + } + return output; +} + function reactiveAttrValue(raw: string, reactive: PageReactive): string | null { let found = false; const value = raw.replace(/\{([^{}]+)\}/g, (whole, inner: string) => { @@ -305,6 +331,7 @@ function renderAttrs( reactive: PageReactive | null = null, dynamicExpressions?: string[], ): string { + attrs = expandBindings(attrs); let bindIndex = 0; const rendered = attrs .map((attr) => { @@ -463,7 +490,7 @@ function renderLoopBody(node: ViewNode, locals: string[] = []): string { const componentTag = isComponentTag(node.tag); - const attrs = node.attrs + const attrs = expandBindings(node.attrs) .filter((attr) => attr.name !== "data-component") .map((attr) => { const name = attr.event @@ -805,7 +832,7 @@ function renderPageComponentInvocation( const island = islandMarkerFor(node); if (island) return island; - const attrs = node.attrs + const attrs = expandBindings(node.attrs) .filter((attr) => attr.name !== "data-component") .map((attr) => renderPageComponentAttr(attr, loops)) .join(""); @@ -827,7 +854,7 @@ function renderNestedComponentInvocation( let bindIndex = 0; - const attrs = node.attrs + const attrs = expandBindings(node.attrs) .filter((attr) => attr.name !== "data-component") .map((attr) => { const spread = /^\{\.\.\.([A-Za-z_$][\w$]*)\}$/.exec(attr.name); @@ -2251,7 +2278,7 @@ function renderClientControlTemplate(nodes: ViewNode[]): string { const componentTag = isComponentTag(node.tag); let bindIndex = 0; - const attrs = node.attrs + const attrs = expandBindings(node.attrs) .map((attribute) => { const name = attribute.event ? componentTag @@ -2293,24 +2320,27 @@ function encodeClientControl(value: unknown): string { function renderComponentIfNode(node: IfNode, ctx: CompCtx): string { let expression = "``"; + const clientBranches: string[] = []; for (let index = node.branches.length - 1; index >= 0; index--) { const branch = node.branches[index]!; const body = branch.body.map((child) => renderComponentNode(child, ctx)).join(""); const bodyExpression = "`" + body + "`"; + clientBranches.unshift( + `{ cond: ${branch.cond === null ? "null" : JSON.stringify(branch.cond)}, body: ${bodyExpression} }`, + ); + expression = branch.cond === null ? bodyExpression : `(${ctx.resolveExpr(branch.cond)}) ? ${bodyExpression} : ${expression}`; } - const definition = encodeClientControl( - node.branches.map((branch) => ({ - cond: branch.cond, - body: renderClientControlTemplate(branch.body), - })), - ); + // Render all branches on the server into an inert, encoded definition. + // A branch first selected in the browser therefore contains the real nested + // component HTML and scope metadata, not a data-component placeholder. + const definition = `\${__wrnexusEncodeControl([${clientBranches.join(",")}])}`; return `${"${" + expression + "}"}`; } @@ -2489,7 +2519,7 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string { (attribute) => attribute.name === "data-component", ); - const attrs = node.attrs + const attrs = expandBindings(node.attrs) .filter((a) => a.name !== "class" && !a.name.startsWith("class:")) .map((a) => { const spread = /^\{\.\.\.([A-Za-z_$][\w$]*)\}$/.exec(a.name); @@ -3036,6 +3066,9 @@ function __wrnRaw(v: unknown): string { } if (needsScope) { + out.push(`function __wrnexusEncodeControl(value: unknown): string { + return __WrnexusBuffer.from(JSON.stringify(value), "utf8").toString("base64"); +}`); out.push(`function __wrnexusSerializeScopeValue(value: unknown): string { if (value === undefined) { return "undefined"; diff --git a/packages/compiler/test/__snapshots__/resilience.test.ts.snap b/packages/compiler/test/__snapshots__/resilience.test.ts.snap index b61e9545..fd5cd15f 100644 --- a/packages/compiler/test/__snapshots__/resilience.test.ts.snap +++ b/packages/compiler/test/__snapshots__/resilience.test.ts.snap @@ -211,6 +211,10 @@ function __wrnRaw(v: unknown): string { return String(v == null ? "" : v); } +function __wrnexusEncodeControl(value: unknown): string { + return __WrnexusBuffer.from(JSON.stringify(value), "utf8").toString("base64"); +} + function __wrnexusSerializeScopeValue(value: unknown): string { if (value === undefined) { return "undefined"; diff --git a/packages/compiler/test/compiler.test.ts b/packages/compiler/test/compiler.test.ts index 88514fe7..4a95fec9 100644 --- a/packages/compiler/test/compiler.test.ts +++ b/packages/compiler/test/compiler.test.ts @@ -1070,6 +1070,25 @@ component Banner { expect(output).toContain("visible"); expect(output).toContain("Visible"); expect(output).toContain("Hidden"); + expect(output).toContain("__wrnexusEncodeControl"); +}); + +test("bind directives compile to reactive values and assignment handlers", () => { + const output = generate( + parse(`page FormPage { + state name = "" + state active = false + view { + + + + } + }`), + ); + expect(output).not.toContain("bind:value"); + expect(output).not.toContain("bind:checked"); + expect(output).toContain("name = payload.value"); + expect(output).toContain("active = payload.checked"); }); test("if and each blocks emit browser control metadata while preserving SSR", () => { diff --git a/packages/csr/package.json b/packages/csr/package.json index 769ac305..e1c3604b 100644 --- a/packages/csr/package.json +++ b/packages/csr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/csr", - "version": "0.8.29", + "version": "0.8.31", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/csr/src/reactive-runtime.ts b/packages/csr/src/reactive-runtime.ts index d5cc87e0..f8332f21 100644 --- a/packages/csr/src/reactive-runtime.ts +++ b/packages/csr/src/reactive-runtime.ts @@ -1173,6 +1173,7 @@ export const REACTIVE_RUNTIME = String.raw` * lacks one of these does not break the rest. */ if (name === "toast") return toastApi; + if (name === "useFetch") return useFetch; if (boundWindowGlobals[name] && typeof window[name] === "function") { return window[name].bind(window); } @@ -4350,6 +4351,29 @@ export const REACTIVE_RUNTIME = String.raw` }); } + /** + * Public client transport for dynamic API paths. + * + * The apis block remains the typed, declarative choice for fixed routes. This + * helper covers paths assembled at runtime without exposing a private + * framework global to application code. + */ + function useFetch(path, methodOrOptions, input) { + var method = "GET"; + var values = input; + if (typeof methodOrOptions === "string") { + method = methodOrOptions; + } else if (methodOrOptions && typeof methodOrOptions === "object") { + method = methodOrOptions.method || "GET"; + values = method === "GET" || method === "HEAD" + ? (methodOrOptions.query || methodOrOptions.params) + : (methodOrOptions.body === undefined ? methodOrOptions.data : methodOrOptions.body); + } + return wrnexusCallApi(path, method, values); + } + + window.useFetch = useFetch; + // Compatibility for applications compiled before the public helper landed. window.__wrnexusCallApi = wrnexusCallApi; function dispatchComponentEvent(root, name, detail) { diff --git a/packages/csr/test/api-call.test.ts b/packages/csr/test/api-call.test.ts index c97f9abd..968d433e 100644 --- a/packages/csr/test/api-call.test.ts +++ b/packages/csr/test/api-call.test.ts @@ -64,6 +64,21 @@ test("POST sends a JSON body", async () => { ); }); +test("useFetch is the public dynamic-path transport", async () => { + const { calls, win } = harness({ status: 200, payload: { ok: true } }); + const useFetch = ( + win as unknown as { + useFetch: (path: string, options: { method: string; body: unknown }) => Promise; + } + ).useFetch; + + await expect( + useFetch("/api/users/42/test", { method: "POST", body: { dryRun: true } }), + ).resolves.toEqual({ ok: true }); + expect(calls[0]!.url).toBe("/api/users/42/test"); + expect(calls[0]!.init.body).toBe(JSON.stringify({ dryRun: true })); +}); + test("a non-GET request carries the CSRF token from the cookie", async () => { const { callApi, calls, win } = harness({ status: 200, payload: {} }); win.document.cookie = "wrn-csrf=token-123"; diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json index 64d8b3e6..2b0cb7a7 100644 --- a/packages/dev-server/package.json +++ b/packages/dev-server/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-server", - "version": "0.8.48", + "version": "0.8.50", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/dev-server/src/runtime.ts b/packages/dev-server/src/runtime.ts index 02746c55..5995a77b 100644 --- a/packages/dev-server/src/runtime.ts +++ b/packages/dev-server/src/runtime.ts @@ -1,4 +1,5 @@ import { brotliCompressSync, constants as zlibConstants } from "node:zlib"; +import { Buffer } from "node:buffer"; /** * Shared request runtime used by BOTH the dev server and the production server. * @@ -1667,6 +1668,34 @@ export function createHandlers(deps: RuntimeDeps): Handlers { language?: string, depth = 0, ): Promise { + // Control definitions are inert base64 JSON. Resolve component mounts in + // their branch templates too, otherwise a component first selected by + // client state would materialize only an empty data-component placeholder. + const controlPattern = /data-wrn-(?:if|each)="([A-Za-z0-9+/=]+)"/g; + const replacements: Array<{ from: string; to: string }> = []; + for (const match of body.matchAll(controlPattern)) { + try { + const definition = JSON.parse(Buffer.from(match[1]!, "base64").toString("utf8")) as + Array<{ body?: string }> | { body?: string; empty?: string }; + const entries = Array.isArray(definition) ? definition : [definition]; + for (const entry of entries) { + if (typeof entry.body === "string" && entry.body.includes("data-component=")) + entry.body = await renderComponents(entry.body, translate, language, depth + 1); + if ( + "empty" in entry && + typeof entry.empty === "string" && + entry.empty.includes("data-component=") + ) + entry.empty = await renderComponents(entry.empty, translate, language, depth + 1); + } + const encoded = Buffer.from(JSON.stringify(definition), "utf8").toString("base64"); + replacements.push({ from: match[0], to: match[0].replace(match[1]!, encoded) }); + } catch { + // The client reports malformed control metadata with source context. + } + } + for (const replacement of replacements) body = body.replace(replacement.from, replacement.to); + if (depth > 15 || router.components.length === 0 || !body.includes("data-component=")) { return body; } diff --git a/packages/metering/package.json b/packages/metering/package.json new file mode 100644 index 00000000..9a5062f7 --- /dev/null +++ b/packages/metering/package.json @@ -0,0 +1,10 @@ +{ + "name": "@wrnexus/metering", + "version": "0.8.2", + "private": true, + "type": "module", + "main": "src/index.ts", + "exports": { + ".": "./src/index.ts" + } +} diff --git a/packages/metering/src/index.ts b/packages/metering/src/index.ts new file mode 100644 index 00000000..dfa555c3 --- /dev/null +++ b/packages/metering/src/index.ts @@ -0,0 +1,144 @@ +export interface EntitlementPlan { + code: string; + name: string; + features: readonly string[]; + allowance?: number; + price?: number; + [key: string]: unknown; +} + +export interface EntitlementsOptions { + plans: () => Promise | readonly TPlan[]; + subscriptionFor: (subjectId: string) => Promise; + fallback: string; +} + +/** Resolve plans, features, and allowances from one server-owned catalog. */ +export function defineEntitlements( + options: EntitlementsOptions, +) { + const all = async () => [...(await options.plans())]; + const planFor = async (subjectId: string): Promise => { + const plans = await all(); + const requested = await options.subscriptionFor(subjectId); + const plan = + plans.find((entry) => entry.code === requested) ?? + plans.find((entry) => entry.code === options.fallback); + if (!plan) throw new Error(`WRN-ENTITLEMENTS: fallback plan '${options.fallback}' is missing`); + return plan; + }; + return { + plans: all, + planFor, + async enabled(subjectId: string, feature: string) { + return (await planFor(subjectId)).features.includes(feature); + }, + async allowance(subjectId: string) { + return Number((await planFor(subjectId)).allowance ?? 0); + }, + }; +} + +export interface MeterPack { + code: string; + units: number; + [key: string]: unknown; +} + +/** Prototype-safe, immutable pack lookup; clients select a code, never an amount. */ +export function definePacks(entries: readonly TPack[]) { + const packs = new Map>(); + for (const entry of entries) { + if (!entry.code.trim()) throw new TypeError("meter pack code cannot be empty"); + if (!Number.isSafeInteger(entry.units) || entry.units < 1) + throw new RangeError(`meter pack '${entry.code}' units must be a positive safe integer`); + if (packs.has(entry.code)) throw new TypeError(`duplicate meter pack '${entry.code}'`); + packs.set(entry.code, Object.freeze({ ...entry })); + } + return Object.freeze({ + get: (code: string) => packs.get(code), + has: (code: string) => packs.has(code), + list: () => [...packs.values()], + }); +} + +export type MeterResult = { ok: true } | { ok: false; reason: string; fault?: true }; +export type MeterKind = "grant" | "purchase" | "refund" | "adjust"; + +export interface MeterStore { + balance(subjectId: string): Promise; + reserve(subjectId: string, units: number, reason: string, reference: string): Promise; + write( + subjectId: string, + units: number, + kind: MeterKind, + reason: string, + reference: string, + ): Promise; +} + +export interface MeterOptions { + store: MeterStore; + label?: string; + onFault?: (error: unknown, operation: string) => void; +} + +/** Non-throwing metering facade with validated units and explicit storage faults. */ +export function defineMeter(options: MeterOptions) { + const label = options.label ?? "units"; + const valid = (units: number): MeterResult | null => + Number.isSafeInteger(units) && units > 0 + ? null + : { ok: false, reason: `${label} must be a positive whole number` }; + const fault = (error: unknown, operation: string): MeterResult => { + options.onFault?.(error, operation); + return { + ok: false, + reason: error instanceof Error ? error.message : String(error), + fault: true, + }; + }; + const add = async ( + kind: MeterKind, + subjectId: string, + units: number, + reason: string, + reference = "", + ): Promise => { + const invalid = valid(units); + if (invalid) return invalid; + try { + await options.store.write(subjectId, units, kind, reason, reference); + return { ok: true }; + } catch (error) { + return fault(error, kind); + } + }; + return { + balance: options.store.balance, + async reserve( + subjectId: string, + units: number, + reason: string, + reference = "", + ): Promise { + const invalid = valid(units); + if (invalid) return invalid; + try { + return (await options.store.reserve(subjectId, units, reason, reference)) + ? { ok: true } + : { ok: false, reason: "insufficient balance" }; + } catch (error) { + return fault(error, "reserve"); + } + }, + grant: (subjectId: string, units: number, reason: string, reference = "") => + add("grant", subjectId, units, reason, reference), + purchase: (subjectId: string, units: number, reason: string, reference = "") => + add("purchase", subjectId, units, reason, reference), + refund: (subjectId: string, units: number, reason: string, reference = "") => + add("refund", subjectId, units, reason, reference), + adjust: (subjectId: string, units: number, reason: string, reference = "") => + add("adjust", subjectId, units, reason, reference), + }; +} diff --git a/packages/metering/test/metering.test.ts b/packages/metering/test/metering.test.ts new file mode 100644 index 00000000..d6ada45d --- /dev/null +++ b/packages/metering/test/metering.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from "bun:test"; +import { defineEntitlements, defineMeter, definePacks } from "../src/index.ts"; + +test("resolves fallback entitlements and features", async () => { + const entitlements = defineEntitlements({ + plans: () => [ + { code: "free", name: "Free", features: [], allowance: 10 }, + { code: "pro", name: "Pro", features: ["schedule"], allowance: 100 }, + ], + subscriptionFor: async () => "pro", + fallback: "free", + }); + expect(await entitlements.enabled("u1", "schedule")).toBe(true); + expect(await entitlements.allowance("u1")).toBe(100); +}); + +test("packs are immutable and selected only by code", () => { + const packs = definePacks([{ code: "small", units: 100, label: "Small" }]); + expect(packs.get("constructor")).toBeUndefined(); + expect(packs.get("small")?.units).toBe(100); +}); + +test("meter distinguishes refusals from storage faults", async () => { + const meter = defineMeter({ + store: { + balance: async () => 5, + reserve: async (_subject, units) => units <= 5, + write: async () => { + throw new Error("offline"); + }, + }, + }); + expect(await meter.reserve("u1", 6, "send")).toEqual({ + ok: false, + reason: "insufficient balance", + }); + expect(await meter.grant("u1", 5, "plan")).toEqual({ + ok: false, + reason: "offline", + fault: true, + }); +}); diff --git a/packages/typecheck/package.json b/packages/typecheck/package.json index 02f2c03c..5274d388 100644 --- a/packages/typecheck/package.json +++ b/packages/typecheck/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/typecheck", - "version": "0.8.11", + "version": "0.8.13", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/typecheck/src/index.ts b/packages/typecheck/src/index.ts index ede56998..2a12022a 100644 --- a/packages/typecheck/src/index.ts +++ b/packages/typecheck/src/index.ts @@ -307,6 +307,9 @@ export function virtualTypeScriptModule( if (ast.dataApis.length) append(`declare const api: { ${apiType} };`); append(`declare const props: Readonly<${ast.name}Props>;`); append("declare const refs: Record;"); + append( + "declare function useFetch(path: string, method?: string | { method?: string; query?: unknown; params?: unknown; body?: unknown; data?: unknown }, input?: unknown): Promise;", + ); append( ast.kind === "global-store" || ast.kind === "page-store" ? storeContract(ast) @@ -332,6 +335,59 @@ export function virtualTypeScriptModule( for (const fn of functions) append(functionDeclaration(fn), fn.source); append("}"); } + const viewFunctions = new Map(); + for (const fn of ast.runtimeFunctions) { + if (fn.runtime !== "server") viewFunctions.set(fn.name, fn.runtime); + } + for (const [name, runtime] of viewFunctions) { + append(`declare const ${name}: typeof ${runtimeNamespace(runtime)}.${name};`); + } + + // View expressions execute in the client scope, but historically were not + // represented in the virtual TypeScript module. Validate them here so a + // misspelled state/function name cannot silently become `undefined` at + // runtime. Each/handler locals mirror the browser evaluator's bindings. + let bindingIndex = 0; + const appendViewBindings = (nodes: ViewNode[], locals: Set): void => { + for (const node of nodes) { + if (node.type === "element") { + for (const attr of node.attrs) { + const expressions = attr.event + ? [attr.value] + : [...attr.value.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1]!.trim()); + for (const expression of expressions) { + if (!expression.trim()) continue; + const declarations = [ + ...[...locals].map((name) => `declare const ${name}: any;`), + ...(attr.event ? ["declare const payload: any;", "declare const event: Event;"] : []), + ].join(" "); + append( + `namespace __wrn_view_${bindingIndex++} { ${declarations} void (${expression}); }`, + attr.value, + ); + } + } + appendViewBindings(node.children, locals); + } else if (node.type === "if") { + for (const branch of node.branches) { + if (branch.cond) + append( + `namespace __wrn_view_${bindingIndex++} { void (${branch.cond}); }`, + branch.cond, + ); + appendViewBindings(branch.body, locals); + } + } else if (node.type === "each") { + append(`namespace __wrn_view_${bindingIndex++} { void (${node.list}); }`, node.list); + const nested = new Set(locals); + nested.add(node.item); + if (node.index) nested.add(node.index); + appendViewBindings(node.body, nested); + appendViewBindings(node.empty, locals); + } + } + }; + appendViewBindings(ast.view, new Set()); return { ast, @@ -464,7 +520,9 @@ function componentUsageDiagnostics( const shape = shapes.get(node.tag); if (!shape) return; const attributes = new Map( - node.attrs.filter((attr) => !attr.event).map((attr) => [attr.name, attr]), + node.attrs + .filter((attr) => !attr.event) + .map((attr) => [attr.name.startsWith("bind:") ? attr.name.slice(5) : attr.name, attr]), ); const outputNames = new Set(shape.outputs.map((output) => output.name)); const position = lineAt(source, `<${node.tag}`); @@ -501,7 +559,8 @@ function componentUsageDiagnostics( continue; } if (/^(?:class|id|style|slot|data-|aria-)/.test(attr.name) || attr.name === "attrs") continue; - const prop = known.get(attr.name); + const propName = attr.name.startsWith("bind:") ? attr.name.slice(5) : attr.name; + const prop = known.get(propName); if (!prop) { diagnostics.push({ code: "WRN-COMPONENT-UNKNOWN-PROP", diff --git a/packages/typecheck/test/typecheck.test.ts b/packages/typecheck/test/typecheck.test.ts index 40f7686f..55708990 100644 --- a/packages/typecheck/test/typecheck.test.ts +++ b/packages/typecheck/test/typecheck.test.ts @@ -85,3 +85,30 @@ test("provides the framework context to page state expressions", () => { ); expect(diagnostics.some((diagnostic) => diagnostic.code === "WRN-TYPE-2304")).toBe(false); }); + +test("rejects an out-of-scope name in a view event handler", () => { + const root = app(); + const diagnostics = checkWrnSource( + `page AccountPage { + functions { client function save() {} } + view { } + }`, + { appRoot: root, filePath: join(root, "app", "pages", "account.wrn") }, + ); + expect(diagnostics.some((diagnostic) => diagnostic.code === "WRN-TYPE-2304")).toBe(true); +}); + +test("accepts handler payload, loop locals, and the public useFetch helper", () => { + const root = app(); + const diagnostics = checkWrnSource( + `page AccountPage { + state rows = [{ id: "1" }] + functions { + client async function save(id: string) { await useFetch("/api/rows/" + id, "POST", { id }) } + } + view { {#each rows as row}{/each} } + }`, + { appRoot: root, filePath: join(root, "app", "pages", "account.wrn") }, + ); + expect(diagnostics.filter((diagnostic) => diagnostic.code === "WRN-TYPE-2304")).toEqual([]); +});