release: WRNexusJS 0.6.0
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "@wrnexus/compiler",
|
||||
"version": "0.5.14",
|
||||
"version": "0.6.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/syntax": "workspace:*"
|
||||
"@wrnexus/syntax": "workspace:*",
|
||||
"@wrnexus/store": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { eraseFunctionTypes, type PageAst, type RuntimeFunctionDecl } from "@wrnexus/syntax";
|
||||
|
||||
const RESERVED_BINDINGS = new Set([
|
||||
"await",
|
||||
"break",
|
||||
"case",
|
||||
"catch",
|
||||
"class",
|
||||
"const",
|
||||
"continue",
|
||||
"debugger",
|
||||
"default",
|
||||
"delete",
|
||||
"do",
|
||||
"else",
|
||||
"enum",
|
||||
"export",
|
||||
"extends",
|
||||
"false",
|
||||
"finally",
|
||||
"for",
|
||||
"function",
|
||||
"if",
|
||||
"implements",
|
||||
"import",
|
||||
"in",
|
||||
"instanceof",
|
||||
"interface",
|
||||
"let",
|
||||
"new",
|
||||
"null",
|
||||
"package",
|
||||
"private",
|
||||
"protected",
|
||||
"public",
|
||||
"return",
|
||||
"static",
|
||||
"super",
|
||||
"switch",
|
||||
"this",
|
||||
"throw",
|
||||
"true",
|
||||
"try",
|
||||
"typeof",
|
||||
"var",
|
||||
"void",
|
||||
"while",
|
||||
"with",
|
||||
"yield",
|
||||
]);
|
||||
const RUNTIME_BINDINGS = new Set([
|
||||
"context",
|
||||
"state",
|
||||
"output",
|
||||
"server",
|
||||
"props",
|
||||
"refs",
|
||||
"event",
|
||||
"payload",
|
||||
]);
|
||||
|
||||
function safeIdentifier(name: string): boolean {
|
||||
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) && !RESERVED_BINDINGS.has(name);
|
||||
}
|
||||
|
||||
function functionEntry(
|
||||
ast: PageAst,
|
||||
fn: RuntimeFunctionDecl,
|
||||
availableFunctions: string[],
|
||||
): string {
|
||||
const parameterNames = new Set(fn.parameters.map((parameter) => parameter.name));
|
||||
const stateNames = ast.states
|
||||
.filter(
|
||||
(state) =>
|
||||
state.runtime !== "server" &&
|
||||
safeIdentifier(state.name) &&
|
||||
!RUNTIME_BINDINGS.has(state.name) &&
|
||||
!parameterNames.has(state.name),
|
||||
)
|
||||
.map((state) => state.name);
|
||||
const stateSet = new Set(stateNames);
|
||||
const propNames = ast.props
|
||||
.filter(
|
||||
(prop) =>
|
||||
safeIdentifier(prop.name) &&
|
||||
!RUNTIME_BINDINGS.has(prop.name) &&
|
||||
!parameterNames.has(prop.name) &&
|
||||
!stateSet.has(prop.name),
|
||||
)
|
||||
.map((prop) => prop.name);
|
||||
const functionAliases = availableFunctions.filter(
|
||||
(name) =>
|
||||
safeIdentifier(name) &&
|
||||
!RUNTIME_BINDINGS.has(name) &&
|
||||
!parameterNames.has(name) &&
|
||||
!stateSet.has(name) &&
|
||||
!propNames.includes(name),
|
||||
);
|
||||
const parameters = fn.parameters.map((parameter) => parameter.name).join(", ");
|
||||
const initialStateSnapshot = stateNames.length
|
||||
? `const __wrnexusInitialState = { ${stateNames.map((name) => `${JSON.stringify(name)}: context.state.${name}`).join(", ")} };`
|
||||
: "";
|
||||
const stateAliases = stateNames.length ? `let { ${stateNames.join(", ")} } = context.state;` : "";
|
||||
const propAliases = propNames.length ? `const { ${propNames.join(", ")} } = context.props;` : "";
|
||||
const syncStateToContext = stateNames.map((name) => `context.state.${name} = ${name};`).join(" ");
|
||||
const syncStateFromContext = stateNames
|
||||
.map((name) => `${name} = context.state.${name};`)
|
||||
.join(" ");
|
||||
const peerAliases = functionAliases
|
||||
.map((name) => {
|
||||
const call = `context.functions[${JSON.stringify(name)}](...__wrnexusPeerArgs)`;
|
||||
if (!stateNames.length) {
|
||||
return `const ${name} = (...__wrnexusPeerArgs) => ${call};`;
|
||||
}
|
||||
return `const ${name} = (...__wrnexusPeerArgs) => {
|
||||
${syncStateToContext}
|
||||
let __wrnexusPeerResult;
|
||||
try {
|
||||
__wrnexusPeerResult = ${call};
|
||||
} catch (__wrnexusPeerError) {
|
||||
${syncStateFromContext}
|
||||
throw __wrnexusPeerError;
|
||||
}
|
||||
if (__wrnexusPeerResult && typeof __wrnexusPeerResult.then === "function") {
|
||||
return Promise.resolve(__wrnexusPeerResult).finally(() => { ${syncStateFromContext} });
|
||||
}
|
||||
${syncStateFromContext}
|
||||
return __wrnexusPeerResult;
|
||||
};`;
|
||||
})
|
||||
.join("\n");
|
||||
const copyBack = stateNames
|
||||
.map(
|
||||
(name) =>
|
||||
`if (!Object.is(${name}, __wrnexusInitialState[${JSON.stringify(name)}])) context.state.${name} = ${name};`,
|
||||
)
|
||||
.join("\n");
|
||||
const body = eraseFunctionTypes(fn.body);
|
||||
const runtimeBindings = [
|
||||
!parameterNames.has("output") ? "const output = context.output;" : "",
|
||||
!parameterNames.has("server") ? "const server = context.server;" : "",
|
||||
!parameterNames.has("props") ? "const props = context.props;" : "",
|
||||
!parameterNames.has("refs") ? "const refs = context.refs;" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n ");
|
||||
return `${JSON.stringify(fn.name)}: ${fn.async ? "async " : ""}function(context${parameters ? `, ${parameters}` : ""}) {
|
||||
${initialStateSnapshot}
|
||||
${stateAliases}
|
||||
${propAliases}
|
||||
${peerAliases}
|
||||
${runtimeBindings}
|
||||
try {
|
||||
${body}
|
||||
} finally {
|
||||
${copyBack}
|
||||
}
|
||||
}`;
|
||||
}
|
||||
|
||||
export function generateBrowserModule(ast: PageAst): string {
|
||||
const functions = ast.runtimeFunctions.filter((fn) =>
|
||||
["legacy", "client", "shared"].includes(fn.runtime),
|
||||
);
|
||||
const functionNames = functions.map((fn) => fn.name);
|
||||
const state = ast.states.filter((entry) => entry.runtime !== "server").map((entry) => entry.name);
|
||||
const storeImports = ast.structuredImports.filter(
|
||||
(entry) =>
|
||||
!entry.typeOnly && entry.source.endsWith(".wrn") && /(?:^|\/)stores?\//.test(entry.source),
|
||||
);
|
||||
const imports = ast.structuredImports
|
||||
.filter((entry) => !entry.typeOnly)
|
||||
.filter((entry) => !entry.source.endsWith(".wrn") || /(?:^|\/)stores?\//.test(entry.source))
|
||||
.map((entry) => entry.raw)
|
||||
.join("\n");
|
||||
const importedBindings = storeImports
|
||||
.flatMap((entry) => [
|
||||
...(entry.defaultImport ? [entry.defaultImport] : []),
|
||||
...(entry.namespaceImport ? [entry.namespaceImport] : []),
|
||||
...entry.namedImports.map((item) => item.local),
|
||||
])
|
||||
.filter(safeIdentifier);
|
||||
return `// generated WRNexusJS browser module for ${ast.name}
|
||||
${imports}
|
||||
export const __wrnexusClientFunctions = {
|
||||
${functions.map((fn) => ` ${functionEntry(ast, fn, functionNames)}`).join(",\n")}
|
||||
};
|
||||
export const __wrnexusClientState = ${JSON.stringify(state)};
|
||||
export const __wrnexusOutputs = ${JSON.stringify(ast.outputs)};
|
||||
export const __wrnexusImportedBindings = { ${importedBindings.join(", ")} };
|
||||
export function bindClientScope(context) {
|
||||
const functions = {};
|
||||
const scopedContext = { ...context, functions };
|
||||
for (const [name, handler] of Object.entries(__wrnexusClientFunctions)) {
|
||||
functions[name] = (...args) => handler(scopedContext, ...args);
|
||||
}
|
||||
return functions;
|
||||
}
|
||||
`;
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import { Buffer } from "node:buffer";
|
||||
|
||||
import { VOID_ELEMENTS, type Attr, type DataMode, type PageAst, type ViewNode } from "./parser.ts";
|
||||
import { eraseFunctionTypes, runtimeTypeOf } from "./types.ts";
|
||||
import { stripRuntimeFunctionModifiers } from "@wrnexus/syntax";
|
||||
import { generateStoreModule } from "./store-codegen.ts";
|
||||
|
||||
interface RenderBinding {
|
||||
method: string;
|
||||
@@ -765,6 +767,38 @@ function localStyleExport(ast: PageAst, styles: string[]): string | null {
|
||||
)};`;
|
||||
}
|
||||
|
||||
function isStoreImportSource(source: string): boolean {
|
||||
return /(?:^|\/)stores?\//.test(source) || source.startsWith("@wrnexus/store");
|
||||
}
|
||||
|
||||
function importedStoreBindings(ast: PageAst): Array<{ local: string; internal: string }> {
|
||||
return ast.structuredImports
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.defaultImport && entry.source.endsWith(".wrn") && isStoreImportSource(entry.source),
|
||||
)
|
||||
.map((entry) => ({
|
||||
local: entry.defaultImport!,
|
||||
internal: `__wrnexusStoreDefinition_${entry.defaultImport}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function generatedImports(ast: PageAst): string[] {
|
||||
const stores = new Map(importedStoreBindings(ast).map((entry) => [entry.local, entry.internal]));
|
||||
return ast.structuredImports.map((entry) => {
|
||||
if (!entry.defaultImport) return entry.raw;
|
||||
const internal = stores.get(entry.defaultImport);
|
||||
return internal
|
||||
? entry.raw.replace(
|
||||
new RegExp(
|
||||
`^(\\s*import\\s+(?:type\\s+)?)(?:${entry.defaultImport.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")})(\\s+from\\s+)`,
|
||||
),
|
||||
`$1${internal}$2`,
|
||||
)
|
||||
: entry.raw;
|
||||
});
|
||||
}
|
||||
|
||||
function isSafeGeneratedIdentifier(name: string): boolean {
|
||||
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name);
|
||||
}
|
||||
@@ -781,22 +815,47 @@ function generateSsrStateAliases(stateNames: string[]): string {
|
||||
|
||||
function hydrationAttribute(ast: PageAst): string {
|
||||
const strategy = ast.hydrate ?? "load";
|
||||
return ` data-wrn-hydration="${attrEscape(hydrationId(ast))}" data-wrn-hydrate="${attrEscape(strategy)}" data-wrn-runtime="${attrEscape(ast.runtime ?? "universal")}"`;
|
||||
const hasBrowserModule = ast.runtimeFunctions.some((fn) =>
|
||||
["legacy", "client", "shared"].includes(fn.runtime),
|
||||
);
|
||||
const moduleAttribute = hasBrowserModule
|
||||
? ' data-wrn-client-module="__WRNEXUS_CLIENT_MODULE__"'
|
||||
: "";
|
||||
return ` data-wrn-hydration="${attrEscape(hydrationId(ast))}" data-wrn-hydrate="${attrEscape(strategy)}" data-wrn-runtime="${attrEscape(ast.runtime ?? "universal")}"${moduleAttribute}`;
|
||||
}
|
||||
|
||||
function targetFunctions(ast: PageAst, target: "browser" | "server"): string {
|
||||
const runtimes =
|
||||
target === "browser"
|
||||
? (["legacy", "client", "shared"] as const)
|
||||
: (["legacy", "server", "shared"] as const);
|
||||
return ast.functions
|
||||
.map((body) => stripRuntimeFunctionModifiers(body, [...runtimes]))
|
||||
.map((body) => body.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
function publicOutputNames(ast: PageAst): string[] {
|
||||
return [
|
||||
...new Set([
|
||||
...ast.outputs.map((output) => output.name),
|
||||
...ast.events.map((event) => event.name),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
export function generate(ast: PageAst): string {
|
||||
if (ast.kind === "global-store" || ast.kind === "page-store") return generateStoreModule(ast);
|
||||
if (ast.kind === "component" || ast.kind === "layout") {
|
||||
return generateComponent(ast);
|
||||
}
|
||||
|
||||
const out: string[] = [];
|
||||
if (ast.imports.length > 0) out.push(ast.imports.join("\n"));
|
||||
if (ast.imports.length > 0) out.push(generatedImports(ast).join("\n"));
|
||||
const ssrBindings: SsrBinding[] = [];
|
||||
const csrBindings: CsrBinding[] = [];
|
||||
const helpers = ast.functions
|
||||
.map((body) => body.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
const helpers = targetFunctions(ast, "server");
|
||||
const apiBindings = apiBindingMap(ast, helpers);
|
||||
|
||||
const typeSource = ast.types
|
||||
@@ -811,7 +870,10 @@ export function generate(ast: PageAst): string {
|
||||
|
||||
// --- Page metadata / SEO ---
|
||||
out.push(`export const meta = ${JSON.stringify({ title: ast.name, ...ast.seo }, null, 2)};`);
|
||||
if (ast.layout) out.push(`export const layout = ${JSON.stringify(ast.layout)};`);
|
||||
if (ast.layout)
|
||||
out.push(
|
||||
`export const layout = ${ast.layoutIsSymbol ? ast.layout : JSON.stringify(ast.layout)};`,
|
||||
);
|
||||
out.push(`export const __wrnexusRuntime = ${JSON.stringify(ast.runtime ?? "universal")};`);
|
||||
out.push(`export const __wrnexusHydrate = ${JSON.stringify(ast.hydrate ?? "load")};`);
|
||||
out.push(`export const __wrnexusHydrationId = ${JSON.stringify(hydrationId(ast))};`);
|
||||
@@ -820,6 +882,7 @@ export function generate(ast: PageAst): string {
|
||||
}
|
||||
|
||||
// --- View -> default page component ---
|
||||
const browserStates = ast.states.filter((state) => state.runtime !== "server");
|
||||
const seedScope = evalStateSeeds(ast.states);
|
||||
for (const entry of ast.computed) {
|
||||
try {
|
||||
@@ -831,7 +894,7 @@ export function generate(ast: PageAst): string {
|
||||
}
|
||||
}
|
||||
const reactiveNames = [
|
||||
...ast.states.map((entry) => entry.name),
|
||||
...browserStates.map((entry) => entry.name),
|
||||
...ast.computed.map((entry) => entry.name),
|
||||
];
|
||||
const runtimeStateNames = new Set(
|
||||
@@ -849,7 +912,7 @@ export function generate(ast: PageAst): string {
|
||||
const pageBehavior = ast.runtime === "server" ? null : componentBehavior(ast);
|
||||
const needsClientRuntime =
|
||||
ast.runtime !== "server" &&
|
||||
(ast.states.length > 0 ||
|
||||
(browserStates.length > 0 ||
|
||||
ast.computed.length > 0 ||
|
||||
hasClientBehavior(ast.view) ||
|
||||
pageBehavior !== null);
|
||||
@@ -887,9 +950,17 @@ export function generate(ast: PageAst): string {
|
||||
.map((state) => `${JSON.stringify(state.name)}: ${state.valueType ?? "unknown"}`)
|
||||
.join("; ")} }`
|
||||
: "Record<string, never>";
|
||||
const hydrationStateNames = JSON.stringify(browserStates.map((state) => state.name));
|
||||
|
||||
const ssrStateAliases = generateSsrStateAliases(ast.states.map((state) => state.name));
|
||||
|
||||
const storeBindings = importedStoreBindings(ast);
|
||||
const storeDeclarations = storeBindings
|
||||
.map(
|
||||
(entry) => ` const ${entry.local} = await ctx.__wrnexusUseStore(${entry.internal});`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
loops.forEach((code, idx) => {
|
||||
body = body.replace(`\x00WRNEACH${idx}\x00`, () => code);
|
||||
});
|
||||
@@ -918,10 +989,12 @@ export function generate(ast: PageAst): string {
|
||||
const decls = loopConsts.length > 0 ? loopConsts.join("\n") + "\n" : "";
|
||||
out.push(
|
||||
`export default async function ${ast.name}(ctx: any) {
|
||||
${storeDeclarations}
|
||||
${decls}
|
||||
const __state: ${stateType} = { ${dynamicStateScope} };
|
||||
${ssrStateAliases}
|
||||
const __scopeValue = Object.entries(__state)
|
||||
const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, (__state as any)[key]]));
|
||||
const __scopeValue = Object.entries(__hydrationState)
|
||||
.map(([key, value]) => {
|
||||
const encoded =
|
||||
typeof value === "number" || typeof value === "boolean"
|
||||
@@ -946,10 +1019,12 @@ export function generate(ast: PageAst): string {
|
||||
);
|
||||
} else {
|
||||
out.push(
|
||||
`export default function ${ast.name}(ctx: any) {
|
||||
`export default ${storeBindings.length > 0 ? "async " : ""}function ${ast.name}(ctx: any) {
|
||||
${storeDeclarations}
|
||||
const __state: ${stateType} = { ${dynamicStateScope} };
|
||||
${ssrStateAliases}
|
||||
const __scopeValue = Object.entries(__state)
|
||||
const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, (__state as any)[key]]));
|
||||
const __scopeValue = Object.entries(__hydrationState)
|
||||
.map(([key, value]) => {
|
||||
const encoded =
|
||||
typeof value === "number" || typeof value === "boolean"
|
||||
@@ -1062,6 +1137,10 @@ interface CompCtx {
|
||||
|
||||
interface ComponentBehavior {
|
||||
functions: string;
|
||||
outputs: Array<{
|
||||
name: string;
|
||||
payload?: { name: string; valueType: string; optional: boolean };
|
||||
}>;
|
||||
computed: Array<{ name: string; expr: string }>;
|
||||
effects: string[];
|
||||
lifecycle: {
|
||||
@@ -1150,12 +1229,7 @@ function escLit(s: string): string {
|
||||
}
|
||||
|
||||
function componentBehavior(ast: PageAst): ComponentBehavior | null {
|
||||
const functions = eraseFunctionTypes(
|
||||
ast.functions
|
||||
.map((body) => body.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n\n"),
|
||||
);
|
||||
const functions = eraseFunctionTypes(targetFunctions(ast, "browser"));
|
||||
|
||||
const computed = ast.computed.map((entry) => ({ name: entry.name, expr: entry.expr.trim() }));
|
||||
const effects = ast.effects.map((entry) => entry.body.trim()).filter(Boolean);
|
||||
@@ -1175,6 +1249,7 @@ function componentBehavior(ast: PageAst): ComponentBehavior | null {
|
||||
|
||||
if (
|
||||
!functions &&
|
||||
ast.outputs.length === 0 &&
|
||||
computed.length === 0 &&
|
||||
effects.length === 0 &&
|
||||
Object.keys(lifecycle).length === 0 &&
|
||||
@@ -1185,6 +1260,7 @@ function componentBehavior(ast: PageAst): ComponentBehavior | null {
|
||||
|
||||
return {
|
||||
functions,
|
||||
outputs: ast.outputs,
|
||||
computed,
|
||||
effects,
|
||||
lifecycle,
|
||||
@@ -1613,7 +1689,7 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string {
|
||||
|
||||
function generateComponent(ast: PageAst): string {
|
||||
const out: string[] = [];
|
||||
if (ast.imports.length > 0) out.push(ast.imports.join("\n"));
|
||||
if (ast.imports.length > 0) out.push(generatedImports(ast).join("\n"));
|
||||
const hasServerEach = viewHasServerEach(ast.view);
|
||||
|
||||
const effectiveProps =
|
||||
@@ -1629,8 +1705,9 @@ function generateComponent(ast: PageAst): string {
|
||||
]
|
||||
: ast.props;
|
||||
|
||||
const browserStates = ast.states.filter((state) => state.runtime !== "server");
|
||||
const stateNames = new Set([
|
||||
...ast.states.map((entry) => entry.name),
|
||||
...browserStates.map((entry) => entry.name),
|
||||
...ast.computed.map((entry) => entry.name),
|
||||
]);
|
||||
const nameRefs = new Map<string, string>();
|
||||
@@ -1652,21 +1729,13 @@ function generateComponent(ast: PageAst): string {
|
||||
const ctx: CompCtx = {
|
||||
stateNames,
|
||||
functionNames: new Set(
|
||||
ast.functions.flatMap((body) => {
|
||||
return Array.from(
|
||||
body.matchAll(/(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/g),
|
||||
(match) => match[1]!,
|
||||
);
|
||||
}),
|
||||
ast.runtimeFunctions.filter((fn) => fn.runtime !== "server").map((fn) => fn.name),
|
||||
),
|
||||
resolveExpr,
|
||||
eventNames: ast.events.map((event) => event.name),
|
||||
eventNames: publicOutputNames(ast),
|
||||
};
|
||||
|
||||
const serverFunctions = ast.functions
|
||||
.map((body) => body.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
const serverFunctions = targetFunctions(ast, "server");
|
||||
|
||||
const hasExplicitRestSpread = viewHasRestAttributeSpread(ast.view);
|
||||
const rootElementIndex = ast.view.findIndex((node) => node.type === "element");
|
||||
@@ -1694,7 +1763,7 @@ function generateComponent(ast: PageAst): string {
|
||||
|
||||
const needsScope =
|
||||
ast.runtime !== "server" &&
|
||||
(ast.states.length > 0 ||
|
||||
(browserStates.length > 0 ||
|
||||
ast.computed.length > 0 ||
|
||||
viewHasEvents(ast.view) ||
|
||||
behavior !== null);
|
||||
@@ -1705,7 +1774,7 @@ function generateComponent(ast: PageAst): string {
|
||||
|
||||
const scopeKeys = [
|
||||
...effectiveProps.map((prop) => prop.name),
|
||||
...ast.states.map((state) => state.name),
|
||||
...browserStates.map((state) => state.name),
|
||||
];
|
||||
|
||||
const behaviorAttr = behaviorAttribute(behavior);
|
||||
@@ -1791,6 +1860,17 @@ function generateComponent(ast: PageAst): string {
|
||||
);
|
||||
}
|
||||
|
||||
if (ast.outputs.length > 0) {
|
||||
out.push(
|
||||
`export interface ${ast.name}Outputs {\n${ast.outputs
|
||||
.map(
|
||||
(output) =>
|
||||
` ${JSON.stringify(output.name)}(${output.payload ? `${output.payload.name}${output.payload.optional ? "?" : ""}: ${output.payload.valueType}` : ""}): void;`,
|
||||
)
|
||||
.join("\n")}\n}`,
|
||||
);
|
||||
}
|
||||
|
||||
out.push(`function __coerce(v: any, def: any, declared: string = "unknown"): any {
|
||||
if (v === undefined || v === null) {
|
||||
return def;
|
||||
@@ -2023,6 +2103,9 @@ function __wireRaw(v: any): string {
|
||||
`}`,
|
||||
);
|
||||
|
||||
out.push(
|
||||
`export default { name: ${JSON.stringify(ast.name)}, kind: ${JSON.stringify(ast.kind)}, render };`,
|
||||
);
|
||||
return out.join("\n\n") + "\n";
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { PageAst } from "@wrnexus/syntax";
|
||||
|
||||
export interface ComponentContractMetadata {
|
||||
name: string;
|
||||
kind: PageAst["kind"];
|
||||
props: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
default?: string;
|
||||
options?: string[];
|
||||
}>;
|
||||
outputs: Array<{ name: string; payloadName?: string; payloadType?: string }>;
|
||||
functions: Array<{
|
||||
name: string;
|
||||
runtime: string;
|
||||
async: boolean;
|
||||
parameters: Array<{ name: string; type: string; optional: boolean }>;
|
||||
returnType: string;
|
||||
}>;
|
||||
states: Array<{ name: string; runtime: string; type: string; initializer: string }>;
|
||||
computed: Array<{ name: string; type: string; expression: string }>;
|
||||
imports: Array<{
|
||||
source: string;
|
||||
typeOnly: boolean;
|
||||
defaultImport?: string;
|
||||
namedImports: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
function unionOptions(type: string | undefined): string[] | undefined {
|
||||
if (!type || !type.includes("|")) return undefined;
|
||||
const values = type
|
||||
.split("|")
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => /^(?:"[^"]*"|'[^']*')$/.test(part))
|
||||
.map((part) => part.slice(1, -1));
|
||||
return values.length ? values : undefined;
|
||||
}
|
||||
|
||||
export function createComponentContract(ast: PageAst): ComponentContractMetadata {
|
||||
return {
|
||||
name: ast.name,
|
||||
kind: ast.kind,
|
||||
props: ast.props.map((prop) => ({
|
||||
name: prop.name,
|
||||
type: prop.valueType ?? "unknown",
|
||||
required: prop.required,
|
||||
...(prop.default !== "undefined" ? { default: prop.default } : {}),
|
||||
...(unionOptions(prop.valueType) ? { options: unionOptions(prop.valueType) } : {}),
|
||||
})),
|
||||
outputs: ast.outputs.map((output) => ({
|
||||
name: output.name,
|
||||
...(output.payload
|
||||
? { payloadName: output.payload.name, payloadType: output.payload.valueType }
|
||||
: {}),
|
||||
})),
|
||||
functions: ast.runtimeFunctions.map((fn) => ({
|
||||
name: fn.name,
|
||||
runtime: fn.runtime,
|
||||
async: fn.async,
|
||||
parameters: fn.parameters.map((param) => ({
|
||||
name: param.name,
|
||||
type: param.valueType ?? "unknown",
|
||||
optional: param.optional,
|
||||
})),
|
||||
returnType: fn.returnType ?? (fn.async ? "Promise<unknown>" : "unknown"),
|
||||
})),
|
||||
states: ast.states.map((state) => ({
|
||||
name: state.name,
|
||||
runtime: state.runtime,
|
||||
type: state.valueType ?? "unknown",
|
||||
initializer: state.expr,
|
||||
})),
|
||||
computed: ast.computed.map((entry) => ({
|
||||
name: entry.name,
|
||||
type: entry.valueType ?? "unknown",
|
||||
expression: entry.expr,
|
||||
})),
|
||||
imports: ast.structuredImports.map((entry) => ({
|
||||
source: entry.source,
|
||||
typeOnly: entry.typeOnly,
|
||||
...(entry.defaultImport ? { defaultImport: entry.defaultImport } : {}),
|
||||
namedImports: entry.namedImports.map((named) => named.local),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { existsSync, realpathSync } from "node:fs";
|
||||
import { dirname, extname, join, resolve } from "node:path";
|
||||
import type { StructuredImportDecl } from "@wrnexus/syntax";
|
||||
|
||||
export type ImportMode = "legacy" | "compatible" | "explicit";
|
||||
export interface ImportResolverOptions {
|
||||
appRoot: string;
|
||||
mode?: ImportMode;
|
||||
aliases?: Record<string, string>;
|
||||
}
|
||||
export interface ResolvedImport {
|
||||
declaration: StructuredImportDecl;
|
||||
resolved?: string;
|
||||
diagnostic?: { code: string; message: string; severity: "error" | "warning" };
|
||||
}
|
||||
|
||||
function candidates(path: string): string[] {
|
||||
return extname(path)
|
||||
? [path]
|
||||
: [
|
||||
path,
|
||||
`${path}.wrn`,
|
||||
`${path}.ts`,
|
||||
`${path}.d.ts`,
|
||||
join(path, "index.wrn"),
|
||||
join(path, "index.ts"),
|
||||
];
|
||||
}
|
||||
|
||||
export function resolveWrnImport(
|
||||
declaration: StructuredImportDecl,
|
||||
importer: string,
|
||||
options: ImportResolverOptions,
|
||||
): ResolvedImport {
|
||||
const source = declaration.source;
|
||||
if (!source.startsWith(".") && !source.startsWith("@/")) return { declaration, resolved: source };
|
||||
const aliasRoot = options.aliases?.["@"] ?? "./app";
|
||||
const base = source.startsWith("@/")
|
||||
? resolve(options.appRoot, aliasRoot, source.slice(2))
|
||||
: resolve(dirname(importer), source);
|
||||
const found = candidates(base).find(existsSync);
|
||||
if (found) return { declaration, resolved: realpathSync(found) };
|
||||
const severity = (options.mode ?? "compatible") === "explicit" ? "error" : "warning";
|
||||
return {
|
||||
declaration,
|
||||
diagnostic: {
|
||||
code: "WRN-IMPORT-NOT-FOUND",
|
||||
message: `Cannot resolve import '${source}' from ${importer}`,
|
||||
severity,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveWrnImports(
|
||||
declarations: StructuredImportDecl[],
|
||||
importer: string,
|
||||
options: ImportResolverOptions,
|
||||
): ResolvedImport[] {
|
||||
return declarations.map((declaration) => resolveWrnImport(declaration, importer, options));
|
||||
}
|
||||
@@ -27,6 +27,14 @@ export {
|
||||
ParseError,
|
||||
} from "@wrnexus/syntax";
|
||||
export { generate } from "./codegen.ts";
|
||||
export { generateTargets } from "./targets.ts";
|
||||
export { generateBrowserModule } from "./client-codegen.ts";
|
||||
export { generateServerFunctionsModule, rpcManifest } from "./server-codegen.ts";
|
||||
export { generateDeclarations } from "./type-codegen.ts";
|
||||
export { generateStoreBrowserModule, generateStoreModule } from "./store-codegen.ts";
|
||||
export { createComponentContract } from "./component-contract.ts";
|
||||
export { resolveWrnImport, resolveWrnImports } from "./import-resolver.ts";
|
||||
export { createWrnSourceMap } from "./source-map.ts";
|
||||
export { generateNative, NativeCompileError } from "./native-codegen.ts";
|
||||
export { Lexer, LexError } from "@wrnexus/syntax";
|
||||
export { eraseFunctionTypes, inferredRuntimeType, runtimeTypeOf } from "@wrnexus/syntax";
|
||||
@@ -39,6 +47,11 @@ export type {
|
||||
DataMode,
|
||||
EffectBlock,
|
||||
EventDecl,
|
||||
OutputDecl,
|
||||
RuntimeFunctionDecl,
|
||||
StateRuntime,
|
||||
StoreKind,
|
||||
StructuredImportDecl,
|
||||
LoadBlock,
|
||||
ModeFunctionsBlock,
|
||||
PageAst,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { stripRuntimeFunctionModifiers, type PageAst } from "@wrnexus/syntax";
|
||||
|
||||
export interface RpcManifestEntry {
|
||||
id: string;
|
||||
component: string;
|
||||
function: string;
|
||||
parameters: Array<{ name: string; type: string; optional: boolean }>;
|
||||
returnType: string;
|
||||
}
|
||||
|
||||
function stableId(value: string): string {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return `wrn_${(hash >>> 0).toString(36)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote exposure is reference based in v0.6. A server function is included in
|
||||
* the RPC manifest only when browser-capable code calls `server.<name>(...)`.
|
||||
* Server functions remain available to SSR/server modules without becoming
|
||||
* remotely callable by default.
|
||||
*/
|
||||
export function remotelyReferencedServerFunctions(ast: PageAst): Set<string> {
|
||||
const browserSources = ast.runtimeFunctions
|
||||
.filter((fn) => ["legacy", "client", "shared"].includes(fn.runtime))
|
||||
.map((fn) => fn.body);
|
||||
for (const [hook, body] of Object.entries(ast.storeLifecycle)) {
|
||||
if (hook !== "serverInit" && body) browserSources.push(body);
|
||||
}
|
||||
const names = new Set<string>();
|
||||
const call = /\bserver\.([A-Za-z_$][\w$]*)\s*\(/g;
|
||||
for (const source of browserSources) {
|
||||
for (const match of source.matchAll(call)) names.add(match[1]!);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
export function rpcManifest(ast: PageAst): RpcManifestEntry[] {
|
||||
const exposed = remotelyReferencedServerFunctions(ast);
|
||||
return ast.runtimeFunctions
|
||||
.filter((fn) => fn.runtime === "server" && exposed.has(fn.name))
|
||||
.map((fn) => ({
|
||||
id: stableId(`${ast.name}:${fn.name}`),
|
||||
component: ast.name,
|
||||
function: fn.name,
|
||||
parameters: fn.parameters.map((param) => ({
|
||||
name: param.name,
|
||||
type: param.valueType ?? "unknown",
|
||||
optional: param.optional,
|
||||
})),
|
||||
returnType: fn.returnType ?? (fn.async ? "Promise<unknown>" : "unknown"),
|
||||
}));
|
||||
}
|
||||
|
||||
export function generateServerFunctionsModule(ast: PageAst): string {
|
||||
const source = ast.functions
|
||||
.map((body) => stripRuntimeFunctionModifiers(body, ["legacy", "server", "shared"]))
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
const names = ast.runtimeFunctions
|
||||
.filter((fn) => ["legacy", "server", "shared"].includes(fn.runtime))
|
||||
.map((fn) => fn.name);
|
||||
const manifest = rpcManifest(ast);
|
||||
return `// generated WRNexusJS server module for ${ast.name}\n${source}\n\nexport const __wrnexusServerFunctions = { ${[...new Set(names)].join(", ")} };\nexport const __wrnexusRpcManifest = ${JSON.stringify(manifest, null, 2)};\n`;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface WrnSourceMapEntry {
|
||||
generatedLine: number;
|
||||
sourceLine: number;
|
||||
sourceColumn: number;
|
||||
kind: string;
|
||||
}
|
||||
export interface WrnSourceMap {
|
||||
version: 1;
|
||||
source: string;
|
||||
generated: string;
|
||||
mappings: WrnSourceMapEntry[];
|
||||
}
|
||||
export function createWrnSourceMap(source: string, generated: string): WrnSourceMap {
|
||||
const sourceLines = source.split(/\r?\n/).length;
|
||||
const generatedLines = generated.split(/\r?\n/).length;
|
||||
const mappings = Array.from({ length: Math.min(sourceLines, generatedLines) }, (_, index) => ({
|
||||
generatedLine: index + 1,
|
||||
sourceLine: index + 1,
|
||||
sourceColumn: 1,
|
||||
kind: "line",
|
||||
}));
|
||||
return { version: 1, source, generated, mappings };
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
import { eraseFunctionTypes, type PageAst, type RuntimeFunctionDecl } from "@wrnexus/syntax";
|
||||
import { generateDeclarations } from "./type-codegen.ts";
|
||||
import { rpcManifest } from "./server-codegen.ts";
|
||||
|
||||
const RESERVED_BINDINGS = new Set([
|
||||
"await",
|
||||
"break",
|
||||
"case",
|
||||
"catch",
|
||||
"class",
|
||||
"const",
|
||||
"continue",
|
||||
"debugger",
|
||||
"default",
|
||||
"delete",
|
||||
"do",
|
||||
"else",
|
||||
"enum",
|
||||
"export",
|
||||
"extends",
|
||||
"false",
|
||||
"finally",
|
||||
"for",
|
||||
"function",
|
||||
"if",
|
||||
"implements",
|
||||
"import",
|
||||
"in",
|
||||
"instanceof",
|
||||
"interface",
|
||||
"let",
|
||||
"new",
|
||||
"null",
|
||||
"package",
|
||||
"private",
|
||||
"protected",
|
||||
"public",
|
||||
"return",
|
||||
"static",
|
||||
"super",
|
||||
"switch",
|
||||
"this",
|
||||
"throw",
|
||||
"true",
|
||||
"try",
|
||||
"typeof",
|
||||
"var",
|
||||
"void",
|
||||
"while",
|
||||
"with",
|
||||
"yield",
|
||||
]);
|
||||
|
||||
function safeBinding(name: string): boolean {
|
||||
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) && !RESERVED_BINDINGS.has(name);
|
||||
}
|
||||
|
||||
function stateObject(ast: PageAst, runtime: "shared" | "client" | "server"): string {
|
||||
const entries = ast.states
|
||||
.filter((state) => state.runtime === runtime)
|
||||
.map((state) => `${JSON.stringify(state.name)}: (${state.expr})`);
|
||||
return `{ ${entries.join(", ")} }`;
|
||||
}
|
||||
|
||||
function actionSource(fn: RuntimeFunctionDecl, stateNames: string[], eraseTypes = false): string {
|
||||
const parameterNames = new Set(fn.parameters.map((param) => param.name));
|
||||
const params = fn.parameters.map((param) => param.name).join(", ");
|
||||
const aliases = stateNames.filter((name) => safeBinding(name) && !parameterNames.has(name));
|
||||
const aliasSource = aliases.length ? `let { ${aliases.join(", ")} } = context.state;` : "";
|
||||
const runtimeAliases = ["server"]
|
||||
.filter((name) => !parameterNames.has(name))
|
||||
.map((name) => `const ${name} = context.${name};`)
|
||||
.join("\n");
|
||||
const copyBack = aliases.map((name) => `context.state.${name} = ${name};`).join("\n");
|
||||
const body = eraseTypes ? eraseFunctionTypes(fn.body) : fn.body;
|
||||
return `{ runtime: ${JSON.stringify(fn.runtime)}, handler: ${fn.async ? "async " : ""}function(context${params ? `, ${params}` : ""}) { ${runtimeAliases}\n${aliasSource}\ntry { ${body} } finally { ${copyBack} } } }`;
|
||||
}
|
||||
|
||||
function persistedCallback(
|
||||
source: string | undefined,
|
||||
functionName: "migrate" | "validate",
|
||||
): string | undefined {
|
||||
if (!source?.trim()) return undefined;
|
||||
const body = eraseFunctionTypes(source);
|
||||
if (functionName === "migrate") {
|
||||
return `(value, fromVersion, toVersion) => {\n${body}\nif (typeof migrate === "function") return migrate(value, fromVersion, toVersion);\nreturn value;\n}`;
|
||||
}
|
||||
return `(value) => {\n${body}\nif (typeof validate === "function") return validate(value);\nreturn value && typeof value === "object" && !Array.isArray(value) ? value : null;\n}`;
|
||||
}
|
||||
|
||||
function persistenceSource(ast: PageAst): string {
|
||||
if (!ast.persist) return "undefined";
|
||||
const migrate = persistedCallback(ast.persist.migrations, "migrate");
|
||||
const validate = persistedCallback(ast.persist.validation, "validate");
|
||||
return `{ storage: ${JSON.stringify(ast.persist.storage)}, include: ${JSON.stringify(ast.persist.include)}, version: ${ast.persist.version}${migrate ? `, migrate: ${migrate}` : ""}${validate ? `, validate: ${validate}` : ""} }`;
|
||||
}
|
||||
|
||||
function lifecycleSource(ast: PageAst, stateNames: string[], browser: boolean): string {
|
||||
return Object.entries(ast.storeLifecycle)
|
||||
.filter(([name]) => !browser || name !== "serverInit")
|
||||
.map(([name, body]) => {
|
||||
const aliases = stateNames.filter(safeBinding);
|
||||
const aliasSource = aliases.length ? `let { ${aliases.join(", ")} } = context.state;` : "";
|
||||
const runtimeAliases = browser ? "const server = context.server;" : "";
|
||||
const copyBack = aliases.map((state) => `context.state.${state} = ${state};`).join("\n");
|
||||
const emittedBody = browser ? eraseFunctionTypes(body) : body;
|
||||
return `${name}: async (context) => { ${runtimeAliases} ${aliasSource} try { ${emittedBody} } finally { ${copyBack} } }`;
|
||||
})
|
||||
.join(",\n");
|
||||
}
|
||||
|
||||
export function generateStoreModule(ast: PageAst): string {
|
||||
if (ast.kind !== "global-store" && ast.kind !== "page-store") {
|
||||
throw new Error("generateStoreModule requires a store AST");
|
||||
}
|
||||
const stateNames = ast.states.map((state) => state.name);
|
||||
const safeStateNames = stateNames.filter(safeBinding);
|
||||
const computed = ast.computed
|
||||
.map(
|
||||
(entry) =>
|
||||
`${JSON.stringify(entry.name)}: (state) => { ${safeStateNames.length ? `const { ${safeStateNames.join(", ")} } = state;` : ""} return (${entry.expr}); }`,
|
||||
)
|
||||
.join(",\n");
|
||||
const actionGroups = new Map<string, RuntimeFunctionDecl[]>();
|
||||
for (const fn of ast.runtimeFunctions) {
|
||||
const group = actionGroups.get(fn.name) ?? [];
|
||||
group.push(fn);
|
||||
actionGroups.set(fn.name, group);
|
||||
}
|
||||
const actions = Array.from(
|
||||
actionGroups,
|
||||
([name, functions]) =>
|
||||
`${JSON.stringify(name)}: [${functions.map((fn) => actionSource(fn, stateNames)).join(", ")}]`,
|
||||
).join(",\n");
|
||||
const persistence = persistenceSource(ast);
|
||||
const lifecycle = lifecycleSource(ast, stateNames, false);
|
||||
const manifest = rpcManifest(ast);
|
||||
const remoteFunctions = manifest.map((entry) => entry.function);
|
||||
const rpcWrappers = remoteFunctions
|
||||
.map(
|
||||
(name) => `${JSON.stringify(name)}: async (...received) => {
|
||||
const rpcContext = received.pop();
|
||||
if (!rpcContext || !rpcContext.request) throw new Error("WRN-RPC-CONTEXT: request context is required");
|
||||
let container = __wrnexusRpcContainers.get(rpcContext.request);
|
||||
if (!container) {
|
||||
const url = new URL(rpcContext.request.url);
|
||||
container = createRequestStoreContainer(rpcContext.request, url.pathname + url.search);
|
||||
__wrnexusRpcContainers.set(rpcContext.request, container);
|
||||
}
|
||||
const store = await container.use(${ast.name});
|
||||
const action = store.actions[${JSON.stringify(name)}];
|
||||
if (typeof action !== "function") throw new Error(${JSON.stringify(`WRN-RPC-FUNCTION: store action '${name}' is unavailable on the server`)});
|
||||
return action(...received);
|
||||
}`,
|
||||
)
|
||||
.join(",\n");
|
||||
|
||||
return `${ast.imports.join("\n")}\nimport { defineStore } from "@wrnexus/store";\nimport { createRequestStoreContainer } from "@wrnexus/store/server";\n\n${ast.types.join("\n\n")}\n\nexport const ${ast.name} = defineStore({\n name: ${JSON.stringify(ast.name)},\n kind: ${JSON.stringify(ast.storeKind)},\n createSharedState: () => (${stateObject(ast, "shared")}),\n createClientState: () => (${stateObject(ast, "client")}),\n createServerState: () => (${stateObject(ast, "server")}),\n computed: { ${computed} },\n actions: { ${actions} },\n persist: ${persistence},\n lifecycle: { ${lifecycle} },\n});\n\nexport default ${ast.name};\n\nconst __wrnexusRpcContainers = new WeakMap();\nexport const __wrnexusServerFunctions = {\n${rpcWrappers}\n};\nexport const __wrnexusRpcManifest = ${JSON.stringify(manifest, null, 2)};\n\n${generateDeclarations(ast)}\n`;
|
||||
}
|
||||
|
||||
/** Standalone browser artifact for an imported `.wrn` store. */
|
||||
export function generateStoreBrowserModule(ast: PageAst): string {
|
||||
if (ast.kind !== "global-store" && ast.kind !== "page-store") {
|
||||
throw new Error("generateStoreBrowserModule requires a store AST");
|
||||
}
|
||||
const browserStates = ast.states.filter((state) => state.runtime !== "server");
|
||||
const stateNames = browserStates.map((state) => state.name);
|
||||
const safeStateNames = stateNames.filter(safeBinding);
|
||||
const initialState = `{ ${browserStates.map((state) => `${JSON.stringify(state.name)}: (${state.expr})`).join(", ")} }`;
|
||||
const computed = ast.computed
|
||||
.map(
|
||||
(entry) =>
|
||||
`${JSON.stringify(entry.name)}: (state) => { ${safeStateNames.length ? `const { ${safeStateNames.join(", ")} } = state;` : ""} return (${entry.expr}); }`,
|
||||
)
|
||||
.join(",\n");
|
||||
const groups = new Map<string, RuntimeFunctionDecl[]>();
|
||||
for (const fn of ast.runtimeFunctions.filter((entry) =>
|
||||
["client", "shared", "legacy"].includes(entry.runtime),
|
||||
)) {
|
||||
const group = groups.get(fn.name) ?? [];
|
||||
group.push(fn);
|
||||
groups.set(fn.name, group);
|
||||
}
|
||||
const actions = Array.from(
|
||||
groups,
|
||||
([name, functions]) =>
|
||||
`${JSON.stringify(name)}: [${functions.map((fn) => actionSource(fn, stateNames, true)).join(", ")}]`,
|
||||
).join(",\n");
|
||||
const persistence = persistenceSource(ast);
|
||||
const lifecycleEntries = lifecycleSource(ast, stateNames, true);
|
||||
|
||||
return `// generated WRNexusJS browser store module for ${ast.name}
|
||||
const __root = globalThis;
|
||||
const __registry = __root.__wrnexusStoreRegistry || (__root.__wrnexusStoreRegistry = new Map());
|
||||
const __hydrationNode = typeof document !== "undefined" ? document.querySelector("script[data-wrnexus-store-hydration]") : null;
|
||||
let __hydration = {};
|
||||
try { __hydration = __hydrationNode ? JSON.parse(__hydrationNode.textContent || "{}") : {}; } catch (_) {}
|
||||
function __clone(value) { try { return structuredClone(value); } catch (_) { return JSON.parse(JSON.stringify(value)); } }
|
||||
function __storage(kind) { if (typeof window === "undefined") return null; return kind === "local" ? window.localStorage : kind === "session" ? window.sessionStorage : null; }
|
||||
function __diagnostic(code, message, details) {
|
||||
const detail = { code, message, store: ${JSON.stringify(ast.name)}, details };
|
||||
try { __root.dispatchEvent && __root.dispatchEvent(new CustomEvent("wrnexus:store-diagnostic", { detail })); } catch (_) {}
|
||||
if (typeof console !== "undefined" && console.warn) console.warn("[wrnexus:store] " + code + ": " + message, details || "");
|
||||
}
|
||||
function __csrfToken() {
|
||||
if (typeof document === "undefined") return undefined;
|
||||
const match = /(?:^|;\\s*)wrnexus_csrf=([^;]+)/.exec(document.cookie || "");
|
||||
return match ? decodeURIComponent(match[1]) : undefined;
|
||||
}
|
||||
async function __callServerFunction(storeName, functionName, args, options) {
|
||||
options = options || {};
|
||||
const csrf = options.csrfToken || __csrfToken();
|
||||
const traceId = options.traceId || (globalThis.crypto && crypto.randomUUID ? crypto.randomUUID() : String(Date.now()));
|
||||
const response = await fetch(options.endpoint || "/__wrnexus/rpc", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
signal: options.signal,
|
||||
headers: Object.assign({ "content-type": "application/json", "x-request-id": traceId }, csrf ? { "x-wrnexus-csrf": csrf } : {}, options.headers || {}),
|
||||
body: JSON.stringify({ component: storeName, function: functionName, args: args }),
|
||||
});
|
||||
const payload = await response.json().catch(function () { return null; });
|
||||
if (!response.ok || !payload || !payload.ok) {
|
||||
const error = new Error(payload && payload.error && payload.error.message || "Server call failed (" + response.status + ")");
|
||||
error.code = payload && payload.error && payload.error.code || "WRN-RPC-FAILED";
|
||||
error.status = response.status;
|
||||
error.details = payload && payload.error && payload.error.details;
|
||||
error.traceId = payload && payload.error && payload.error.traceId || traceId;
|
||||
throw error;
|
||||
}
|
||||
return payload.value;
|
||||
}
|
||||
function __compatible(expected, value) {
|
||||
if (expected === null || value === null) return expected === value || expected === null;
|
||||
if (Array.isArray(expected)) return Array.isArray(value);
|
||||
return typeof expected === typeof value;
|
||||
}
|
||||
function __create(definition) {
|
||||
const routeId = typeof location !== "undefined" ? location.pathname + location.search : "default";
|
||||
const key = definition.kind === "page" ? definition.name + "@" + routeId : definition.name;
|
||||
if (__registry.has(key)) return __registry.get(key);
|
||||
let currentDefinition = definition;
|
||||
const listeners = new Set();
|
||||
const initial = currentDefinition.createState();
|
||||
let restored = null;
|
||||
if (currentDefinition.persist) {
|
||||
try {
|
||||
const storage = __storage(currentDefinition.persist.storage);
|
||||
const rawValue = storage && storage.getItem("wrnexus:store:" + currentDefinition.name);
|
||||
const parsed = rawValue ? JSON.parse(rawValue) : null;
|
||||
if (parsed) {
|
||||
let candidate = parsed.state;
|
||||
const fromVersion = Number(parsed.version || 0);
|
||||
if (fromVersion !== currentDefinition.persist.version) {
|
||||
if (typeof currentDefinition.persist.migrate === "function") candidate = currentDefinition.persist.migrate(candidate, fromVersion, currentDefinition.persist.version);
|
||||
else { __diagnostic("WRN-PERSIST-VERSION", "Persisted state version cannot be restored without a migration.", { fromVersion, toVersion: currentDefinition.persist.version }); candidate = null; }
|
||||
}
|
||||
if (candidate && typeof currentDefinition.persist.validate === "function") candidate = currentDefinition.persist.validate(candidate);
|
||||
if (candidate && typeof candidate === "object" && !Array.isArray(candidate)) {
|
||||
restored = {};
|
||||
currentDefinition.persist.include.forEach(function (name) { if (Object.prototype.hasOwnProperty.call(candidate, name)) restored[name] = candidate[name]; });
|
||||
} else if (candidate != null) {
|
||||
__diagnostic("WRN-PERSIST-INVALID", "Persisted state failed validation and was reset.", candidate);
|
||||
}
|
||||
}
|
||||
} catch (error) { __diagnostic("WRN-PERSIST-RESTORE", "Persisted state could not be restored and was reset.", error); }
|
||||
}
|
||||
const raw = Object.assign({}, initial, restored || {}, __hydration[currentDefinition.name] || {});
|
||||
let mutable = false;
|
||||
let actionName = "direct";
|
||||
function persistState() {
|
||||
if (!currentDefinition.persist) return;
|
||||
try {
|
||||
const picked = {};
|
||||
currentDefinition.persist.include.forEach(function (name) { picked[name] = raw[name]; });
|
||||
const storage = __storage(currentDefinition.persist.storage);
|
||||
if (storage) storage.setItem("wrnexus:store:" + currentDefinition.name, JSON.stringify({ version: currentDefinition.persist.version, state: picked }));
|
||||
} catch (error) { __diagnostic("WRN-PERSIST-WRITE", "Persisted state could not be written.", error); }
|
||||
}
|
||||
const state = new Proxy(raw, {
|
||||
set(target, property, value) {
|
||||
if (!mutable) throw new TypeError("WRN-STORE-READONLY: " + currentDefinition.name + "." + String(property) + " must be changed by a store action.");
|
||||
if (Object.is(target[property], value)) return true;
|
||||
target[property] = value;
|
||||
persistState();
|
||||
listeners.forEach(function (listener) { listener(instance.snapshot(), { store: currentDefinition.name, action: actionName, changed: [String(property)] }); });
|
||||
return true;
|
||||
},
|
||||
deleteProperty(target, property) {
|
||||
if (!mutable) throw new TypeError("WRN-STORE-READONLY: store state is readonly outside actions");
|
||||
return Reflect.deleteProperty(target, property);
|
||||
},
|
||||
});
|
||||
const actions = {};
|
||||
const server = new Proxy({}, { get: function (_target, property) { return function () { return __callServerFunction(currentDefinition.name, String(property), Array.prototype.slice.call(arguments)); }; } });
|
||||
function installActions() {
|
||||
Object.keys(actions).forEach(function (name) { delete actions[name]; });
|
||||
Object.entries(currentDefinition.actions || {}).forEach(function (pair) {
|
||||
const name = pair[0], candidates = pair[1];
|
||||
const selected = candidates.find(function (entry) { return entry.runtime === "client"; }) || candidates.find(function (entry) { return entry.runtime === "shared"; }) || candidates.find(function (entry) { return entry.runtime === "legacy"; });
|
||||
if (!selected) return;
|
||||
actions[name] = async function () {
|
||||
const args = Array.prototype.slice.call(arguments);
|
||||
const previousMutable = mutable, previousAction = actionName;
|
||||
mutable = true; actionName = name;
|
||||
try { return await selected.handler({ state, snapshot: function () { return __clone(state); }, reset: function () { return instance.reset(); }, runtime: "client", routeId, server }, ...args); }
|
||||
finally { mutable = previousMutable; actionName = previousAction; }
|
||||
};
|
||||
});
|
||||
}
|
||||
installActions();
|
||||
const core = {
|
||||
name: currentDefinition.name,
|
||||
kind: currentDefinition.kind,
|
||||
state,
|
||||
actions,
|
||||
whenReady: Promise.resolve(),
|
||||
reset() {
|
||||
mutable = true; actionName = "$reset";
|
||||
try {
|
||||
const next = currentDefinition.createState();
|
||||
Object.keys(raw).forEach(function (name) { if (!(name in next)) delete raw[name]; });
|
||||
Object.assign(raw, next); persistState();
|
||||
listeners.forEach(function (listener) { listener(instance.snapshot(), { store: currentDefinition.name, action: "$reset", changed: Object.keys(next) }); });
|
||||
} finally { mutable = false; actionName = "direct"; }
|
||||
},
|
||||
snapshot() { return Object.freeze(__clone(raw)); },
|
||||
subscribe(listener) { listeners.add(listener); return function () { listeners.delete(listener); }; },
|
||||
async dispose() {
|
||||
mutable = true; actionName = "$dispose";
|
||||
try { await currentDefinition.lifecycle && currentDefinition.lifecycle.dispose && currentDefinition.lifecycle.dispose({ state, runtime: "client", routeId, server }); }
|
||||
finally { mutable = false; actionName = "direct"; listeners.clear(); __registry.delete(key); }
|
||||
},
|
||||
async __hotUpdate(nextDefinition) {
|
||||
const previous = __clone(raw);
|
||||
const nextShape = nextDefinition.createState();
|
||||
const preserved = [], reset = [], added = [], removed = [];
|
||||
Object.keys(previous).forEach(function (name) {
|
||||
if (!(name in nextShape)) { removed.push(name); return; }
|
||||
if (__compatible(nextShape[name], previous[name])) { nextShape[name] = previous[name]; preserved.push(name); }
|
||||
else reset.push(name);
|
||||
});
|
||||
Object.keys(nextShape).forEach(function (name) { if (!(name in previous)) added.push(name); });
|
||||
currentDefinition = nextDefinition;
|
||||
mutable = true; actionName = "$hmr";
|
||||
try {
|
||||
Object.keys(raw).forEach(function (name) { delete raw[name]; });
|
||||
Object.assign(raw, nextShape);
|
||||
installActions(); persistState();
|
||||
} finally { mutable = false; actionName = "direct"; }
|
||||
const result = { store: currentDefinition.name, preserved, reset, added, removed };
|
||||
listeners.forEach(function (listener) { listener(instance.snapshot(), { store: currentDefinition.name, action: "$hmr", changed: added.concat(reset, removed) }); });
|
||||
try { __root.dispatchEvent && __root.dispatchEvent(new CustomEvent("wrnexus:store-hmr", { detail: result })); } catch (_) {}
|
||||
return result;
|
||||
},
|
||||
};
|
||||
const instance = new Proxy(core, {
|
||||
get(target, property, receiver) {
|
||||
if (Reflect.has(target, property)) return Reflect.get(target, property, receiver);
|
||||
if (property in actions) return actions[property];
|
||||
if (property in (currentDefinition.computed || {})) return currentDefinition.computed[property](state);
|
||||
return state[property];
|
||||
},
|
||||
set() { throw new TypeError("WRN-STORE-READONLY: store state is readonly outside actions"); },
|
||||
});
|
||||
__registry.set(key, instance);
|
||||
const hydrationSource = __hydration[currentDefinition.name];
|
||||
const init = async function () {
|
||||
const run = async function (name, hook) {
|
||||
if (!hook) return;
|
||||
mutable = true; actionName = name;
|
||||
try { await hook({ state, runtime: "client", routeId, server }); }
|
||||
finally { mutable = false; actionName = "direct"; }
|
||||
};
|
||||
await run("$clientInit", currentDefinition.lifecycle && currentDefinition.lifecycle.clientInit);
|
||||
if (hydrationSource) await run("$hydrate", currentDefinition.lifecycle && currentDefinition.lifecycle.hydrate);
|
||||
};
|
||||
core.whenReady = init();
|
||||
return instance;
|
||||
}
|
||||
if (!__root.__wrnexusApplyStoreHotUpdate) {
|
||||
__root.__wrnexusApplyStoreHotUpdate = async function (name, definition) {
|
||||
const results = [];
|
||||
for (const item of Array.from(__registry.values())) if (item.name === name && typeof item.__hotUpdate === "function") results.push(await item.__hotUpdate(definition));
|
||||
return results;
|
||||
};
|
||||
}
|
||||
if (!__root.__wrnexusStoreContainer) {
|
||||
__root.__wrnexusStoreContainer = {
|
||||
async disposePageStores() { for (const item of Array.from(__registry.values())) if (item.kind === "page") await item.dispose(); },
|
||||
async hotUpdate(name, definition) { return __root.__wrnexusApplyStoreHotUpdate(name, definition); },
|
||||
inspect() { return Array.from(__registry.values()).map(function (item) { return { name: item.name, kind: item.kind, state: item.snapshot() }; }); },
|
||||
};
|
||||
}
|
||||
export const ${ast.name}Definition = {
|
||||
name: ${JSON.stringify(ast.name)},
|
||||
kind: ${JSON.stringify(ast.storeKind)},
|
||||
createState: () => (${initialState}),
|
||||
computed: { ${computed} },
|
||||
actions: { ${actions} },
|
||||
persist: ${persistence},
|
||||
lifecycle: { ${lifecycleEntries} },
|
||||
};
|
||||
export const ${ast.name} = __create(${ast.name}Definition);
|
||||
export default ${ast.name};
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { PageAst } from "@wrnexus/syntax";
|
||||
import { createComponentContract } from "./component-contract.ts";
|
||||
import { generateBrowserModule } from "./client-codegen.ts";
|
||||
import { generateServerFunctionsModule, rpcManifest } from "./server-codegen.ts";
|
||||
import { generateDeclarations } from "./type-codegen.ts";
|
||||
import { generateStoreBrowserModule, generateStoreModule } from "./store-codegen.ts";
|
||||
|
||||
export interface CompileTargets {
|
||||
server: string;
|
||||
browser: string;
|
||||
declarations: string;
|
||||
contract: ReturnType<typeof createComponentContract>;
|
||||
rpc: ReturnType<typeof rpcManifest>;
|
||||
}
|
||||
|
||||
export function generateTargets(ast: PageAst): CompileTargets {
|
||||
return {
|
||||
server:
|
||||
ast.kind === "global-store" || ast.kind === "page-store"
|
||||
? generateStoreModule(ast)
|
||||
: generateServerFunctionsModule(ast),
|
||||
browser:
|
||||
ast.kind === "global-store" || ast.kind === "page-store"
|
||||
? generateStoreBrowserModule(ast)
|
||||
: generateBrowserModule(ast),
|
||||
declarations: generateDeclarations(ast),
|
||||
contract: createComponentContract(ast),
|
||||
rpc: rpcManifest(ast),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { PageAst } from "@wrnexus/syntax";
|
||||
|
||||
function member(name: string): string {
|
||||
return /^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name);
|
||||
}
|
||||
function params(astParams: PageAst["runtimeFunctions"][number]["parameters"]): string {
|
||||
return astParams
|
||||
.map(
|
||||
(param) =>
|
||||
`${member(param.name)}${param.optional ? "?" : ""}: ${param.valueType ?? "unknown"}`,
|
||||
)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
export function generateDeclarations(ast: PageAst): string {
|
||||
const inline = ast.types
|
||||
.map((body) => body.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
if (ast.kind === "global-store" || ast.kind === "page-store") {
|
||||
const state = ast.states
|
||||
.filter((entry) => entry.runtime !== "server")
|
||||
.map((entry) => ` readonly ${member(entry.name)}: ${entry.valueType ?? "unknown"};`)
|
||||
.join("\n");
|
||||
const computed = ast.computed
|
||||
.map((entry) => ` readonly ${member(entry.name)}: ${entry.valueType ?? "unknown"};`)
|
||||
.join("\n");
|
||||
const actions = ast.runtimeFunctions
|
||||
.filter((fn) => fn.runtime !== "server")
|
||||
.map(
|
||||
(fn) =>
|
||||
` ${member(fn.name)}(${params(fn.parameters)}): ${fn.returnType ?? (fn.async ? "Promise<unknown>" : "unknown")};`,
|
||||
)
|
||||
.join("\n");
|
||||
return `${inline ? `${inline}\n\n` : ""}export interface ${ast.name}State {\n${state}\n}\n\nexport interface ${ast.name}Computed {\n${computed}\n}\n\nexport interface ${ast.name}Actions {\n${actions}\n}\n\nexport interface ${ast.name}Instance extends ${ast.name}State, ${ast.name}Computed, ${ast.name}Actions {\n reset(): void;\n snapshot(): Readonly<${ast.name}State>;\n}\n\ndeclare const store: ${ast.name}Instance;\nexport default store;\n`;
|
||||
}
|
||||
const props = ast.props
|
||||
.map(
|
||||
(prop) =>
|
||||
` readonly ${member(prop.name)}${prop.required ? "" : "?"}: ${prop.valueType ?? "unknown"};`,
|
||||
)
|
||||
.join("\n");
|
||||
const outputs = ast.outputs
|
||||
.map(
|
||||
(output) =>
|
||||
` ${member(output.name)}(${output.payload ? `${member(output.payload.name)}${output.payload.optional ? "?" : ""}: ${output.payload.valueType}` : ""}): void;`,
|
||||
)
|
||||
.join("\n");
|
||||
const clientFunctions = ast.runtimeFunctions
|
||||
.filter((fn) => fn.runtime === "client" || fn.runtime === "shared" || fn.runtime === "legacy")
|
||||
.map(
|
||||
(fn) =>
|
||||
` ${member(fn.name)}(${params(fn.parameters)}): ${fn.returnType ?? (fn.async ? "Promise<unknown>" : "unknown")};`,
|
||||
)
|
||||
.join("\n");
|
||||
const serverFunctions = ast.runtimeFunctions
|
||||
.filter((fn) => fn.runtime === "server")
|
||||
.map(
|
||||
(fn) =>
|
||||
` ${member(fn.name)}(${params(fn.parameters)}): Promise<Awaited<${fn.returnType ?? "unknown"}>>;`,
|
||||
)
|
||||
.join("\n");
|
||||
return `${inline ? `${inline}\n\n` : ""}export interface ${ast.name}Props {\n${props}\n}\n\nexport interface ${ast.name}Outputs {\n${outputs}\n}\n\nexport interface ${ast.name}ClientFunctions {\n${clientFunctions}\n}\n\nexport interface ${ast.name}ServerCalls {\n${serverFunctions}\n}\n`;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { generateTargets, parse } from "../src/index.ts";
|
||||
|
||||
const ast = parse(`component ConfirmDialog {
|
||||
props { title: string open: boolean = false }
|
||||
state { loading: boolean = false }
|
||||
server state { internalId: string = "secret" }
|
||||
outputs { confirm(payload: string) close() }
|
||||
functions {
|
||||
client async function confirm(value: string): Promise<void> {
|
||||
const saved = await server.confirm(value)
|
||||
output.confirm(saved)
|
||||
}
|
||||
server async function confirm(value: string): Promise<string> { return value }
|
||||
shared function normalize(value: string): string { return value.trim() }
|
||||
}
|
||||
view { <button>{title}</button> }
|
||||
}`);
|
||||
|
||||
test("emits separate browser and server function targets", () => {
|
||||
const targets = generateTargets(ast);
|
||||
expect(targets.browser).toContain('"confirm": async function');
|
||||
expect(targets.browser).toContain('"normalize": function');
|
||||
expect(targets.server).toContain("async function confirm");
|
||||
expect(targets.rpc).toEqual([
|
||||
expect.objectContaining({ component: "ConfirmDialog", function: "confirm" }),
|
||||
]);
|
||||
expect(targets.declarations).toContain("interface ConfirmDialogOutputs");
|
||||
expect(targets.declarations).toContain("interface ConfirmDialogServerCalls");
|
||||
});
|
||||
|
||||
test("component contracts retain declared union options", () => {
|
||||
const targets = generateTargets(
|
||||
parse(`component SizeBox {
|
||||
props { size: "small" | "medium" | "large" = "medium" }
|
||||
view { <div></div> }
|
||||
}`),
|
||||
);
|
||||
expect(targets.contract.props[0]?.options).toEqual(["small", "medium", "large"]);
|
||||
});
|
||||
|
||||
test("generates a standalone browser module for imported stores", () => {
|
||||
const targets = generateTargets(
|
||||
parse(`page store SearchStore {
|
||||
state { query: string = "" }
|
||||
client state { focused: boolean = false }
|
||||
server state { secret: string = "hidden" }
|
||||
computed { empty: boolean = query.length === 0 }
|
||||
functions { client function setQuery(value: string): void { query = value } }
|
||||
persist { storage = "session" include = ["query"] version = 1 }
|
||||
}`),
|
||||
);
|
||||
expect(targets.browser).toContain("__wrnexusStoreRegistry");
|
||||
expect(targets.browser).toContain('name: "SearchStore"');
|
||||
expect(targets.browser).toContain('"query"');
|
||||
expect(targets.browser).not.toContain('"secret": ("hidden")');
|
||||
expect(targets.declarations).toContain("interface SearchStoreInstance");
|
||||
});
|
||||
|
||||
test("browser codegen avoids reserved prop bindings and parameter collisions", () => {
|
||||
const targets = generateTargets(
|
||||
parse(`component ReservedBindings {
|
||||
props { class: string = "" output: string = "" }
|
||||
state { value: string = "" }
|
||||
outputs { change(payload: { value: string }) }
|
||||
functions {
|
||||
client function update(output: string): void {
|
||||
value = output
|
||||
}
|
||||
client function notify(): void {
|
||||
output.change({ value: value })
|
||||
}
|
||||
}
|
||||
view { <button>{class}</button> }
|
||||
}`),
|
||||
);
|
||||
expect(targets.browser).not.toContain("const { class }");
|
||||
expect(targets.browser).not.toContain("const output = context.output;\n const output");
|
||||
expect(() => new Function(targets.browser.replace(/^export\s+/gm, ""))).not.toThrow();
|
||||
});
|
||||
|
||||
test("RPC manifests expose only server functions referenced through server.name", () => {
|
||||
const targets = generateTargets(
|
||||
parse(`component SecureActions {
|
||||
functions {
|
||||
client async function saveClient(): Promise<void> { await server.save("ok") }
|
||||
server async function save(value: string): Promise<string> { return value }
|
||||
server function internalSecret(): string { return "secret" }
|
||||
}
|
||||
view { <button @click='saveClient()'>Save</button> }
|
||||
}`),
|
||||
);
|
||||
expect(targets.rpc.map((entry) => entry.function)).toEqual(["save"]);
|
||||
expect(targets.server).toContain("internalSecret");
|
||||
});
|
||||
|
||||
test("generated browser stores bind typed RPC, persistence validation, and HMR", () => {
|
||||
const targets = generateTargets(
|
||||
parse(`global store UserStore {
|
||||
state { user: string | null = null count: number = 0 }
|
||||
functions {
|
||||
client async function refresh(): Promise<void> { user = await server.loadCurrentUser() }
|
||||
server async function loadCurrentUser(): Promise<string | null> { return "Ajay" }
|
||||
server function internalOnly(): string { return "secret" }
|
||||
}
|
||||
persist {
|
||||
storage = "local"
|
||||
include = ["count"]
|
||||
version = 2
|
||||
migrations { function migrate(value, fromVersion, toVersion) { return value } }
|
||||
validate { function validate(value) { return value && typeof value === "object" ? value : null } }
|
||||
}
|
||||
}`),
|
||||
);
|
||||
expect(targets.browser).toContain("const server = context.server");
|
||||
expect(targets.browser).toContain("currentDefinition.persist.migrate");
|
||||
expect(targets.browser).toContain("currentDefinition.persist.validate");
|
||||
expect(targets.browser).toContain("__wrnexusApplyStoreHotUpdate");
|
||||
expect(targets.rpc.map((entry) => entry.function)).toEqual(["loadCurrentUser"]);
|
||||
});
|
||||
|
||||
test("browser codegen binds peer client functions through the scoped function table", () => {
|
||||
const targets = generateTargets(
|
||||
parse(`component PeerCalls {
|
||||
state { value: number = 0 }
|
||||
functions {
|
||||
client function increment(): void { value += 1 }
|
||||
client function run(): void { increment() }
|
||||
}
|
||||
view { <button @click='run()'>{value}</button> }
|
||||
}`),
|
||||
);
|
||||
expect(targets.browser).toContain("context.functions");
|
||||
const executable = new Function(
|
||||
`${targets.browser.replace(/^export\s+/gm, "")}\nreturn { bindClientScope };`,
|
||||
)() as { bindClientScope: (context: Record<string, unknown>) => Record<string, () => void> };
|
||||
const state = { value: 0 };
|
||||
const functions = executable.bindClientScope({
|
||||
state,
|
||||
props: {},
|
||||
output: {},
|
||||
server: {},
|
||||
refs: {},
|
||||
});
|
||||
functions.run?.();
|
||||
expect(state.value).toBe(1);
|
||||
});
|
||||
Reference in New Issue
Block a user