feat: close application architecture gaps
This commit is contained in:
@@ -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"],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.8.55",
|
||||
"version": "0.8.57",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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<void> {
|
||||
const child = Bun.spawn(["bun", "run", name], {
|
||||
@@ -20,6 +21,7 @@ export async function runCheck(appRoot: string): Promise<void> {
|
||||
const manifest = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")) as {
|
||||
scripts?: Record<string, string>;
|
||||
};
|
||||
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");
|
||||
|
||||
@@ -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")}`,
|
||||
);
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/compiler",
|
||||
"version": "0.8.17",
|
||||
"version": "0.8.19",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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 `<template data-wrn-if="${definition}"></template>${"${" + expression + "}"}<template data-wrn-control-end></template>`;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
<Input bind:value={name} />
|
||||
<input bind:value={name} />
|
||||
<Switch bind:checked={active} />
|
||||
}
|
||||
}`),
|
||||
);
|
||||
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", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.8.29",
|
||||
"version": "0.8.31",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<unknown>;
|
||||
}
|
||||
).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";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.8.48",
|
||||
"version": "0.8.50",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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<string> {
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@wrnexus/metering",
|
||||
"version": "0.8.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./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<TPlan extends EntitlementPlan> {
|
||||
plans: () => Promise<readonly TPlan[]> | readonly TPlan[];
|
||||
subscriptionFor: (subjectId: string) => Promise<string | null | undefined>;
|
||||
fallback: string;
|
||||
}
|
||||
|
||||
/** Resolve plans, features, and allowances from one server-owned catalog. */
|
||||
export function defineEntitlements<TPlan extends EntitlementPlan>(
|
||||
options: EntitlementsOptions<TPlan>,
|
||||
) {
|
||||
const all = async () => [...(await options.plans())];
|
||||
const planFor = async (subjectId: string): Promise<TPlan> => {
|
||||
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<TPack extends MeterPack>(entries: readonly TPack[]) {
|
||||
const packs = new Map<string, Readonly<TPack>>();
|
||||
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<number>;
|
||||
reserve(subjectId: string, units: number, reason: string, reference: string): Promise<boolean>;
|
||||
write(
|
||||
subjectId: string,
|
||||
units: number,
|
||||
kind: MeterKind,
|
||||
reason: string,
|
||||
reference: string,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
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<MeterResult> => {
|
||||
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<MeterResult> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/typecheck",
|
||||
"version": "0.8.11",
|
||||
"version": "0.8.13",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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<string, Element | null>;");
|
||||
append(
|
||||
"declare function useFetch(path: string, method?: string | { method?: string; query?: unknown; params?: unknown; body?: unknown; data?: unknown }, input?: unknown): Promise<any>;",
|
||||
);
|
||||
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<string, RuntimeFunctionDecl["runtime"]>();
|
||||
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<string>): 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",
|
||||
|
||||
@@ -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 { <button @click="savve(missingValue)"></button> }
|
||||
}`,
|
||||
{ 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}<button @click="save(row.id)"></button>{/each} }
|
||||
}`,
|
||||
{ appRoot: root, filePath: join(root, "app", "pages", "account.wrn") },
|
||||
);
|
||||
expect(diagnostics.filter((diagnostic) => diagnostic.code === "WRN-TYPE-2304")).toEqual([]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user