release: WRNexusJS 0.8.3
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ai",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Badge, Button, Card } from "@wrnexus/ui"
|
||||
|
||||
component AccountStatus {
|
||||
props {
|
||||
status: string = "active"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Button } from "@wrnexus/ui"
|
||||
|
||||
component AuthProviderButtons {
|
||||
outputs {
|
||||
select(payload: { provider: string; href: string })
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Alert } from "@wrnexus/ui"
|
||||
|
||||
component AuthSecurityNotice {
|
||||
props {
|
||||
title: string = "Security notice"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Card } from "@wrnexus/ui"
|
||||
|
||||
component AuthShell {
|
||||
props {
|
||||
title: string = "Welcome"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Button, Input, PinInput } from "@wrnexus/ui"
|
||||
|
||||
component AuthenticatorSetup {
|
||||
props {
|
||||
setupAction = "/api/auth/totp/setup"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Avatar, Badge, Button, Card } from "@wrnexus/ui"
|
||||
|
||||
component DeviceSessions {
|
||||
props {
|
||||
sessions: unknown[] = []
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Button, Input } from "@wrnexus/ui"
|
||||
|
||||
component ForgotPassword {
|
||||
props {
|
||||
action = "/api/auth/password/request"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Alert, Button } from "@wrnexus/ui"
|
||||
|
||||
component ImpersonationBanner {
|
||||
props {
|
||||
visible: boolean = true
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Button, Input, StrongPassword } from "@wrnexus/ui"
|
||||
|
||||
component InvitationAccept {
|
||||
props {
|
||||
action = "/api/auth/invitations/accept"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Button, Input } from "@wrnexus/ui"
|
||||
|
||||
component MagicLinkSignIn {
|
||||
props {
|
||||
action = "/api/auth/magic-link/request"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Button, Input, PinInput } from "@wrnexus/ui"
|
||||
|
||||
component OtpSignIn {
|
||||
props {
|
||||
requestAction = "/api/auth/otp/login/request"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Button } from "@wrnexus/ui"
|
||||
|
||||
component PasskeyButton {
|
||||
outputs {
|
||||
passkeyRegistered(payload: { credentialId: string; response?: object })
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Alert, Button, Card } from "@wrnexus/ui"
|
||||
|
||||
component RecoveryCodes {
|
||||
props {
|
||||
codes: unknown[] = []
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Button, StrongPassword } from "@wrnexus/ui"
|
||||
|
||||
component ResetPassword {
|
||||
props {
|
||||
action = "/api/auth/password/reset"
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import PasskeyButton from "./PasskeyButton.wrn"
|
||||
import { Button, Checkbox, Input, TogglePassword } from "@wrnexus/ui"
|
||||
|
||||
component SignIn {
|
||||
props {
|
||||
action = "/api/auth/login"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Button, Checkbox, Input, StrongPassword } from "@wrnexus/ui"
|
||||
|
||||
component SignUp {
|
||||
props {
|
||||
action = "/api/auth/register"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Button, PinInput, Select } from "@wrnexus/ui"
|
||||
|
||||
component TwoFactorChallenge {
|
||||
props {
|
||||
action = "/api/auth/mfa/complete"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Button, Input } from "@wrnexus/ui"
|
||||
|
||||
component VerifyEmail {
|
||||
props {
|
||||
kind = "email"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Button, PinInput } from "@wrnexus/ui"
|
||||
|
||||
component VerifyPhone {
|
||||
props {
|
||||
kind = "phone"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/auth",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/authz",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/benchmark",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"type": "module",
|
||||
"description": "Deterministic benchmark runner and performance regression budgets for WRNexusJS.",
|
||||
"main": "src/index.ts",
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/cache",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"type": "module",
|
||||
"description": "Tag-aware memory and response caching with stale-while-revalidate for WRNexusJS.",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import Captcha from "./Captcha.wrn"
|
||||
import { Card } from "@wrnexus/ui"
|
||||
|
||||
component CaptchaField {
|
||||
props {
|
||||
title: string = "Security verification"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Alert } from "@wrnexus/ui"
|
||||
|
||||
component CaptchaStatus {
|
||||
props {
|
||||
success: boolean = false
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/captcha",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"description": "First-class CAPTCHA challenges, providers, verification guards, page gates, and WRNexusJS UI.",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
+108
-20
@@ -29,6 +29,7 @@ import {
|
||||
analyzeRuntimeRequirements,
|
||||
assertValidAst,
|
||||
generate,
|
||||
generateTargets,
|
||||
parse,
|
||||
type RuntimeRequirements,
|
||||
type DeploymentRuntime,
|
||||
@@ -106,6 +107,7 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
const appDir = join(root, "app");
|
||||
const distDir = join(root, "dist");
|
||||
const compiledDir = join(distDir, "compiled");
|
||||
const clientModulesDir = join(distDir, "client");
|
||||
const reactivePath = join(distDir, "reactive.js");
|
||||
const publicDir = join(root, "public");
|
||||
const distPublicDir = join(distDir, "public");
|
||||
@@ -117,6 +119,7 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
// Clean output.
|
||||
rmSync(distDir, { recursive: true, force: true });
|
||||
mkdirSync(compiledDir, { recursive: true });
|
||||
mkdirSync(clientModulesDir, { recursive: true });
|
||||
if (existsSync(publicDir)) {
|
||||
cpSync(publicDir, distPublicDir, { recursive: true });
|
||||
console.log(`✓ Public: ${distPublicDir}`);
|
||||
@@ -169,15 +172,29 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
// keep the exact compiler path and output contract by default.
|
||||
let compiledCount = 0;
|
||||
const compiledFiles = new Map<string, string>();
|
||||
const clientFiles = new Map<string, string>();
|
||||
const runtimeAnalysis = new Map<string, RuntimeRequirements>();
|
||||
const partialStaticFiles = new Set<string>();
|
||||
const compileWrn = async (file: string): Promise<void> => {
|
||||
if (!file.endsWith(".wrn") || compiledFiles.has(file)) return;
|
||||
const out = join(compiledDir, `route${compiledCount++}.ts`);
|
||||
// Reserve the artifact before resolving imports so cycles terminate and
|
||||
// mutually dependent generated modules can point at deterministic paths.
|
||||
compiledFiles.set(file, out);
|
||||
const source = readFileSync(file, "utf8");
|
||||
const artifactId = compiledCount++;
|
||||
const out = join(compiledDir, `route${artifactId}.ts`);
|
||||
const clientHash = createHash("sha256")
|
||||
.update(fwd(file))
|
||||
.update("\0")
|
||||
.update(source)
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
const clientOut = join(clientModulesDir, `${basename(file, ".wrn")}-${clientHash}.mjs`);
|
||||
const clientUrl = `/__wrnexus/client/${basename(clientOut)}`;
|
||||
const clientEntry = join(compiledDir, `client${artifactId}.entry.mjs`);
|
||||
|
||||
// Reserve both artifacts before resolving imports so dependency cycles
|
||||
// terminate and every generated import has a deterministic target.
|
||||
compiledFiles.set(file, out);
|
||||
clientFiles.set(file, clientOut);
|
||||
|
||||
let ast = parse(source);
|
||||
assertValidAst(ast, { file, accessibility: true });
|
||||
ast = await pluginRunner.transformAst(ast, file);
|
||||
@@ -193,18 +210,35 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
errors.map((diagnostic) => `[${diagnostic.code}] ${diagnostic.message}`).join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
let code = `// compiled from .wrn\n${generate(ast)}`;
|
||||
const targets = generateTargets(ast);
|
||||
let code = `// compiled from ${fwd(relative(root, file))}\n${generate(ast)}`.replaceAll(
|
||||
"__WRNEXUS_CLIENT_MODULE__",
|
||||
clientUrl,
|
||||
);
|
||||
let browserCode = `// browser module compiled from ${fwd(relative(root, file))}\n${targets.browser}`;
|
||||
code = await pluginRunner.transformCode(code, file);
|
||||
browserCode = await pluginRunner.transformCode(browserCode, file);
|
||||
|
||||
for (const [id, target] of virtualModules) {
|
||||
const sourcePattern = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const relativeTarget = relative(dirname(out), target).replace(/\\/g, "/");
|
||||
const specifier = relativeTarget.startsWith(".") ? relativeTarget : `./${relativeTarget}`;
|
||||
const mainRelative = relative(dirname(out), target).replace(/\\/g, "/");
|
||||
const mainSpecifier = mainRelative.startsWith(".") ? mainRelative : `./${mainRelative}`;
|
||||
code = code.replace(
|
||||
new RegExp(`(["'])${sourcePattern}\\1`, "g"),
|
||||
JSON.stringify(specifier),
|
||||
JSON.stringify(mainSpecifier),
|
||||
);
|
||||
const browserRelative = relative(dirname(clientEntry), target).replace(/\\/g, "/");
|
||||
const browserSpecifier = browserRelative.startsWith(".")
|
||||
? browserRelative
|
||||
: `./${browserRelative}`;
|
||||
browserCode = browserCode.replace(
|
||||
new RegExp(`(["'])${sourcePattern}\\1`, "g"),
|
||||
JSON.stringify(browserSpecifier),
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedImports = resolveWrnImports(ast.structuredImports, file, {
|
||||
appRoot: root,
|
||||
mode: config.imports?.mode ?? "compatible",
|
||||
@@ -214,23 +248,74 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
if (imported.diagnostic?.severity === "error") {
|
||||
throw new Error(`${imported.diagnostic.code}: ${imported.diagnostic.message}`);
|
||||
}
|
||||
if (!imported.resolved || !imported.declaration.source.startsWith(".")) continue;
|
||||
let target = imported.resolved;
|
||||
if (target.endsWith(".wrn")) {
|
||||
await compileWrn(target);
|
||||
target = compiledFiles.get(target)!;
|
||||
}
|
||||
if (!imported.resolved) continue;
|
||||
const sourcePattern = imported.declaration.source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const relativeTarget = relative(dirname(out), target).replace(/\\/g, "/");
|
||||
const specifier = relativeTarget.startsWith(".") ? relativeTarget : `./${relativeTarget}`;
|
||||
code = code.replace(
|
||||
new RegExp(`(["'])${sourcePattern}\\1`, "g"),
|
||||
JSON.stringify(specifier),
|
||||
|
||||
const importAliases = { "@": "./app", ...(config.imports?.aliases ?? {}) };
|
||||
const isApplicationImport =
|
||||
imported.declaration.source.startsWith(".") ||
|
||||
Object.keys(importAliases).some(
|
||||
(alias) =>
|
||||
imported.declaration.source === alias ||
|
||||
imported.declaration.source.startsWith(`${alias}/`),
|
||||
);
|
||||
if (isApplicationImport) {
|
||||
let target = imported.resolved;
|
||||
if (target.endsWith(".wrn")) {
|
||||
await compileWrn(target);
|
||||
target = compiledFiles.get(target)!;
|
||||
}
|
||||
const relativeTarget = relative(dirname(out), target).replace(/\\/g, "/");
|
||||
const specifier = relativeTarget.startsWith(".") ? relativeTarget : `./${relativeTarget}`;
|
||||
code = code.replace(
|
||||
new RegExp(`(["'])${sourcePattern}\\1`, "g"),
|
||||
JSON.stringify(specifier),
|
||||
);
|
||||
}
|
||||
|
||||
// Browser modules keep only imports referenced by client/shared code.
|
||||
// Resolve those imports relative to the original .wrn source before
|
||||
// bundling so no filesystem or bare package specifier reaches browsers.
|
||||
if (isApplicationImport && new RegExp(`(["'])${sourcePattern}\\1`).test(browserCode)) {
|
||||
let browserTarget = imported.resolved;
|
||||
if (browserTarget.endsWith(".wrn")) {
|
||||
await compileWrn(browserTarget);
|
||||
browserTarget = clientFiles.get(browserTarget)!;
|
||||
}
|
||||
const browserRelative = relative(dirname(clientEntry), browserTarget).replace(/\\/g, "/");
|
||||
const browserSpecifier = browserRelative.startsWith(".")
|
||||
? browserRelative
|
||||
: `./${browserRelative}`;
|
||||
browserCode = browserCode.replace(
|
||||
new RegExp(`(["'])${sourcePattern}\\1`, "g"),
|
||||
JSON.stringify(browserSpecifier),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(out, code, "utf8");
|
||||
writeFileSync(clientEntry, browserCode, "utf8");
|
||||
const browserResult = await Bun.build({
|
||||
entrypoints: [clientEntry],
|
||||
target: "browser",
|
||||
format: "esm",
|
||||
splitting: false,
|
||||
minify: true,
|
||||
sourcemap: config.build?.sourceMaps ? "inline" : "none",
|
||||
});
|
||||
if (!browserResult.success || !browserResult.outputs.length) {
|
||||
throw new Error(
|
||||
`WRN-CLIENT-BUNDLE: failed to bundle ${file}\n${browserResult.logs.map(String).join("\n")}`,
|
||||
);
|
||||
}
|
||||
writeFileSync(out, code, "utf8");
|
||||
writeFileSync(clientOut, await browserResult.outputs[0]!.text(), "utf8");
|
||||
rmSync(clientEntry, { force: true });
|
||||
} catch (error) {
|
||||
compiledFiles.delete(file);
|
||||
clientFiles.delete(file);
|
||||
rmSync(out, { force: true });
|
||||
rmSync(clientOut, { force: true });
|
||||
rmSync(clientEntry, { force: true });
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -369,6 +454,8 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
console.log(`✓ Partial shells: ${partialShells.size} build-time route shell(s)`);
|
||||
}
|
||||
const assetHash = createHash("sha256");
|
||||
for (const file of clientFiles.values()) assetHash.update(readFileSync(file));
|
||||
if (clientFiles.size) console.log(`✓ Client modules: ${clientFiles.size} bundled module(s)`);
|
||||
const emittedPluginAssets = await emitPluginAssets(
|
||||
pluginContributions,
|
||||
distDir,
|
||||
@@ -533,6 +620,7 @@ await createProductionServer(
|
||||
},
|
||||
{
|
||||
reactivePath: join(import.meta.dir, "reactive.js"),
|
||||
clientModulesDir: join(import.meta.dir, "client"),
|
||||
themePath: join(import.meta.dir, "theme.css"),
|
||||
themeJsPath: join(import.meta.dir, "theme.js"),
|
||||
theme: ${JSON.stringify(theme)},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { basename, extname, join, relative, resolve } from "node:path";
|
||||
import { buildRouter, createRouteManifest, nameRoutes } from "@wrnexus/router";
|
||||
import { checkWrnFile, type WrnTypeDiagnostic } from "@wrnexus/typecheck";
|
||||
import { parse } from "@wrnexus/syntax";
|
||||
import { generate, generateTargets } from "@wrnexus/compiler";
|
||||
import { regenerateRoutes } from "./routes.ts";
|
||||
import { loadAppConfig } from "@wrnexus/styles";
|
||||
import { createPluginRunner, discoverPlugins, type PluginContributions } from "@wrnexus/plugin";
|
||||
@@ -258,11 +259,51 @@ export async function generateApplicationTypesWithPlugins(
|
||||
return generateApplicationTypes(root, await runner.contributions());
|
||||
}
|
||||
|
||||
function codegenDiagnostics(file: string): WrnTypeDiagnostic[] {
|
||||
try {
|
||||
const ast = parse(readFileSync(file, "utf8"));
|
||||
const main = generate(ast);
|
||||
const targets = generateTargets(ast);
|
||||
const bun = (
|
||||
globalThis as unknown as {
|
||||
Bun?: {
|
||||
Transpiler?: new (options: { loader: string }) => {
|
||||
transformSync(code: string): unknown;
|
||||
};
|
||||
};
|
||||
}
|
||||
).Bun;
|
||||
if (bun?.Transpiler) {
|
||||
const transpiler = new bun.Transpiler({ loader: "ts" });
|
||||
transpiler.transformSync(main);
|
||||
transpiler.transformSync(targets.browser);
|
||||
transpiler.transformSync(targets.server);
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
return [
|
||||
{
|
||||
code: "WRN-CODEGEN",
|
||||
category: "error",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
file,
|
||||
line: 1,
|
||||
column: 1,
|
||||
length: 1,
|
||||
hint: "Fix the reported SSR/client code generation issue before starting or building the app.",
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export function checkApplication(appRoot: string): WrnTypeDiagnostic[] {
|
||||
const root = resolve(appRoot);
|
||||
return files(join(root, "app"), (path) => extname(path) === ".wrn").flatMap((file) =>
|
||||
checkWrnFile(file, { appRoot: root }),
|
||||
);
|
||||
return files(join(root, "app"), (path) => extname(path) === ".wrn").flatMap((file) => {
|
||||
const diagnostics = checkWrnFile(file, { appRoot: root });
|
||||
return diagnostics.some((item) => item.category === "error")
|
||||
? diagnostics
|
||||
: [...diagnostics, ...codegenDiagnostics(file)];
|
||||
});
|
||||
}
|
||||
|
||||
export async function runTypecheck(appRoot: string): Promise<boolean> {
|
||||
|
||||
@@ -1989,6 +1989,16 @@ const MIGRATIONS: Migration[] = [
|
||||
// Runtime stability fixes require no application source rewrite.
|
||||
},
|
||||
},
|
||||
{
|
||||
version: "0.8.3",
|
||||
id: "0.8.3-client-ssr-realtime-stability",
|
||||
description:
|
||||
"Fixes browser function bundling, SSR computed values, Async aliases, typed loop props, realtime identity isolation, loader invalidation, and strict package imports.",
|
||||
apply() {
|
||||
// Framework runtime and compiler fixes require no application source rewrite.
|
||||
// The compile-cache generation changes automatically invalidate old artifacts.
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Release tooling uses this to require an explicit migration entry per version. */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/compiler",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { eraseFunctionTypes, type PageAst, type RuntimeFunctionDecl } from "@wrnexus/syntax";
|
||||
import {
|
||||
eraseFunctionTypes,
|
||||
type PageAst,
|
||||
type RuntimeFunctionDecl,
|
||||
type StructuredImportDecl,
|
||||
type ViewNode,
|
||||
} from "@wrnexus/syntax";
|
||||
|
||||
const RESERVED_BINDINGS = new Set([
|
||||
"await",
|
||||
@@ -63,6 +69,111 @@ function safeIdentifier(name: string): boolean {
|
||||
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) && !RESERVED_BINDINGS.has(name);
|
||||
}
|
||||
|
||||
function identifierReferenced(source: string, name: string): boolean {
|
||||
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return new RegExp(`(?:^|[^A-Za-z0-9_$])${escaped}(?![A-Za-z0-9_$])`).test(source);
|
||||
}
|
||||
|
||||
function viewReferenceSource(nodes: ViewNode[]): string[] {
|
||||
return nodes.flatMap((node): string[] => {
|
||||
if (node.type === "text") return [node.value];
|
||||
if (node.type === "each") {
|
||||
return [
|
||||
node.list,
|
||||
node.key ?? "",
|
||||
...viewReferenceSource(node.body),
|
||||
...viewReferenceSource(node.empty),
|
||||
];
|
||||
}
|
||||
if (node.type === "if") {
|
||||
return node.branches.flatMap((branch) => [
|
||||
branch.cond ?? "",
|
||||
...viewReferenceSource(branch.body),
|
||||
]);
|
||||
}
|
||||
return [...node.attrs.map((attr) => attr.value), ...viewReferenceSource(node.children)];
|
||||
});
|
||||
}
|
||||
|
||||
function browserReferenceSource(ast: PageAst, functions: RuntimeFunctionDecl[]): string {
|
||||
return [
|
||||
...functions.flatMap((fn) => [fn.source, fn.body]),
|
||||
...ast.computed.map((entry) => entry.expr),
|
||||
...ast.effects,
|
||||
ast.lifecycle.mount ?? "",
|
||||
ast.lifecycle.update ?? "",
|
||||
ast.lifecycle.unmount ?? "",
|
||||
...viewReferenceSource(ast.view),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function renderSelectedImport(
|
||||
entry: StructuredImportDecl,
|
||||
source: string,
|
||||
): { code: string; bindings: string[] } | null {
|
||||
if (entry.typeOnly) return null;
|
||||
const isStore = entry.source.endsWith(".wrn") && /(?:^|\/)stores?\//.test(entry.source);
|
||||
if (entry.source.endsWith(".wrn") && !isStore) return null;
|
||||
|
||||
const defaultImport =
|
||||
entry.defaultImport && (isStore || identifierReferenced(source, entry.defaultImport))
|
||||
? entry.defaultImport
|
||||
: undefined;
|
||||
const namespaceImport =
|
||||
entry.namespaceImport && (isStore || identifierReferenced(source, entry.namespaceImport))
|
||||
? entry.namespaceImport
|
||||
: undefined;
|
||||
const namedImports = entry.namedImports.filter(
|
||||
(item) => !item.typeOnly && (isStore || identifierReferenced(source, item.local)),
|
||||
);
|
||||
|
||||
const bindings = [
|
||||
...(defaultImport ? [defaultImport] : []),
|
||||
...(namespaceImport ? [namespaceImport] : []),
|
||||
...namedImports.map((item) => item.local),
|
||||
].filter(safeIdentifier);
|
||||
|
||||
const hasBindings = Boolean(defaultImport || namespaceImport || namedImports.length);
|
||||
const sideEffectOnly =
|
||||
!entry.defaultImport && !entry.namespaceImport && entry.namedImports.length === 0;
|
||||
if (!hasBindings && !sideEffectOnly) return null;
|
||||
if (sideEffectOnly) return { code: entry.raw, bindings: [] };
|
||||
|
||||
const clauses: string[] = [];
|
||||
if (defaultImport) clauses.push(defaultImport);
|
||||
if (namespaceImport) clauses.push(`* as ${namespaceImport}`);
|
||||
if (namedImports.length) {
|
||||
clauses.push(
|
||||
`{ ${namedImports
|
||||
.map((item) =>
|
||||
item.imported === item.local ? item.imported : `${item.imported} as ${item.local}`,
|
||||
)
|
||||
.join(", ")} }`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
code: `import ${clauses.join(", ")} from ${JSON.stringify(entry.source)};`,
|
||||
bindings,
|
||||
};
|
||||
}
|
||||
|
||||
function selectedBrowserImports(
|
||||
ast: PageAst,
|
||||
functions: RuntimeFunctionDecl[],
|
||||
): { code: string; bindings: string[] }[] {
|
||||
const referenceSource = browserReferenceSource(ast, functions);
|
||||
return ast.structuredImports
|
||||
.map((entry) => renderSelectedImport(entry, referenceSource))
|
||||
.filter((entry): entry is { code: string; bindings: string[] } => entry !== null);
|
||||
}
|
||||
|
||||
export function browserModuleRequired(ast: PageAst): boolean {
|
||||
const functions = ast.runtimeFunctions.filter((fn) =>
|
||||
["legacy", "client", "shared"].includes(fn.runtime),
|
||||
);
|
||||
return functions.length > 0 || selectedBrowserImports(ast, functions).length > 0;
|
||||
}
|
||||
|
||||
function functionEntry(
|
||||
ast: PageAst,
|
||||
fn: RuntimeFunctionDecl,
|
||||
@@ -164,22 +275,9 @@ export function generateBrowserModule(ast: PageAst): string {
|
||||
);
|
||||
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);
|
||||
const selectedImports = selectedBrowserImports(ast, functions);
|
||||
const imports = selectedImports.map((entry) => entry.code).join("\n");
|
||||
const importedBindings = [...new Set(selectedImports.flatMap((entry) => entry.bindings))];
|
||||
return `// generated WRNexusJS browser module for ${ast.name}
|
||||
${imports}
|
||||
export const __wrnexusClientFunctions = {
|
||||
|
||||
@@ -22,6 +22,7 @@ import { eraseFunctionTypes, runtimeTypeOf } from "./types.ts";
|
||||
import { stripRuntimeFunctionModifiers } from "@wrnexus/syntax";
|
||||
import { generateStoreModule } from "./store-codegen.ts";
|
||||
import { optimizeAst } from "./analysis.ts";
|
||||
import { browserModuleRequired } from "./client-codegen.ts";
|
||||
|
||||
interface RenderBinding {
|
||||
method: string;
|
||||
@@ -264,12 +265,21 @@ function evalStateSeeds(states: { name: string; expr: string }[]): Record<string
|
||||
* runtime keeps it live. Non-state `{expr}` and un-evaluable expressions are
|
||||
* left as literal client mustaches.
|
||||
*/
|
||||
function substituteReactiveText(raw: string, reactive: PageReactive | null): string {
|
||||
function substituteReactiveText(
|
||||
raw: string,
|
||||
reactive: PageReactive | null,
|
||||
dynamicExpressions?: string[],
|
||||
): string {
|
||||
const text = substituteTMarkers(raw);
|
||||
if (!reactive || reactive.stateNames.size === 0) return text;
|
||||
return text.replace(/\{([^{}]+)\}/g, (whole, inner: string) => {
|
||||
const expr = inner.trim();
|
||||
if (expr.startsWith("t:") || !exprRefsState(expr, reactive.stateNames)) return whole;
|
||||
if (exprRefsState(expr, reactive.runtimeStateNames) && dynamicExpressions) {
|
||||
dynamicExpressions.push(`\${__wrnexusEscapeHtml(${expr})}`);
|
||||
const sentinel = `\x00WRNEACH${dynamicExpressions.length - 1}\x00`;
|
||||
return `<span data-text="${attrEscape(expr)}">${sentinel}</span>`;
|
||||
}
|
||||
let value: unknown;
|
||||
try {
|
||||
value = new Function("with(this){return (" + expr + ");}").call(reactive.scope);
|
||||
@@ -308,7 +318,9 @@ function bakeLoopText(raw: string): string {
|
||||
}
|
||||
|
||||
/** Bake a loop-body attribute value (same rules as text; escapeHtml is attribute-safe). */
|
||||
function bakeLoopAttr(raw: string): string {
|
||||
function bakeLoopAttr(raw: string, typed = false): string {
|
||||
const wholeExpression = wholeAttributeExpression(raw);
|
||||
if (typed && wholeExpression) return "${__wrnexusPropAttr(" + wholeExpression + ")}";
|
||||
if (!raw.includes("{")) return escLit(attrEscape(raw));
|
||||
let out = "";
|
||||
let last = 0;
|
||||
@@ -347,7 +359,7 @@ function renderLoopBody(node: ViewNode): string {
|
||||
return escLit(` ${name}`);
|
||||
}
|
||||
|
||||
return escLit(` ${name}="`) + bakeLoopAttr(attr.value) + escLit(`"`);
|
||||
return escLit(` ${name}="`) + bakeLoopAttr(attr.value, componentTag) + escLit(`"`);
|
||||
})
|
||||
.join("");
|
||||
|
||||
@@ -492,7 +504,7 @@ function renderNode(
|
||||
loops: string[],
|
||||
reactive: PageReactive | null = null,
|
||||
): string {
|
||||
if (node.type === "text") return substituteReactiveText(node.value, reactive); // {t:key} + state baking
|
||||
if (node.type === "text") return substituteReactiveText(node.value, reactive, loops); // {t:key} + state baking
|
||||
|
||||
// Server control block (loop / conditional) → a sentinel that survives
|
||||
// templateEscape, swapped for its real `${…}` code after escaping.
|
||||
@@ -532,13 +544,15 @@ function renderNode(
|
||||
if (node.tag === "Async") {
|
||||
const source = attrValue(node.attrs, "source") ?? "data";
|
||||
const retries = attrValue(node.attrs, "retries") ?? "2";
|
||||
const tags = attrValue(node.attrs, "tags") ?? source;
|
||||
const serverResolved = attrValue(node.attrs, "data-wrn-async-server") === "true";
|
||||
const asyncIndex = serverResolved ? loops.push("") - 1 : -1;
|
||||
const branch = (name: string) => {
|
||||
const element = node.children.find(
|
||||
const branchElement = (name: string) =>
|
||||
node.children.find(
|
||||
(child): child is Extract<ViewNode, { type: "element" }> =>
|
||||
child.type === "element" && child.tag === name,
|
||||
);
|
||||
const branch = (name: string) => {
|
||||
const element = branchElement(name);
|
||||
return (element?.children ?? [])
|
||||
.map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive))
|
||||
.join("");
|
||||
@@ -546,19 +560,42 @@ function renderNode(
|
||||
const loading = branch("Loading");
|
||||
const success = branch("Success");
|
||||
const error = branch("Error");
|
||||
const successElement = branchElement("Success");
|
||||
const errorElement = branchElement("Error");
|
||||
const identifier = (value: string | undefined, fallback: string) =>
|
||||
value && isSafeGeneratedIdentifier(value) ? value : fallback;
|
||||
const successAlias = identifier(
|
||||
successElement ? attrValue(successElement.attrs, "data") : undefined,
|
||||
identifier(source, "data"),
|
||||
);
|
||||
const errorAlias = identifier(
|
||||
errorElement ? attrValue(errorElement.attrs, "error") : undefined,
|
||||
"error",
|
||||
);
|
||||
const nested = (value: string) => value.replace(/\\/g, "\\\\").replace(/`/g, "\\`");
|
||||
const scoped = (value: string, alias: string, expression: string) => {
|
||||
const index =
|
||||
loops.push(
|
||||
`\${(() => { const ${alias} = ${expression}; return \`${nested(value)}\`; })()}`,
|
||||
) - 1;
|
||||
return `\x00WRNEACH${index}\x00`;
|
||||
};
|
||||
const successTemplate = scoped(success, successAlias, `(ctx[${JSON.stringify(source)}] ?? {})`);
|
||||
const errorTemplate = scoped(error, errorAlias, `{ message: "" }`);
|
||||
let initial = loading;
|
||||
if (serverResolved) {
|
||||
const nested = (value: string) => value.replace(/\\/g, "\\\\").replace(/`/g, "\\`");
|
||||
const sourcePattern = source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const sourcePattern = successAlias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const serverSuccess = success.replace(
|
||||
new RegExp(`\\{\\s*(${sourcePattern}(?:\\.[A-Za-z_$][\\w$]*)*)\\s*\\}`, "g"),
|
||||
(_whole, expression: string) => `\${__wrnexusEscapeHtml(${expression})}`,
|
||||
);
|
||||
loops[asyncIndex] =
|
||||
`\${ctx[${JSON.stringify(source)}] !== undefined ? \`${nested(serverSuccess)}\` : \`${nested(loading)}\`}`;
|
||||
initial = `\x00WRNEACH${asyncIndex}\x00`;
|
||||
const index =
|
||||
loops.push(
|
||||
`\${ctx[${JSON.stringify(source)}] !== undefined ? (() => { const ${successAlias} = ctx[${JSON.stringify(source)}]; return \`${nested(serverSuccess)}\`; })() : \`${nested(loading)}\`}`,
|
||||
) - 1;
|
||||
initial = `\x00WRNEACH${index}\x00`;
|
||||
}
|
||||
return `<section data-wrn-async="${attrEscape(source)}" data-wrn-async-retries="${attrEscape(retries)}"${serverResolved ? ' data-wrn-async-resolved="true"' : ""} aria-busy="${serverResolved ? "false" : "true"}"><div data-wrn-async-content>${initial}</div><template data-wrn-async-loading>${loading}</template><template data-wrn-async-success>${success}</template><template data-wrn-async-error>${error}</template></section>`;
|
||||
return `<section data-wrn-async="${attrEscape(source)}" data-wrn-async-tags="${attrEscape(tags)}" data-wrn-async-retries="${attrEscape(retries)}"${serverResolved ? ' data-wrn-async-resolved="true"' : ""} aria-busy="${serverResolved ? "false" : "true"}"><div data-wrn-async-content>${initial}</div><template data-wrn-async-loading>${loading}</template><template data-wrn-async-success data-wrn-async-alias="${attrEscape(successAlias)}">${successTemplate}</template><template data-wrn-async-error data-wrn-async-alias="${attrEscape(errorAlias)}">${errorTemplate}</template></section>`;
|
||||
}
|
||||
|
||||
if (node.tag === "KeepAlive") {
|
||||
@@ -965,13 +1002,133 @@ function generateSsrStateAliases(stateNames: string[]): string {
|
||||
return `const { ${names.join(", ")} } = __state;\n`;
|
||||
}
|
||||
|
||||
function orderPageStates(entries: PageAst["states"]): PageAst["states"] {
|
||||
if (entries.length < 2) return entries;
|
||||
const byName = new Map(entries.map((entry) => [entry.name, entry]));
|
||||
const visiting = new Set<string>();
|
||||
const visited = new Set<string>();
|
||||
const ordered: PageAst["states"] = [];
|
||||
|
||||
const visit = (name: string): void => {
|
||||
if (visited.has(name)) return;
|
||||
if (visiting.has(name)) {
|
||||
throw new Error(`WRN-STATE-CYCLE: state value '${name}' has a dependency cycle.`);
|
||||
}
|
||||
const entry = byName.get(name);
|
||||
if (!entry) return;
|
||||
visiting.add(name);
|
||||
for (const dependency of byName.keys()) {
|
||||
if (
|
||||
dependency !== name &&
|
||||
new RegExp(`\\b${dependency.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(entry.expr)
|
||||
) {
|
||||
visit(dependency);
|
||||
}
|
||||
}
|
||||
visiting.delete(name);
|
||||
visited.add(name);
|
||||
ordered.push(entry);
|
||||
};
|
||||
|
||||
for (const entry of entries) visit(entry.name);
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function generateSsrStateInitializer(entries: PageAst["states"]): string {
|
||||
const ordered = orderPageStates(entries);
|
||||
const declarations = ordered
|
||||
.filter((entry) => isSafeGeneratedIdentifier(entry.name))
|
||||
.map(
|
||||
(entry) =>
|
||||
`const ${entry.name} = (() => { try { return (${entry.expr}); } catch { return undefined; } })();`,
|
||||
)
|
||||
.join(" ");
|
||||
const values = entries
|
||||
.map((entry) => {
|
||||
if (isSafeGeneratedIdentifier(entry.name)) {
|
||||
return `${JSON.stringify(entry.name)}: ${entry.name}`;
|
||||
}
|
||||
return `${JSON.stringify(entry.name)}: (() => { try { return (${entry.expr}); } catch { return undefined; } })()`;
|
||||
})
|
||||
.join(", ");
|
||||
return `(() => { ${declarations} return { ${values} }; })()`;
|
||||
}
|
||||
|
||||
function orderPageComputed(entries: PageAst["computed"]): PageAst["computed"] {
|
||||
if (entries.length < 2) return entries;
|
||||
const byName = new Map(entries.map((entry) => [entry.name, entry]));
|
||||
const visiting = new Set<string>();
|
||||
const visited = new Set<string>();
|
||||
const ordered: PageAst["computed"] = [];
|
||||
|
||||
const visit = (name: string): void => {
|
||||
if (visited.has(name)) return;
|
||||
if (visiting.has(name)) {
|
||||
throw new Error(`WRN-COMPUTED-CYCLE: computed value '${name}' has a dependency cycle.`);
|
||||
}
|
||||
const entry = byName.get(name);
|
||||
if (!entry) return;
|
||||
visiting.add(name);
|
||||
for (const dependency of byName.keys()) {
|
||||
if (
|
||||
dependency !== name &&
|
||||
new RegExp(`\\b${dependency.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(entry.expr)
|
||||
) {
|
||||
visit(dependency);
|
||||
}
|
||||
}
|
||||
visiting.delete(name);
|
||||
visited.add(name);
|
||||
ordered.push(entry);
|
||||
};
|
||||
|
||||
for (const entry of entries) visit(entry.name);
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function generateSsrComputedAliases(entries: PageAst["computed"]): string {
|
||||
return orderPageComputed(entries)
|
||||
.filter((entry) => isSafeGeneratedIdentifier(entry.name))
|
||||
.map(
|
||||
(entry) =>
|
||||
`const ${entry.name} = (() => { try { return (${entry.expr}); } catch { return undefined; } })();`,
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function runtimeComputedNames(
|
||||
states: PageAst["states"],
|
||||
computed: PageAst["computed"],
|
||||
runtimeRoots: Iterable<string> = [],
|
||||
): Set<string> {
|
||||
const entries = [...states, ...computed];
|
||||
const runtime = new Set([
|
||||
...runtimeRoots,
|
||||
...entries.filter((entry) => /\bctx\b/.test(entry.expr)).map((entry) => entry.name),
|
||||
]);
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const entry of entries) {
|
||||
if (runtime.has(entry.name)) continue;
|
||||
if (
|
||||
[...runtime].some((name) =>
|
||||
new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(entry.expr),
|
||||
)
|
||||
) {
|
||||
runtime.add(entry.name);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
function hydrationAttribute(ast: PageAst): string {
|
||||
const strategy = ["static", "server"].includes(ast.renderMode ?? "")
|
||||
? "none"
|
||||
: (ast.hydrate ?? "load");
|
||||
const hasBrowserModule = ast.runtimeFunctions.some((fn) =>
|
||||
["legacy", "client", "shared"].includes(fn.runtime),
|
||||
);
|
||||
const hasBrowserModule = browserModuleRequired(ast);
|
||||
const moduleAttribute = hasBrowserModule
|
||||
? ' data-wrn-client-module="__WRNEXUS_CLIENT_MODULE__"'
|
||||
: "";
|
||||
@@ -1119,9 +1276,11 @@ export function generate(ast: PageAst): string {
|
||||
}
|
||||
|
||||
// --- View -> default page component ---
|
||||
const orderedStates = orderPageStates(ast.states);
|
||||
const browserStates = ast.states.filter((state) => state.runtime !== "server");
|
||||
const seedScope = evalStateSeeds(ast.states);
|
||||
for (const entry of ast.computed) {
|
||||
const seedScope = evalStateSeeds(orderedStates);
|
||||
const orderedComputed = orderPageComputed(ast.computed);
|
||||
for (const entry of orderedComputed) {
|
||||
try {
|
||||
seedScope[entry.name] = new Function("with(this){return (" + entry.expr + ");}").call(
|
||||
seedScope,
|
||||
@@ -1134,9 +1293,14 @@ export function generate(ast: PageAst): string {
|
||||
...browserStates.map((entry) => entry.name),
|
||||
...ast.computed.map((entry) => entry.name),
|
||||
];
|
||||
const runtimeStateNames = new Set(
|
||||
ast.states.filter((entry) => /\bctx\b/.test(entry.expr)).map((entry) => entry.name),
|
||||
);
|
||||
const storeBindings = importedStoreBindings(ast);
|
||||
const runtimeRoots = [
|
||||
...storeBindings.map((entry) => entry.local),
|
||||
...ast.loads
|
||||
.filter((load) => load.mode === "server" && !load.deferred && load.name)
|
||||
.map((load) => load.name!),
|
||||
];
|
||||
const runtimeStateNames = runtimeComputedNames(orderedStates, orderedComputed, runtimeRoots);
|
||||
const reactive: PageReactive | null =
|
||||
reactiveNames.length > 0
|
||||
? { stateNames: new Set(reactiveNames), runtimeStateNames, scope: seedScope }
|
||||
@@ -1187,12 +1351,7 @@ export function generate(ast: PageAst): string {
|
||||
);
|
||||
staticShellBody = templateEscape(shellHtml);
|
||||
}
|
||||
const dynamicStateScope = ast.states
|
||||
.map(
|
||||
(state) =>
|
||||
`${JSON.stringify(state.name)}: (() => { try { return (${state.expr}); } catch { return undefined; } })()`,
|
||||
)
|
||||
.join(", ");
|
||||
const dynamicStateInitializer = generateSsrStateInitializer(orderedStates);
|
||||
const stateType =
|
||||
ast.states.length > 0
|
||||
? `{ ${ast.states
|
||||
@@ -1202,8 +1361,8 @@ export function generate(ast: PageAst): string {
|
||||
const hydrationStateNames = JSON.stringify(browserStates.map((state) => state.name));
|
||||
|
||||
const ssrStateAliases = generateSsrStateAliases(ast.states.map((state) => state.name));
|
||||
const ssrComputedAliases = generateSsrComputedAliases(orderedComputed);
|
||||
|
||||
const storeBindings = importedStoreBindings(ast);
|
||||
const storeDeclarations = storeBindings
|
||||
.map(
|
||||
(entry) => ` const ${entry.local} = await ctx.__wrnexusUseStore(${entry.internal});`,
|
||||
@@ -1214,12 +1373,16 @@ export function generate(ast: PageAst): string {
|
||||
.map((load) => ` const ${load.name} = ctx[${JSON.stringify(load.name)}];`)
|
||||
.join("\n");
|
||||
|
||||
loops.forEach((code, idx) => {
|
||||
body = body.replace(`\x00WRNEACH${idx}\x00`, () => code);
|
||||
// Inner dynamic expressions are registered before the wrapper that scopes
|
||||
// them (for example an Async branch alias). Resolve from the outside in so a
|
||||
// wrapper sentinel is expanded before its nested sentinels are visited.
|
||||
for (let idx = loops.length - 1; idx >= 0; idx--) {
|
||||
const code = loops[idx]!;
|
||||
body = body.replaceAll(`\x00WRNEACH${idx}\x00`, code);
|
||||
if (staticShellBody?.includes(`\x00WRNEACH${idx}\x00`)) {
|
||||
staticShellBody = staticShellBody.replace(`\x00WRNEACH${idx}\x00`, () => code);
|
||||
staticShellBody = staticShellBody.replaceAll(`\x00WRNEACH${idx}\x00`, code);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Server loops iterate raw SSR data. Declare a named const for every `ssr` data
|
||||
// binding a loop references, so `{#each <name> as …}` can iterate the real value.
|
||||
@@ -1235,10 +1398,7 @@ export function generate(ast: PageAst): string {
|
||||
}
|
||||
}
|
||||
|
||||
const needsSsrRuntime =
|
||||
ssrBindings.length > 0 ||
|
||||
loops.length > 0 ||
|
||||
ast.states.some((state) => /\bctx\b/.test(state.expr));
|
||||
const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0 || runtimeStateNames.size > 0;
|
||||
if (needsSsrRuntime) {
|
||||
out.push(ssrRuntimeSource());
|
||||
out.push(`const __wrnexusSsrBindings = ${JSON.stringify(ssrBindings, null, 2)};`);
|
||||
@@ -1248,16 +1408,18 @@ export function generate(ast: PageAst): string {
|
||||
${storeDeclarations}
|
||||
${serverLoadAliases}
|
||||
${decls}
|
||||
const __state: ${stateType} = { ${dynamicStateScope} };
|
||||
const __state: ${stateType} = ${dynamicStateInitializer};
|
||||
${ssrStateAliases}
|
||||
${ssrComputedAliases}
|
||||
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"
|
||||
? String(value)
|
||||
: JSON.stringify(value == null ? "" : String(value));
|
||||
|
||||
let encoded: string;
|
||||
try {
|
||||
encoded = value === undefined ? "undefined" : JSON.stringify(value);
|
||||
} catch {
|
||||
encoded = JSON.stringify(String(value));
|
||||
}
|
||||
return key + ": " + encoded;
|
||||
})
|
||||
.join(", ")
|
||||
@@ -1279,16 +1441,18 @@ export function generate(ast: PageAst): string {
|
||||
`export default ${storeBindings.length > 0 ? "async " : ""}function ${ast.name}(ctx: any) {
|
||||
${storeDeclarations}
|
||||
${serverLoadAliases}
|
||||
const __state: ${stateType} = { ${dynamicStateScope} };
|
||||
const __state: ${stateType} = ${dynamicStateInitializer};
|
||||
${ssrStateAliases}
|
||||
${ssrComputedAliases}
|
||||
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"
|
||||
? String(value)
|
||||
: JSON.stringify(value == null ? "" : String(value));
|
||||
|
||||
let encoded: string;
|
||||
try {
|
||||
encoded = value === undefined ? "undefined" : JSON.stringify(value);
|
||||
} catch {
|
||||
encoded = JSON.stringify(String(value));
|
||||
}
|
||||
return key + ": " + encoded;
|
||||
})
|
||||
.join(", ")
|
||||
@@ -1311,14 +1475,18 @@ export function generate(ast: PageAst): string {
|
||||
${storeDeclarations}
|
||||
${serverLoadAliases}
|
||||
${loopConsts.length > 0 ? loopConsts.join("\n") : ""}
|
||||
const __state: ${stateType} = { ${dynamicStateScope} };
|
||||
const __state: ${stateType} = ${dynamicStateInitializer};
|
||||
${ssrStateAliases}
|
||||
${ssrComputedAliases}
|
||||
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"
|
||||
? String(value)
|
||||
: JSON.stringify(value == null ? "" : String(value));
|
||||
let encoded: string;
|
||||
try {
|
||||
encoded = value === undefined ? "undefined" : JSON.stringify(value);
|
||||
} catch {
|
||||
encoded = JSON.stringify(String(value));
|
||||
}
|
||||
return key + ": " + encoded;
|
||||
})
|
||||
.join(", ")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync, realpathSync } from "node:fs";
|
||||
import { existsSync, realpathSync, statSync } from "node:fs";
|
||||
import { dirname, extname, join, resolve } from "node:path";
|
||||
import type { StructuredImportDecl } from "@wrnexus/syntax";
|
||||
|
||||
@@ -33,12 +33,29 @@ export function resolveWrnImport(
|
||||
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))
|
||||
const aliases: Record<string, string> = {
|
||||
"@": "./app",
|
||||
...(options.aliases ?? {}),
|
||||
};
|
||||
const alias = Object.keys(aliases)
|
||||
.filter((key) => key.length > 0 && (source === key || source.startsWith(`${key}/`)))
|
||||
.sort((left, right) => right.length - left.length)[0];
|
||||
if (!source.startsWith(".") && !alias) return { declaration, resolved: source };
|
||||
const base = alias
|
||||
? resolve(
|
||||
options.appRoot,
|
||||
aliases[alias]!,
|
||||
source === alias ? "" : source.slice(alias.length + 1),
|
||||
)
|
||||
: resolve(dirname(importer), source);
|
||||
const found = candidates(base).find(existsSync);
|
||||
const found = candidates(base).find((candidate) => {
|
||||
if (!existsSync(candidate)) return false;
|
||||
try {
|
||||
return statSync(candidate).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (found) return { declaration, resolved: realpathSync(found) };
|
||||
const severity = (options.mode ?? "compatible") === "explicit" ? "error" : "warning";
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { parse, resolveWrnImports } from "../src/index.ts";
|
||||
|
||||
test("configured import aliases resolve application client modules", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-import-alias-"));
|
||||
const app = join(root, "app");
|
||||
const page = join(app, "settings.wrn");
|
||||
const helper = join(app, "client", "storage.ts");
|
||||
const clientIndex = join(app, "client", "index.ts");
|
||||
mkdirSync(join(app, "client"), { recursive: true });
|
||||
writeFileSync(helper, "export function persist(value: unknown) { return value; }\n");
|
||||
writeFileSync(clientIndex, "export const storageReady = true;\n");
|
||||
|
||||
try {
|
||||
const ast = parse(`import { persist } from "~/client/storage.ts"
|
||||
page Settings { view { <button>Save</button> } }`);
|
||||
const [resolved] = resolveWrnImports(ast.structuredImports, page, {
|
||||
appRoot: root,
|
||||
mode: "explicit",
|
||||
aliases: { "~": "./app" },
|
||||
});
|
||||
expect(resolved?.diagnostic).toBeUndefined();
|
||||
expect(resolved?.resolved).toBe(helper);
|
||||
|
||||
const directoryAst = parse(`import { storageReady } from "~client"
|
||||
page ClientIndex { view { <p>Ready</p> } }`);
|
||||
const [directoryResolved] = resolveWrnImports(directoryAst.structuredImports, page, {
|
||||
appRoot: root,
|
||||
mode: "explicit",
|
||||
aliases: { "~client": "./app/client" },
|
||||
});
|
||||
expect(directoryResolved?.diagnostic).toBeUndefined();
|
||||
expect(directoryResolved?.resolved).toBe(clientIndex);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { generate, generateTargets, parse } from "../src/index.ts";
|
||||
|
||||
test("page computed values derived from request state are declared during SSR", () => {
|
||||
const code = generate(
|
||||
parse(`import UserCard from "./UserCard.wrn"
|
||||
page Chat {
|
||||
state {
|
||||
currentUserId = ctx.url.searchParams.get("as") ?? "asha"
|
||||
users = [{ id: "asha", name: "Asha" }, { id: "rohan", name: "Rohan" }]
|
||||
}
|
||||
computed {
|
||||
currentUser = users.find((user) => user.id === currentUserId) ?? users[0]
|
||||
}
|
||||
view {
|
||||
<h1>{currentUser.name}</h1>
|
||||
{#each users as user}
|
||||
<UserCard user='{user}' />
|
||||
<div data-user='{user.id}'></div>
|
||||
{/each}
|
||||
}
|
||||
}`),
|
||||
);
|
||||
|
||||
expect(code).toContain("const currentUser =");
|
||||
expect(code).toContain("__wrnexusEscapeHtml(currentUser.name)");
|
||||
expect(code).toContain("__wrnexusPropAttr(user)");
|
||||
expect(code).toContain("__wrnexusEscapeHtml(user.id)");
|
||||
expect(code).not.toContain("__wrnexusPropAttr(user.id)");
|
||||
expect(code).toContain('encoded = value === undefined ? "undefined" : JSON.stringify(value)');
|
||||
expect(code).not.toContain("String((__state as any)[key])");
|
||||
});
|
||||
|
||||
test("computed values that reference ctx directly stay dynamic during SSR", () => {
|
||||
const code = generate(
|
||||
parse(`page DirectRequestComputed {
|
||||
computed { identity = ctx.url.searchParams.get("as") ?? "asha" }
|
||||
view { <p>{identity}</p> }
|
||||
}`),
|
||||
);
|
||||
expect(code).toContain("const identity =");
|
||||
expect(code).toContain("__wrnexusEscapeHtml(identity)");
|
||||
});
|
||||
|
||||
test("computed values derived from server loads stay dynamic during SSR", () => {
|
||||
const code = generate(
|
||||
parse(`page LoadedComputed {
|
||||
load server overview { return { count: 4 } }
|
||||
computed { total = overview.count + 1 }
|
||||
view { <p>{total}</p> }
|
||||
}`),
|
||||
);
|
||||
expect(code).toContain('const overview = ctx["overview"]');
|
||||
expect(code).toContain("__wrnexusEscapeHtml(total)");
|
||||
});
|
||||
|
||||
test("request-derived state dependencies stay dynamic during SSR", async () => {
|
||||
const code = generate(
|
||||
parse(`page Identity {
|
||||
state {
|
||||
userId = ctx.url.searchParams.get("as") ?? "asha"
|
||||
label = "User: " + userId
|
||||
}
|
||||
view { <p>{label}</p> }
|
||||
}`),
|
||||
);
|
||||
expect(code).toContain("__wrnexusEscapeHtml(label)");
|
||||
expect(code).not.toContain('<span data-text="label"></span>');
|
||||
const javascript = new Bun.Transpiler({ loader: "ts" }).transformSync(code);
|
||||
const moduleUrl = `data:text/javascript;base64,${Buffer.from(javascript).toString("base64")}`;
|
||||
const rendered = await (
|
||||
await import(moduleUrl)
|
||||
).default({
|
||||
url: new URL("http://localhost/?as=rohan"),
|
||||
});
|
||||
expect(rendered).toContain("User: rohan");
|
||||
});
|
||||
|
||||
test("Async aliases and invalidation tags are scoped in generated templates", () => {
|
||||
const code = generate(
|
||||
parse(`page Uploads {
|
||||
load client uploads { return { name: "report.pdf" } }
|
||||
view {
|
||||
<Async source="uploads" tags="uploads,files">
|
||||
<Loading>Loading</Loading>
|
||||
<Success data="file"><a href="/files/{file.name}">{file.name}</a></Success>
|
||||
<Error error="problem"><p>{problem.message}</p></Error>
|
||||
</Async>
|
||||
}
|
||||
}`),
|
||||
);
|
||||
|
||||
expect(code).toContain('data-wrn-async-tags="uploads,files"');
|
||||
expect(code).toContain('data-wrn-async-alias="file"');
|
||||
expect(code).toContain('data-wrn-async-alias="problem"');
|
||||
expect(code).toContain("const file =");
|
||||
expect(code).toContain("const problem =");
|
||||
});
|
||||
|
||||
test("browser codegen prunes SSR-only UI imports and keeps client dependencies", () => {
|
||||
const browser = generateTargets(
|
||||
parse(`import { Button } from "@wrnexus/ui"
|
||||
import { save } from "./helper.ts"
|
||||
component Settings {
|
||||
state { enabled = true }
|
||||
functions {
|
||||
client function persist(): void { save(enabled) }
|
||||
}
|
||||
view {
|
||||
<Button label="Save" />
|
||||
<button @click='persist()'>Save</button>
|
||||
}
|
||||
}`),
|
||||
).browser;
|
||||
|
||||
expect(browser).not.toContain("@wrnexus/ui");
|
||||
expect(browser).toContain('import { save } from "./helper.ts"');
|
||||
expect(browser).toContain('"persist": function');
|
||||
});
|
||||
|
||||
test("reactive view imports trigger a browser module without hydrating static SSR helpers", () => {
|
||||
const reactiveAst = parse(`import { formatName } from "./format.ts"
|
||||
page Profile {
|
||||
state { name = "Asha" }
|
||||
view { <p>{formatName(name)}</p> }
|
||||
}`);
|
||||
const staticAst = parse(`import { readFileSync } from "node:fs"
|
||||
page StaticProfile {
|
||||
runtime = "server"
|
||||
view { <p>{readFileSync("profile.txt", "utf8")}</p> }
|
||||
}`);
|
||||
|
||||
expect(generate(reactiveAst)).toContain('data-wrn-client-module="__WRNEXUS_CLIENT_MODULE__"');
|
||||
expect(generateTargets(reactiveAst).browser).toContain(
|
||||
'import { formatName } from "./format.ts"',
|
||||
);
|
||||
expect(generate(staticAst)).not.toContain("data-wrn-client-module");
|
||||
});
|
||||
|
||||
test("state dependency cycles fail code generation", () => {
|
||||
expect(() =>
|
||||
generate(
|
||||
parse(`page CyclicState {
|
||||
state { first = second + 1 second = first + 1 }
|
||||
view { <p>{first}</p> }
|
||||
}`),
|
||||
),
|
||||
).toThrow("WRN-STATE-CYCLE");
|
||||
});
|
||||
|
||||
test("computed dependency cycles fail code generation", () => {
|
||||
expect(() =>
|
||||
generate(
|
||||
parse(`page Cyclic {
|
||||
computed { first = second + 1 second = first + 1 }
|
||||
view { <p>{first}</p> }
|
||||
}`),
|
||||
),
|
||||
).toThrow("WRN-COMPUTED-CYCLE");
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/content",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"type": "module",
|
||||
"description": "Typed Markdown content collections, loaders, indexes, feeds, and preview workflows for WRNexusJS.",
|
||||
"main": "./src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/core",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -135,7 +135,6 @@ const DEFAULT_PERMISSIONS_POLICY: PermissionsPolicyConfig = {
|
||||
payment: [],
|
||||
"picture-in-picture": [],
|
||||
"sync-xhr": [],
|
||||
unload: [],
|
||||
usb: [],
|
||||
"xr-spatial-tracking": [],
|
||||
};
|
||||
|
||||
@@ -72,3 +72,8 @@ test("resolveRequestUrl honors standard Forwarded headers behind trusted proxies
|
||||
expect(resolveRequestUrl(request, true).href).toBe("https://workroot.in/path");
|
||||
expect(resolveRequestUrl(request, false).href).toBe("http://127.0.0.1:3000/path");
|
||||
});
|
||||
|
||||
test("default permissions policy does not emit unsupported unload", () => {
|
||||
const res = withSecurityHeaders(req(), new Response("x"), "development");
|
||||
expect(res.headers.get("permissions-policy") ?? "").not.toContain("unload=");
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -702,5 +702,10 @@ export const NAV_RUNTIME = String.raw`
|
||||
false,
|
||||
);
|
||||
};
|
||||
|
||||
/** Re-render the current route without adding a duplicate history entry. */
|
||||
window.__wrnexusRefresh = function () {
|
||||
navigate(location.href, true);
|
||||
};
|
||||
})();
|
||||
`.trim();
|
||||
|
||||
@@ -363,6 +363,26 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function routeSnapshot() {
|
||||
var url = new URL(window.location.href);
|
||||
var params = window.__wrnexusRouteParams || {};
|
||||
var marker = document.querySelector("[data-wrn-route-params]");
|
||||
if (marker) {
|
||||
try { params = JSON.parse(marker.getAttribute("data-wrn-route-params") || "{}"); }
|
||||
catch (_) { params = {}; }
|
||||
window.__wrnexusRouteParams = params;
|
||||
marker.remove();
|
||||
}
|
||||
return {
|
||||
url: url.href,
|
||||
pathname: url.pathname,
|
||||
search: url.search,
|
||||
hash: url.hash,
|
||||
searchParams: url.searchParams,
|
||||
params: params,
|
||||
};
|
||||
}
|
||||
function sanitizeReactiveUrl(value) {
|
||||
var raw = String(value == null ? "" : value);
|
||||
var compact = raw.trim().replace(/[\u0000-\u0020]+/g, "").toLowerCase();
|
||||
@@ -755,6 +775,20 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
var anyStateListeners = new Set();
|
||||
var cleanupCallbacks = [];
|
||||
var disposed = false;
|
||||
var routeValue = signal(routeSnapshot());
|
||||
|
||||
function updateRouteValue() {
|
||||
routeValue.set(routeSnapshot());
|
||||
}
|
||||
|
||||
window.addEventListener("wrnexus:navigated", updateRouteValue);
|
||||
window.addEventListener("popstate", updateRouteValue);
|
||||
window.addEventListener("hashchange", updateRouteValue);
|
||||
cleanupCallbacks.push(function () {
|
||||
window.removeEventListener("wrnexus:navigated", updateRouteValue);
|
||||
window.removeEventListener("popstate", updateRouteValue);
|
||||
window.removeEventListener("hashchange", updateRouteValue);
|
||||
});
|
||||
|
||||
function readGlobal(name) {
|
||||
if (declaredEvents.has(name)) {
|
||||
@@ -785,6 +819,10 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
if (name === "location") return window.location;
|
||||
if (name === "history") return window.history;
|
||||
if (name === "navigator") return window.navigator;
|
||||
if (name === "$route" || name === "route") {
|
||||
if (currentRenderer) routeValue.subscribe(currentRenderer);
|
||||
return routeValue.get();
|
||||
}
|
||||
if (name === "setTimeout") return window.setTimeout.bind(window);
|
||||
if (name === "clearTimeout") return window.clearTimeout.bind(window);
|
||||
if (name === "setInterval") return window.setInterval.bind(window);
|
||||
@@ -965,6 +1003,12 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
|
||||
function installBehaviorFunctions(source) {
|
||||
extractBehaviorFunctions(source || "").forEach(function (definition) {
|
||||
// A generated browser module contains the real JavaScript implementation
|
||||
// of client/shared functions. Keep that implementation when it has already
|
||||
// been installed instead of replacing it with the small CSP-safe fallback
|
||||
// interpreter. The fallback intentionally supports only a bounded subset of
|
||||
// JavaScript and must never shadow the compiled function.
|
||||
if (typeof behaviorFunctions[definition.name] === "function") return;
|
||||
behaviorFunctions[
|
||||
definition.name
|
||||
] = function () {
|
||||
@@ -3665,15 +3709,8 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
return special.value;
|
||||
}
|
||||
|
||||
// Template literals are evaluated without eval so they remain compatible
|
||||
// with a strict CSP. Each interpolation uses the same bounded expression
|
||||
// evaluator as every other reactive binding.
|
||||
if (
|
||||
expr.length >= 2 &&
|
||||
expr[0] === "\`" &&
|
||||
expr[expr.length - 1] === "\`"
|
||||
) {
|
||||
var template = expr.slice(1, -1);
|
||||
function evaluateTemplateLiteral(source) {
|
||||
var template = source.slice(1, -1);
|
||||
var rendered = "";
|
||||
var cursor = 0;
|
||||
|
||||
@@ -3703,7 +3740,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
else if (character === "}" && --depth === 0) break;
|
||||
}
|
||||
|
||||
if (depth !== 0) throw new Error("Unclosed template interpolation in '" + expr + "'");
|
||||
if (depth !== 0) throw new Error("Unclosed template interpolation in '" + source + "'");
|
||||
rendered += String(evaluateExpression(template.slice(expressionStart, expressionEnd), read));
|
||||
cursor = expressionEnd + 1;
|
||||
continue;
|
||||
@@ -3715,6 +3752,18 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
return rendered;
|
||||
}
|
||||
|
||||
// Template literals are evaluated without eval so they remain compatible
|
||||
// with a strict CSP. Each interpolation uses the same bounded expression
|
||||
// evaluator as every other reactive binding. They are accepted both as a
|
||||
// complete expression and as a primary inside a larger expression.
|
||||
if (
|
||||
expr.length >= 2 &&
|
||||
expr[0] === "\`" &&
|
||||
expr[expr.length - 1] === "\`"
|
||||
) {
|
||||
return evaluateTemplateLiteral(expr);
|
||||
}
|
||||
|
||||
var tokens =
|
||||
tokenizeExpression(expr);
|
||||
|
||||
@@ -3731,6 +3780,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
var t = next();
|
||||
if (!t) throw new Error("Unexpected end of expression in '" + expr + "'");
|
||||
if (t.type === "number" || t.type === "string") return { value: t.value };
|
||||
if (t.type === "template") return { value: evaluateTemplateLiteral(t.value) };
|
||||
if (t.type === "ident") {
|
||||
if (t.value === "true") return { value: true };
|
||||
if (t.value === "false") return { value: false };
|
||||
@@ -3874,6 +3924,37 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
tokens.push({ type: "string", value: value });
|
||||
continue;
|
||||
}
|
||||
if (ch === "\`") {
|
||||
var templateStart = i++;
|
||||
var templateDepth = 0;
|
||||
var templateQuote = "";
|
||||
while (i < input.length) {
|
||||
ch = input[i++];
|
||||
if (templateQuote) {
|
||||
if (ch === "\\") i++;
|
||||
else if (ch === templateQuote) templateQuote = "";
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\`" && templateDepth === 0) break;
|
||||
if (ch === "$" && input[i] === "{") {
|
||||
templateDepth++;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (templateDepth > 0) {
|
||||
if (ch === '"' || ch === "'") templateQuote = ch;
|
||||
else if (ch === "{") templateDepth++;
|
||||
else if (ch === "}") templateDepth--;
|
||||
}
|
||||
}
|
||||
if (input[i - 1] !== "\`") throw new Error("Unclosed template literal");
|
||||
tokens.push({ type: "template", value: input.slice(templateStart, i) });
|
||||
continue;
|
||||
}
|
||||
if (/[A-Za-z_$]/.test(ch)) {
|
||||
var s = i++;
|
||||
while (i < input.length && /[A-Za-z0-9_$]/.test(input[i])) i++;
|
||||
@@ -4047,6 +4128,23 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
Array.from(node.childNodes || []).forEach(visit);
|
||||
}
|
||||
visit(root);
|
||||
root.querySelectorAll("*").forEach(function (node) {
|
||||
Array.from(node.attributes || []).forEach(function (attribute) {
|
||||
if (attribute.name.indexOf("data-wrn-async-") === 0) return;
|
||||
if (attribute.value.indexOf("{") === -1) return;
|
||||
var nextValue = String(attribute.value).replace(/\{\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\}/g, function (whole, path) {
|
||||
var parts = path.split(".");
|
||||
if (parts[0] === prefix) parts.shift();
|
||||
if (prefix === "error" && parts[0] !== "message") return whole;
|
||||
var value = asyncValueAt(model, parts.join("."));
|
||||
return typeof value === "object" ? JSON.stringify(value) : String(value);
|
||||
});
|
||||
if (/^(?:href|src|action|formaction)$/i.test(attribute.name)) {
|
||||
nextValue = sanitizeReactiveUrl(nextValue);
|
||||
}
|
||||
node.setAttribute(attribute.name, nextValue);
|
||||
});
|
||||
});
|
||||
root.querySelectorAll("[data-wrn-async-value]").forEach(function (node) {
|
||||
var path = node.getAttribute("data-wrn-async-value") || "";
|
||||
var value = asyncValueAt(model, path);
|
||||
@@ -4058,7 +4156,9 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
var content = boundary.querySelector("[data-wrn-async-content]");
|
||||
if (!template || !content) return;
|
||||
var fragment = template.content.cloneNode(true);
|
||||
interpolateAsync(fragment, model, state === "error" ? "error" : boundary.getAttribute("data-wrn-async"));
|
||||
var alias = template.getAttribute("data-wrn-async-alias") ||
|
||||
(state === "error" ? "error" : boundary.getAttribute("data-wrn-async"));
|
||||
interpolateAsync(fragment, model, alias);
|
||||
content.replaceChildren(fragment);
|
||||
boundary.setAttribute("aria-busy", state === "loading" ? "true" : "false");
|
||||
boundary.setAttribute("data-wrn-async-state", state);
|
||||
@@ -4073,7 +4173,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
}
|
||||
var name = boundary.getAttribute("data-wrn-async") || "";
|
||||
if (!/^[A-Za-z_$][\w$]{0,63}$/.test(name)) return showAsync(boundary, "error", { message: "Invalid data source" });
|
||||
var url = "/__wrnexus/client-load?route=" + encodeURIComponent(location.pathname) + "&name=" + encodeURIComponent(name);
|
||||
var url = "/__wrnexus/client-load?route=" + encodeURIComponent(location.pathname) + "&search=" + encodeURIComponent(location.search) + "&name=" + encodeURIComponent(name);
|
||||
var attempts = Math.min(5, Math.max(0, Number(boundary.getAttribute("data-wrn-async-retries")) || 0));
|
||||
var controller = new AbortController();
|
||||
boundary.__wrnexusAsyncAbort = controller;
|
||||
@@ -4102,6 +4202,44 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
(root || document).querySelectorAll("[data-wrn-async]").forEach(setupAsyncBoundary);
|
||||
}
|
||||
|
||||
var invalidationRefreshPending = false;
|
||||
function asyncBoundaryTags(boundary) {
|
||||
var raw = boundary.getAttribute("data-wrn-async-tags") || boundary.getAttribute("data-wrn-async") || "";
|
||||
return raw.split(",").map(function (tag) { return tag.trim(); }).filter(Boolean);
|
||||
}
|
||||
function refreshAsyncBoundary(boundary) {
|
||||
if (boundary.__wrnexusAsyncAbort) {
|
||||
try { boundary.__wrnexusAsyncAbort.abort(); } catch (_) {}
|
||||
}
|
||||
boundary.__wrnexusAsync = false;
|
||||
boundary.removeAttribute("data-wrn-async-resolved");
|
||||
setupAsyncBoundary(boundary);
|
||||
}
|
||||
function handleCacheInvalidation(event) {
|
||||
var detail = event && event.detail && typeof event.detail === "object" ? event.detail : {};
|
||||
var tags = Array.isArray(detail.tags) ? detail.tags.map(String) : [];
|
||||
if (!tags.length) return;
|
||||
var matchedServerBoundary = false;
|
||||
document.querySelectorAll("[data-wrn-async]").forEach(function (boundary) {
|
||||
var matches = asyncBoundaryTags(boundary).some(function (tag) { return tags.indexOf(tag) !== -1; });
|
||||
if (!matches) return;
|
||||
if (boundary.getAttribute("data-wrn-async-resolved") === "true") {
|
||||
matchedServerBoundary = true;
|
||||
return;
|
||||
}
|
||||
refreshAsyncBoundary(boundary);
|
||||
});
|
||||
if (matchedServerBoundary && !invalidationRefreshPending) {
|
||||
invalidationRefreshPending = true;
|
||||
Promise.resolve().then(function () {
|
||||
invalidationRefreshPending = false;
|
||||
if (typeof window.__wrnexusRefresh === "function") window.__wrnexusRefresh();
|
||||
else window.location.reload();
|
||||
});
|
||||
}
|
||||
}
|
||||
window.addEventListener("wrnexus:cache:invalidate", handleCacheInvalidation);
|
||||
|
||||
function mountClientRoots(root) {
|
||||
(root || document).querySelectorAll("template[data-wrn-client-template]").forEach(function (template) {
|
||||
var id = template.getAttribute("data-wrn-client-template");
|
||||
|
||||
@@ -23,10 +23,29 @@ export const REALTIME_RUNTIME = String.raw`
|
||||
if (!("WebSocket" in window)) return;
|
||||
var wire = (window.wire = window.wire || {});
|
||||
if (wire.room) return; // already installed
|
||||
var open = {}; // name -> room connection
|
||||
var open = {}; // normalized room+query key -> room connection
|
||||
|
||||
function normalizedQuery(query) {
|
||||
var params = new URLSearchParams(String(query || "").replace(/^\?/, ""));
|
||||
var pairs = [];
|
||||
params.forEach(function (value, name) { pairs.push([name, value]); });
|
||||
pairs.sort(function (a, b) {
|
||||
return a[0] === b[0] ? String(a[1]).localeCompare(String(b[1])) : String(a[0]).localeCompare(String(b[0]));
|
||||
});
|
||||
var normalized = new URLSearchParams();
|
||||
pairs.forEach(function (pair) { normalized.append(pair[0], pair[1]); });
|
||||
return normalized.toString();
|
||||
}
|
||||
|
||||
function roomKey(name, query) {
|
||||
var normalized = normalizedQuery(query);
|
||||
return String(name || "") + (normalized ? "?" + normalized : "");
|
||||
}
|
||||
|
||||
function openRoom(name, query) {
|
||||
if (open[name]) return open[name];
|
||||
query = normalizedQuery(query);
|
||||
var key = roomKey(name, query);
|
||||
if (open[key]) return open[key];
|
||||
var ws = null, queue = [], listeners = [], attempts = 0, timer = null, closed = false;
|
||||
|
||||
function url() {
|
||||
@@ -69,6 +88,8 @@ export const REALTIME_RUNTIME = String.raw`
|
||||
|
||||
var api = {
|
||||
name: name,
|
||||
key: key,
|
||||
query: query,
|
||||
send: function (obj) {
|
||||
var payload = typeof obj === "string" ? obj : JSON.stringify(obj);
|
||||
if (ws && ws.readyState === 1) ws.send(payload);
|
||||
@@ -80,9 +101,9 @@ export const REALTIME_RUNTIME = String.raw`
|
||||
listeners.push({ type: type, cb: cb });
|
||||
return api;
|
||||
},
|
||||
close: function () { closed = true; clearTimeout(timer); if (ws) try { ws.close(); } catch (_) {} ws = null; delete open[name]; },
|
||||
close: function () { closed = true; clearTimeout(timer); if (ws) try { ws.close(); } catch (_) {} ws = null; delete open[key]; },
|
||||
};
|
||||
open[name] = api;
|
||||
open[key] = api;
|
||||
connect();
|
||||
return api;
|
||||
}
|
||||
@@ -114,11 +135,14 @@ export const REALTIME_RUNTIME = String.raw`
|
||||
}
|
||||
|
||||
function bindContainer(el) {
|
||||
if (el.__wireRoomBound) return;
|
||||
el.__wireRoomBound = true;
|
||||
var name = el.getAttribute("data-room");
|
||||
var user = el.getAttribute("data-room-user");
|
||||
var room = openRoom(name, user ? "user=" + encodeURIComponent(user) : "");
|
||||
var query = el.getAttribute("data-room-query") || (user ? "user=" + encodeURIComponent(user) : "");
|
||||
var key = roomKey(name, query);
|
||||
if (el.__wireRoomBound && el.__wireRoomKey === key) return;
|
||||
el.__wireRoomBound = true;
|
||||
el.__wireRoomKey = key;
|
||||
var room = openRoom(name, query);
|
||||
el.__wireRoom = room;
|
||||
|
||||
var log = el.querySelector("[data-room-log]");
|
||||
@@ -161,7 +185,7 @@ export const REALTIME_RUNTIME = String.raw`
|
||||
var input = form.elements[i];
|
||||
if (input.name) data[input.name] = input.value;
|
||||
}
|
||||
room.send(data);
|
||||
if (el.__wireRoom) el.__wireRoom.send(data);
|
||||
for (var j = 0; j < form.elements.length; j++) {
|
||||
if (form.elements[j].hasAttribute("data-room-reset")) form.elements[j].value = "";
|
||||
}
|
||||
@@ -173,8 +197,8 @@ export const REALTIME_RUNTIME = String.raw`
|
||||
var containers = (root || document).querySelectorAll("[data-room]");
|
||||
var present = {};
|
||||
for (var i = 0; i < containers.length; i++) {
|
||||
present[containers[i].getAttribute("data-room")] = true;
|
||||
bindContainer(containers[i]);
|
||||
if (containers[i].__wireRoomKey) present[containers[i].__wireRoomKey] = true;
|
||||
}
|
||||
// Close rooms whose container has left the page (client-side navigation).
|
||||
for (var nm in open) if (!present[nm]) open[nm].close();
|
||||
|
||||
@@ -452,3 +452,124 @@ test("$emit inside component functions does not depend on a global browser event
|
||||
expect(win.document.querySelector("span")!.textContent).toBe("true");
|
||||
expect(detail).toEqual({ source: "click" });
|
||||
});
|
||||
|
||||
test("template literals work inside browser API call arguments", () => {
|
||||
const behavior = Buffer.from(
|
||||
JSON.stringify({
|
||||
functions: `function save() {
|
||||
window.localStorage.setItem(\`prefs:\${count}\`, JSON.stringify(count))
|
||||
message = \`Saved \${count}\`
|
||||
}`,
|
||||
watches: [],
|
||||
lifecycle: {},
|
||||
}),
|
||||
).toString("base64");
|
||||
const win = mount(
|
||||
`<div data-scope="count: 3, message: 'ready'" data-wrn-behavior="${behavior}">` +
|
||||
`<button data-on-click="save()">{message}</button></div>`,
|
||||
);
|
||||
win.document.querySelector("button")!.click();
|
||||
expect(win.localStorage.getItem("prefs:3")).toBe("3");
|
||||
expect(win.document.querySelector("button")!.textContent).toBe("Saved 3");
|
||||
});
|
||||
|
||||
test("compiled client functions are not overwritten by fallback behavior parsing", () => {
|
||||
const behavior = Buffer.from(
|
||||
JSON.stringify({
|
||||
functions: `function save() { message = \`fallback\` }`,
|
||||
watches: [],
|
||||
lifecycle: {},
|
||||
}),
|
||||
).toString("base64");
|
||||
const win = new Window() as unknown as Window & Record<string, unknown>;
|
||||
win.document.body.innerHTML =
|
||||
`<div id="scope" data-scope="message: 'ready'" data-wrn-behavior="${behavior}">` +
|
||||
`<button data-on-click="save()">{message}</button></div>`;
|
||||
const scope = win.document.getElementById("scope") as unknown as HTMLElement & {
|
||||
__wrnexusClientModule?: unknown;
|
||||
};
|
||||
scope.__wrnexusClientModule = {
|
||||
bindClientScope(context: { state: Record<string, unknown> }) {
|
||||
return {
|
||||
save() {
|
||||
context.state.message = "native";
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
(globalThis as Record<string, unknown>).window = win;
|
||||
(globalThis as Record<string, unknown>).document = win.document;
|
||||
(globalThis as Record<string, unknown>).location = win.location;
|
||||
(globalThis as Record<string, unknown>).NodeFilter = (
|
||||
win as unknown as { NodeFilter: unknown }
|
||||
).NodeFilter;
|
||||
(globalThis as Record<string, unknown>).MutationObserver = (
|
||||
win as unknown as { MutationObserver: unknown }
|
||||
).MutationObserver;
|
||||
(0, eval)(REACTIVE_RUNTIME);
|
||||
(win as unknown as { __wrnexusHydrateScopes?: (root: unknown) => void }).__wrnexusHydrateScopes?.(
|
||||
win.document,
|
||||
);
|
||||
win.document.querySelector("button")!.click();
|
||||
expect(win.document.querySelector("button")!.textContent).toBe("native");
|
||||
});
|
||||
|
||||
test("$route updates after client navigation", () => {
|
||||
const win = new Window({ url: "http://localhost/" }) as unknown as Window &
|
||||
Record<string, unknown>;
|
||||
win.document.body.innerHTML =
|
||||
`<span hidden data-wrn-route-params='{"id":"42"}'></span>` +
|
||||
`<div data-scope=""><strong>{$route.pathname}</strong><em>{$route.params.id}</em></div>`;
|
||||
(globalThis as Record<string, unknown>).window = win;
|
||||
(globalThis as Record<string, unknown>).document = win.document;
|
||||
(globalThis as Record<string, unknown>).location = win.location;
|
||||
(globalThis as Record<string, unknown>).NodeFilter = (
|
||||
win as unknown as { NodeFilter: unknown }
|
||||
).NodeFilter;
|
||||
(globalThis as Record<string, unknown>).MutationObserver = (
|
||||
win as unknown as { MutationObserver: unknown }
|
||||
).MutationObserver;
|
||||
(0, eval)(REACTIVE_RUNTIME);
|
||||
(win as unknown as { __wrnexusHydrateScopes?: (root: unknown) => void }).__wrnexusHydrateScopes?.(
|
||||
win.document,
|
||||
);
|
||||
expect(win.document.querySelector("strong")!.textContent).toBe("/");
|
||||
expect(win.document.querySelector("em")!.textContent).toBe("42");
|
||||
expect(win.document.querySelector("[data-wrn-route-params]")).toBeNull();
|
||||
win.history.pushState({}, "", "/settings?tab=chat");
|
||||
win.dispatchEvent(
|
||||
new (win as unknown as { CustomEvent: typeof CustomEvent }).CustomEvent(
|
||||
"wrnexus:navigated",
|
||||
) as unknown as Parameters<Window["dispatchEvent"]>[0],
|
||||
);
|
||||
expect(win.document.querySelector("strong")!.textContent).toBe("/settings");
|
||||
});
|
||||
|
||||
test("cache invalidation refetches matching client Async boundaries", async () => {
|
||||
let requests = 0;
|
||||
(globalThis as Record<string, unknown>).fetch = () => {
|
||||
requests++;
|
||||
return Promise.resolve(Response.json({ data: { value: requests } }));
|
||||
};
|
||||
const win = mount(
|
||||
`<section data-wrn-async="uploads" data-wrn-async-tags="uploads" data-wrn-async-retries="0">` +
|
||||
`<div data-wrn-async-content></div>` +
|
||||
`<template data-wrn-async-loading>Loading</template>` +
|
||||
`<template data-wrn-async-success data-wrn-async-alias="uploads"><b>{uploads.value}</b></template>` +
|
||||
`<template data-wrn-async-error data-wrn-async-alias="error">{error.message}</template>` +
|
||||
`</section>`,
|
||||
);
|
||||
const runtime = win as unknown as { __wrnexusHydrateAsyncBoundaries?: (root: unknown) => void };
|
||||
runtime.__wrnexusHydrateAsyncBoundaries?.(win.document);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(win.document.querySelector("b")?.textContent).toBe("1");
|
||||
win.dispatchEvent(
|
||||
new (win as unknown as { CustomEvent: typeof CustomEvent }).CustomEvent(
|
||||
"wrnexus:cache:invalidate",
|
||||
{ detail: { tags: ["uploads"] } },
|
||||
) as unknown as Parameters<Window["dispatchEvent"]>[0],
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(requests).toBe(2);
|
||||
expect(win.document.querySelector("b")?.textContent).toBe("2");
|
||||
});
|
||||
|
||||
@@ -121,3 +121,27 @@ test("programmatic wire.room() sends and receives", () => {
|
||||
sockets[0]!.fireMessage({ type: "ping", n: 1 });
|
||||
expect(got).toEqual([{ type: "ping", n: 1 }]);
|
||||
});
|
||||
|
||||
test("same room with different users opens identity-specific sockets", () => {
|
||||
boot(`
|
||||
<div data-room="teamspace" data-room-user="asha"></div>
|
||||
<div data-room="teamspace" data-room-user="rohan"></div>
|
||||
`);
|
||||
expect(sockets.map((socket) => socket.url).sort()).toEqual([
|
||||
"ws://localhost/realtime/teamspace?user=asha",
|
||||
"ws://localhost/realtime/teamspace?user=rohan",
|
||||
]);
|
||||
});
|
||||
|
||||
test("declarative room reconnects when its user identity changes", () => {
|
||||
const win = boot(
|
||||
`<div id="room" data-room="teamspace" data-room-user="asha"></div>`,
|
||||
) as unknown as Window & {
|
||||
wire: { bindRooms: (root?: unknown) => void };
|
||||
};
|
||||
expect(sockets[0]?.url).toBe("ws://localhost/realtime/teamspace?user=asha");
|
||||
win.document.getElementById("room")!.setAttribute("data-room-user", "neha");
|
||||
win.wire.bindRooms(win.document);
|
||||
expect(sockets[0]?.readyState).toBe(3);
|
||||
expect(sockets[1]?.url).toBe("ws://localhost/realtime/teamspace?user=neha");
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/db",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
setCompileCacheDir,
|
||||
setCompileImportOptions,
|
||||
setDevCompilerPipeline,
|
||||
wrnBrowserArtifactUrl,
|
||||
wrnBrowserArtifactUrlAsync,
|
||||
} from "./pipeline.ts";
|
||||
import { createRpcHandler } from "@wrnexus/ssr/rpc";
|
||||
import { createHandlers, type WsData } from "./runtime.ts";
|
||||
@@ -632,7 +632,7 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
storeUpdates.push({
|
||||
name: declaration[2]!,
|
||||
kind: declaration[1]!,
|
||||
url: wrnBrowserArtifactUrl(absolute),
|
||||
url: await wrnBrowserArtifactUrlAsync(absolute),
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`[wrnexus] failed to prepare store HMR for ${absolute}`, error);
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
unlinkSync,
|
||||
existsSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join, basename, extname, resolve } from "node:path";
|
||||
import { dirname, join, basename, extname, relative, resolve } from "node:path";
|
||||
import {
|
||||
compile,
|
||||
generate,
|
||||
@@ -91,6 +91,16 @@ export function setCompileImportOptions(
|
||||
|
||||
const compileInProgress = new Map<string, WrnCompileArtifacts>();
|
||||
|
||||
function isApplicationImportSource(
|
||||
source: string,
|
||||
aliases: Record<string, string> | undefined,
|
||||
): boolean {
|
||||
if (source.startsWith(".")) return true;
|
||||
return Object.keys(aliases ?? {}).some(
|
||||
(alias) => source === alias || source.startsWith(`${alias}/`),
|
||||
);
|
||||
}
|
||||
|
||||
function projectRootForFile(file: string): string {
|
||||
let current = dirname(resolve(file));
|
||||
while (true) {
|
||||
@@ -101,6 +111,19 @@ function projectRootForFile(file: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function importOptionsHash(file: string): string {
|
||||
const root = resolve(projectRootForFile(file));
|
||||
const options = compileImportOptions.get(root) ?? {
|
||||
mode: "compatible" as const,
|
||||
aliases: { "@": "./app" },
|
||||
autoImport: true,
|
||||
};
|
||||
const aliases = Object.fromEntries(
|
||||
Object.entries(options.aliases).sort(([left], [right]) => left.localeCompare(right)),
|
||||
);
|
||||
return hashPath(JSON.stringify({ ...options, aliases }));
|
||||
}
|
||||
|
||||
function rewriteArtifactImports(
|
||||
code: string,
|
||||
ast: PageAst,
|
||||
@@ -132,8 +155,7 @@ function rewriteArtifactImports(
|
||||
}
|
||||
}
|
||||
if (!entry.resolved || !entry.declaration.source) continue;
|
||||
if (!entry.declaration.source.startsWith(".") && !entry.declaration.source.startsWith("@/"))
|
||||
continue;
|
||||
if (!isApplicationImportSource(entry.declaration.source, importOptions.aliases)) continue;
|
||||
let replacement = entry.resolved;
|
||||
if (entry.resolved.endsWith(".wrn")) {
|
||||
const dependencySource = readFileSync(entry.resolved, "utf8");
|
||||
@@ -148,13 +170,22 @@ function rewriteArtifactImports(
|
||||
output = output.replace(entry.declaration.raw, "");
|
||||
continue;
|
||||
}
|
||||
replacement = `/__wrnexus/client/${basename(dependency.browser).replace(/\.client\.mjs$/, ".mjs")}`;
|
||||
replacement = dependency.browser;
|
||||
} else {
|
||||
replacement = target === "server" ? dependency.server : dependency.main;
|
||||
}
|
||||
}
|
||||
const escaped = entry.declaration.source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const specifier = replacement.startsWith("/") ? replacement : pathToFileURL(replacement).href;
|
||||
const specifier =
|
||||
target === "browser"
|
||||
? (() => {
|
||||
const cacheDir = compileCacheDir ?? join(dirname(importer), ".wrnexus");
|
||||
const relativeTarget = relative(cacheDir, replacement).replace(/\\/g, "/");
|
||||
return relativeTarget.startsWith(".") ? relativeTarget : `./${relativeTarget}`;
|
||||
})()
|
||||
: replacement.startsWith("/")
|
||||
? replacement
|
||||
: pathToFileURL(replacement).href;
|
||||
output = output.replace(new RegExp(`(["'])${escaped}\\1`, "g"), JSON.stringify(specifier));
|
||||
}
|
||||
return output;
|
||||
@@ -169,10 +200,15 @@ async function rewriteArtifactImportsAsync(
|
||||
let output = code;
|
||||
for (const [id, replacement] of devCompilerPipeline?.virtualModules ?? []) {
|
||||
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
output = output.replace(
|
||||
new RegExp(`(["'])${escaped}\\1`, "g"),
|
||||
JSON.stringify(pathToFileURL(replacement).href),
|
||||
);
|
||||
const specifier =
|
||||
target === "browser"
|
||||
? (() => {
|
||||
const cacheDir = compileCacheDir ?? join(dirname(importer), ".wrnexus");
|
||||
const relativeTarget = relative(cacheDir, replacement).replace(/\\/g, "/");
|
||||
return relativeTarget.startsWith(".") ? relativeTarget : `./${relativeTarget}`;
|
||||
})()
|
||||
: pathToFileURL(replacement).href;
|
||||
output = output.replace(new RegExp(`(["'])${escaped}\\1`, "g"), JSON.stringify(specifier));
|
||||
}
|
||||
if (!ast.structuredImports.length) return output;
|
||||
const root = projectRootForFile(importer);
|
||||
@@ -189,8 +225,7 @@ async function rewriteArtifactImportsAsync(
|
||||
for (const entry of resolved) {
|
||||
if (entry.diagnostic?.severity === "error") throw new Error(entry.diagnostic.message);
|
||||
if (!entry.resolved || !entry.declaration.source) continue;
|
||||
if (!entry.declaration.source.startsWith(".") && !entry.declaration.source.startsWith("@/"))
|
||||
continue;
|
||||
if (!isApplicationImportSource(entry.declaration.source, importOptions.aliases)) continue;
|
||||
let replacement = entry.resolved;
|
||||
if (replacement.endsWith(".wrn")) {
|
||||
const dependencySource = readFileSync(replacement, "utf8");
|
||||
@@ -204,11 +239,20 @@ async function rewriteArtifactImportsAsync(
|
||||
output = output.replace(entry.declaration.raw, "");
|
||||
continue;
|
||||
}
|
||||
replacement = `/__wrnexus/client/${basename(dependency.browser).replace(/\.client\.mjs$/, ".mjs")}`;
|
||||
replacement = dependency.browser;
|
||||
} else replacement = target === "server" ? dependency.server : dependency.main;
|
||||
}
|
||||
const escaped = entry.declaration.source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const specifier = replacement.startsWith("/") ? replacement : pathToFileURL(replacement).href;
|
||||
const specifier =
|
||||
target === "browser"
|
||||
? (() => {
|
||||
const cacheDir = compileCacheDir ?? join(dirname(importer), ".wrnexus");
|
||||
const relativeTarget = relative(cacheDir, replacement).replace(/\\/g, "/");
|
||||
return relativeTarget.startsWith(".") ? relativeTarget : `./${relativeTarget}`;
|
||||
})()
|
||||
: replacement.startsWith("/")
|
||||
? replacement
|
||||
: pathToFileURL(replacement).href;
|
||||
output = output.replace(new RegExp(`(["'])${escaped}\\1`, "g"), JSON.stringify(specifier));
|
||||
}
|
||||
return output;
|
||||
@@ -257,14 +301,14 @@ export async function loadModule(file: string): Promise<Record<string, unknown>>
|
||||
* When unset, compilation falls back to a sibling `.wrnexus/` next to each file.
|
||||
*/
|
||||
let compileCacheDir: string | null = null;
|
||||
const WRN_COMPILE_CACHE_VERSION = "v2";
|
||||
const WRN_COMPILE_CACHE_VERSION = "v3";
|
||||
|
||||
/**
|
||||
* Point all `.wrn` compilation at ONE cache dir (typically `<appRoot>/.wrnexus`)
|
||||
* instead of scattering a `.wrnexus/` folder next to every `.wrn` source. Called
|
||||
* once by the dev server at startup.
|
||||
*/
|
||||
export function setCompileCacheDir(dir: string): void {
|
||||
export function setCompileCacheDir(dir: string | null): void {
|
||||
compileCacheDir = dir;
|
||||
}
|
||||
|
||||
@@ -357,18 +401,68 @@ const compileMetrics: WrnCompileMetrics = {
|
||||
};
|
||||
const asyncCompileInProgress = new Map<string, Promise<WrnCompileArtifacts>>();
|
||||
|
||||
async function bundleBrowserArtifact(
|
||||
code: string,
|
||||
file: string,
|
||||
cacheDir: string,
|
||||
stem: string,
|
||||
): Promise<string> {
|
||||
const hasModuleImport = /(?:^|\n)\s*import(?:\s|["'])|\bimport\s*\(/m.test(code);
|
||||
if (!hasModuleImport || code.includes("wrnexus-client-bundled")) return code;
|
||||
const bun = (globalThis as any).Bun;
|
||||
if (!bun?.build) {
|
||||
throw new Error(
|
||||
`WRN-CLIENT-BUNDLE: ${file} has browser imports, but the Bun bundler is unavailable.`,
|
||||
);
|
||||
}
|
||||
const entry = join(cacheDir, `${stem}.browser-entry.mjs`);
|
||||
writeFileSync(entry, code, "utf8");
|
||||
try {
|
||||
const result = await bun.build({
|
||||
entrypoints: [entry],
|
||||
target: "browser",
|
||||
format: "esm",
|
||||
splitting: false,
|
||||
minify: false,
|
||||
sourcemap: "inline",
|
||||
});
|
||||
if (!result.success || !result.outputs?.length) {
|
||||
const detail = (result.logs ?? []).map(String).join("\n");
|
||||
throw new Error(`WRN-CLIENT-BUNDLE: failed to bundle ${file}${detail ? `\n${detail}` : ""}`);
|
||||
}
|
||||
return `// wrnexus-client-bundled\n${await result.outputs[0].text()}`;
|
||||
} finally {
|
||||
try {
|
||||
unlinkSync(entry);
|
||||
} catch {
|
||||
// Best-effort cleanup; cache pruning removes stale temporary entries.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function compileWireArtifactsAsync(file: string, version = 0): Promise<WrnCompileArtifacts> {
|
||||
if (!devCompilerPipeline) return Promise.resolve(compileWireArtifacts(file, version));
|
||||
const key = `${file}:${version}`;
|
||||
const active = asyncCompileInProgress.get(key);
|
||||
if (active) return active;
|
||||
const task = (async () => {
|
||||
if (!devCompilerPipeline) {
|
||||
const artifacts = compileWireArtifacts(file, version);
|
||||
const code = readFileSync(artifacts.browser, "utf8");
|
||||
const bundled = await bundleBrowserArtifact(
|
||||
code,
|
||||
file,
|
||||
dirname(artifacts.browser),
|
||||
basename(artifacts.browser, ".client.mjs"),
|
||||
);
|
||||
if (bundled !== code) writeFileSync(artifacts.browser, bundled, "utf8");
|
||||
return artifacts;
|
||||
}
|
||||
const cacheDir = compileCacheDir ?? join(dirname(file), ".wrnexus");
|
||||
const name = basename(file).replace(/\.wrn$/, "");
|
||||
const suffix = version ? `-hmr-${version}` : "";
|
||||
const source = readFileSync(file, "utf8");
|
||||
// Plugin output affects the artifact, so use a separate cache generation.
|
||||
const stem = `${name}-${WRN_COMPILE_CACHE_VERSION}-plugin-${hashPath(file)}-${hashPath(source)}${suffix}`;
|
||||
const stem = `${name}-${WRN_COMPILE_CACHE_VERSION}-plugin-${hashPath(file)}-${hashPath(source)}-${importOptionsHash(file)}${suffix}`;
|
||||
const artifacts: WrnCompileArtifacts = {
|
||||
main: join(cacheDir, `${stem}.wrn.ts`),
|
||||
browser: join(cacheDir, `${stem}.client.mjs`),
|
||||
@@ -394,11 +488,11 @@ export function compileWireArtifactsAsync(file: string, version = 0): Promise<Wr
|
||||
};
|
||||
for (const target of ["main", "browser", "server"] as const) {
|
||||
const rewritten = await rewriteArtifactImportsAsync(outputs[target], ast, file, target);
|
||||
writeFileSync(
|
||||
artifacts[target],
|
||||
await devCompilerPipeline!.transformCode(rewritten, file),
|
||||
"utf8",
|
||||
);
|
||||
let transformed = await devCompilerPipeline!.transformCode(rewritten, file);
|
||||
if (target === "browser") {
|
||||
transformed = await bundleBrowserArtifact(transformed, file, cacheDir, stem);
|
||||
}
|
||||
writeFileSync(artifacts[target], transformed, "utf8");
|
||||
}
|
||||
writeFileSync(
|
||||
artifacts.declarations,
|
||||
@@ -437,7 +531,7 @@ export function compileWireArtifacts(file: string, version = 0): WrnCompileArtif
|
||||
const name = basename(file).replace(/\.wrn$/, "");
|
||||
const suffix = version ? `-hmr-${version}` : "";
|
||||
const source = readFileSync(file, "utf8");
|
||||
const stem = `${name}-${WRN_COMPILE_CACHE_VERSION}-${hashPath(file)}-${hashPath(source)}${suffix}`;
|
||||
const stem = `${name}-${WRN_COMPILE_CACHE_VERSION}-${hashPath(file)}-${hashPath(source)}-${importOptionsHash(file)}${suffix}`;
|
||||
const artifacts: WrnCompileArtifacts = {
|
||||
main: join(cacheDir, `${stem}.wrn.ts`),
|
||||
browser: join(cacheDir, `${stem}.client.mjs`),
|
||||
@@ -516,6 +610,12 @@ export function wrnBrowserArtifactUrl(file: string): string {
|
||||
return `/__wrnexus/client/${basename(artifact).replace(/\.client\.mjs$/, ".mjs")}`;
|
||||
}
|
||||
|
||||
/** Async browser artifact URL used by HMR so imported client modules are bundled before delivery. */
|
||||
export async function wrnBrowserArtifactUrlAsync(file: string): Promise<string> {
|
||||
const artifact = (await compileWireArtifactsAsync(file, moduleVersions.get(file) ?? 0)).browser;
|
||||
return `/__wrnexus/client/${basename(artifact).replace(/\.client\.mjs$/, ".mjs")}`;
|
||||
}
|
||||
|
||||
export function serveWrnBrowserArtifact(pathname: string): Response | null {
|
||||
const artifact = browserArtifactPaths.get(pathname);
|
||||
if (!artifact || !existsSync(artifact)) return null;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* supervised `dev --production-runtime` mode can explicitly enable it.
|
||||
*/
|
||||
|
||||
import { join } from "node:path";
|
||||
import type { Middleware, Mode, SecurityConfig, SeoConfig } from "@wrnexus/core";
|
||||
import {
|
||||
compileRoutePattern,
|
||||
@@ -88,6 +89,8 @@ export interface ProdOptions {
|
||||
stylesIncludeFramework?: boolean;
|
||||
/** Absolute path to the pre-built reactive runtime. */
|
||||
reactivePath?: string;
|
||||
/** Absolute directory containing bundled per-WRN browser modules. */
|
||||
clientModulesDir?: string;
|
||||
/** Absolute path to the pre-built theme stylesheet (`theme.css`). */
|
||||
themePath?: string;
|
||||
/** Absolute path to the pre-built theme runtime (`theme.js`). */
|
||||
@@ -267,6 +270,13 @@ async function serveFile(path: string | undefined, headers: Record<string, strin
|
||||
function createProdAssetServer(opts: ProdOptions): AssetServer {
|
||||
return {
|
||||
async serve(pathname: string): Promise<Response | null> {
|
||||
if (pathname.startsWith("/__wrnexus/client/")) {
|
||||
const name = pathname.slice("/__wrnexus/client/".length);
|
||||
if (!opts.clientModulesDir || !/^[A-Za-z0-9._-]+\.mjs$/.test(name)) {
|
||||
return new Response("Not Found", { status: 404 });
|
||||
}
|
||||
return serveFile(join(opts.clientModulesDir, name), JS_HEADERS);
|
||||
}
|
||||
if (pathname === "/__wrnexus/reactive.js") {
|
||||
if (opts.reactivePath) {
|
||||
const file = Bun.file(opts.reactivePath);
|
||||
|
||||
@@ -1279,11 +1279,14 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
|
||||
async function handleClientLoad(ctx: Context): Promise<Response> {
|
||||
const routePath = ctx.url.searchParams.get("route") ?? "";
|
||||
const routeSearch = ctx.url.searchParams.get("search") ?? "";
|
||||
const name = ctx.url.searchParams.get("name") ?? "";
|
||||
if (
|
||||
!routePath.startsWith("/") ||
|
||||
routePath.startsWith("/__wrnexus/") ||
|
||||
!isSafeRequestPath(routePath) ||
|
||||
(routeSearch !== "" &&
|
||||
(!routeSearch.startsWith("?") || routeSearch.includes("#") || routeSearch.length > 4096)) ||
|
||||
!/^[A-Za-z_$][\w$]{0,63}$/.test(name)
|
||||
) {
|
||||
return new Response("Not Found", { status: 404 });
|
||||
@@ -1294,8 +1297,11 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
const load = pageModule.__wrnexusClientLoad;
|
||||
if (typeof load !== "function") return new Response("Not Found", { status: 404 });
|
||||
try {
|
||||
ctx.params = page.params;
|
||||
const values = await load(ctx);
|
||||
const routeUrl = new URL(routePath + routeSearch, ctx.url.origin);
|
||||
if (routeUrl.pathname !== routePath) return new Response("Not Found", { status: 404 });
|
||||
const routeRequest = new Request(routeUrl, { method: "GET", headers: ctx.req.headers });
|
||||
const loadCtx: Context = { ...ctx, req: routeRequest, url: routeUrl, params: page.params };
|
||||
const values = await load(loadCtx);
|
||||
if (!values || typeof values !== "object" || !(name in values)) {
|
||||
return new Response("Not Found", { status: 404 });
|
||||
}
|
||||
@@ -1627,6 +1633,10 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
if (partial) {
|
||||
body = typeof precomputedShell === "string" ? precomputedShell : (pagePartial?.shell ?? body);
|
||||
}
|
||||
const routeParamsMarker = `<span hidden data-wrn-route-params="${escapeHtml(
|
||||
JSON.stringify(ctx.params ?? {}),
|
||||
)}"></span>`;
|
||||
body = `${routeParamsMarker}${body}`;
|
||||
if (body.includes("data-wrn-action=")) {
|
||||
body = body.replace(
|
||||
/(<form\b[^>]*\bdata-wrn-action=(?:"[^"]+"|'[^']+')[^>]*>)/gi,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
compileWireArtifactsAsync,
|
||||
setCompileCacheDir,
|
||||
setCompileImportOptions,
|
||||
} from "../src/pipeline.ts";
|
||||
|
||||
test("development browser artifacts bundle local client imports", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-client-bundle-"));
|
||||
const app = join(root, "app");
|
||||
const cache = join(root, ".wrnexus");
|
||||
mkdirSync(app, { recursive: true });
|
||||
writeFileSync(join(app, "helper.ts"), "export function save(value: unknown) { return value; }\n");
|
||||
const page = join(app, "settings.wrn");
|
||||
writeFileSync(
|
||||
page,
|
||||
`import { save } from "./helper.ts"
|
||||
page Settings {
|
||||
state { value = 1 }
|
||||
functions { client function persist() { return save(value) } }
|
||||
view { <button @click='persist()'>Save</button> }
|
||||
}`,
|
||||
);
|
||||
|
||||
try {
|
||||
setCompileCacheDir(cache);
|
||||
const artifacts = await compileWireArtifactsAsync(page);
|
||||
const browser = readFileSync(artifacts.browser, "utf8");
|
||||
expect(browser).toContain("wrnexus-client-bundled");
|
||||
expect(browser).not.toContain("file://");
|
||||
expect(browser).not.toContain('from "./helper.ts"');
|
||||
} finally {
|
||||
setCompileCacheDir(null);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("development browser artifacts bundle configured alias imports", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-client-alias-bundle-"));
|
||||
const app = join(root, "app");
|
||||
const cache = join(root, ".wrnexus");
|
||||
mkdirSync(join(app, "client"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(app, "client", "storage.ts"),
|
||||
"export function persist(value: unknown) { return value; }\n",
|
||||
);
|
||||
const page = join(app, "settings.wrn");
|
||||
writeFileSync(
|
||||
page,
|
||||
`import { persist } from "~/client/storage.ts"
|
||||
page Settings {
|
||||
state { value = 1 }
|
||||
functions { client function save() { return persist(value) } }
|
||||
view { <button @click='save()'>Save</button> }
|
||||
}`,
|
||||
);
|
||||
|
||||
try {
|
||||
setCompileCacheDir(cache);
|
||||
setCompileImportOptions(root, {
|
||||
mode: "explicit",
|
||||
aliases: { "~": "./app" },
|
||||
autoImport: false,
|
||||
});
|
||||
const artifacts = await compileWireArtifactsAsync(page);
|
||||
const browser = readFileSync(artifacts.browser, "utf8");
|
||||
expect(browser).toContain("wrnexus-client-bundled");
|
||||
expect(browser).not.toContain("~/client/storage.ts");
|
||||
expect(browser).not.toContain("file://");
|
||||
|
||||
mkdirSync(join(root, "alternate", "client"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "alternate", "client", "storage.ts"),
|
||||
"export function persist(value: unknown) { return value; }\n",
|
||||
);
|
||||
setCompileImportOptions(root, {
|
||||
mode: "explicit",
|
||||
aliases: { "~": "./alternate" },
|
||||
autoImport: false,
|
||||
});
|
||||
const changed = await compileWireArtifactsAsync(page);
|
||||
expect(changed.browser).not.toBe(artifacts.browser);
|
||||
} finally {
|
||||
setCompileCacheDir(null);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -62,7 +62,9 @@ test("production streams request regions into the build-time static shell", asyn
|
||||
test("normal production output remains free of development HMR", async () => {
|
||||
const handlers = createProductionHandlers(manifest, {});
|
||||
const response = await handlers.fetch(new Request("http://localhost/"), server);
|
||||
expect(await (response as Response).text()).not.toContain("/__wrnexus/hmr");
|
||||
const html = await (response as Response).text();
|
||||
expect(html).not.toContain("/__wrnexus/hmr");
|
||||
expect(html).toContain('data-wrn-route-params="{}"');
|
||||
});
|
||||
|
||||
test("production client-load endpoint returns only the requested named result", async () => {
|
||||
@@ -121,3 +123,43 @@ test("production prefers fast gzip for dynamic HTML and cached Brotli for immuta
|
||||
)) as Response;
|
||||
expect(head.headers.get("content-encoding")).toBeNull();
|
||||
});
|
||||
|
||||
test("client-load preserves the active route query string", async () => {
|
||||
const queryManifest: ProdManifest = {
|
||||
...manifest,
|
||||
pages: [
|
||||
...manifest.pages,
|
||||
{
|
||||
raw: "/query",
|
||||
mod: {
|
||||
default: () => "<main>Query page</main>",
|
||||
__wrnexusClientLoad: async (ctx: { url: URL }) => ({
|
||||
identity: ctx.url.searchParams.get("as"),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const handlers = createProductionHandlers(queryManifest, {});
|
||||
const response = (await handlers.fetch(
|
||||
new Request(
|
||||
"http://localhost/__wrnexus/client-load?route=%2Fquery&search=%3Fas%3Drohan&name=identity",
|
||||
),
|
||||
server,
|
||||
)) as Response;
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ data: "rohan" });
|
||||
});
|
||||
|
||||
test("production serves generated WRN browser modules from the client directory", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "wrnexus-client-modules-"));
|
||||
writeFileSync(join(directory, "settings-abc123.mjs"), "export const ready = true;\n");
|
||||
const handlers = createProductionHandlers(manifest, { clientModulesDir: directory });
|
||||
const response = (await handlers.fetch(
|
||||
new Request("http://localhost/__wrnexus/client/settings-abc123.mjs"),
|
||||
server,
|
||||
)) as Response;
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toContain("text/javascript");
|
||||
expect(await response.text()).toContain("ready = true");
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-toolbar",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/encryption",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/graphql",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/helpers",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Badge, Card } from "@wrnexus/ui"
|
||||
|
||||
component LocaleStatus {
|
||||
props {
|
||||
locale: string = "en"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/i18n",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/identity",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"type": "module",
|
||||
"description": "Enterprise federation, provisioning, machine identity, and privacy governance for WRNexusJS.",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import OptimizedImage from "./OptimizedImage.wrn"
|
||||
import { Card } from "@wrnexus/ui"
|
||||
|
||||
component ImageCard {
|
||||
props {
|
||||
title: string = ""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/image",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"type": "module",
|
||||
"description": "Responsive image planning, secure remote image policies, and performance auditing for WRNexusJS.",
|
||||
"main": "./src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/jwt",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/language-server",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"type": "module",
|
||||
"description": "Editor-neutral Language Server Protocol implementation for WRNexus .wrn files.",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/mcp",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"type": "module",
|
||||
"description": "Model Context Protocol server exposing WRNexus application and framework context.",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/mobile",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/native",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/oauth",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/observability",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"type": "module",
|
||||
"description": "Metrics, Web Vitals collection, request instrumentation, and exporter adapters for WRNexusJS.",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/playground",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"type": "module",
|
||||
"description": "Secure, shareable WRNexus compiler and UI playground.",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/plugin",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/pubsub",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/pwa",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"type": "module",
|
||||
"description": "Service workers, manifests, offline queues, background sync, push, and conflict resolution for WRNexusJS.",
|
||||
"main": "./src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/queue",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/reactive",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ChatBubble } from "@wrnexus/ui"
|
||||
|
||||
component RealtimeMessageBubble {
|
||||
props {
|
||||
id: string = ""
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Button, Textarea } from "@wrnexus/ui"
|
||||
|
||||
component MessageComposer {
|
||||
props {
|
||||
name: string = "text"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Avatar, Badge, Card } from "@wrnexus/ui"
|
||||
|
||||
component PresenceList {
|
||||
props {
|
||||
members: unknown[] = []
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import RoomStatus from "./RoomStatus.wrn"
|
||||
import { Card } from "@wrnexus/ui"
|
||||
|
||||
component RealtimeRoom {
|
||||
props {
|
||||
room: string = ""
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Badge, Card } from "@wrnexus/ui"
|
||||
|
||||
component RoomMeta {
|
||||
props {
|
||||
name: string = "Room"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Badge } from "@wrnexus/ui"
|
||||
|
||||
component RoomStatus {
|
||||
props {
|
||||
label: string = "Connecting…"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Alert } from "@wrnexus/ui"
|
||||
|
||||
component TypingIndicator {
|
||||
props {
|
||||
names: unknown[] = []
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/realtime",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"description": "Typed realtime rooms, message helpers, presence utilities, package UI blocks, and WRNexusJS integration.",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/router",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/security",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"type": "module",
|
||||
"description": "Security policies, safe serialization, SSRF protection, request limits, and secure cookie helpers for WRNexusJS.",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ssr",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/store",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/styles",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/syntax",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/test",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/tracking",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/typecheck",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import Button from "./button.wrn"
|
||||
import Checkbox from "./Checkbox.wrn"
|
||||
import Input from "./Input.wrn"
|
||||
import PinInput from "./PinInput.wrn"
|
||||
|
||||
component AuthForm {
|
||||
outputs {
|
||||
submit(payload: { event: Event; mode: string; action: string })
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import FeatureCard from "./FeatureCard.wrn"
|
||||
|
||||
component FeatureGrid {
|
||||
props {
|
||||
size: string = "default"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import TextLink from "./TextLink.wrn"
|
||||
|
||||
component FeatureIconCard {
|
||||
props {
|
||||
icon: string = "icon-[lucide--sparkles]"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import Badge from "./Badge.wrn"
|
||||
|
||||
component Hero {
|
||||
props {
|
||||
eyebrow: string = ""
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import Button from "./button.wrn"
|
||||
|
||||
component HeroActions {
|
||||
props {
|
||||
actions: unknown[] = []
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import SectionHeader from "./SectionHeader.wrn"
|
||||
import TextLink from "./TextLink.wrn"
|
||||
|
||||
component MarketingSectionHeader {
|
||||
props {
|
||||
id: string = ""
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user