407 lines
20 KiB
TypeScript
407 lines
20 KiB
TypeScript
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"].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*)wrn-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-csrf-token": 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"; });
|
|
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};
|
|
`;
|
|
}
|