release: WRNexusJS 0.6.0
This commit is contained in:
@@ -12,9 +12,10 @@ import {
|
||||
mkdirSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
existsSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join, basename, extname } from "node:path";
|
||||
import { compileWireFile } from "@wrnexus/compiler";
|
||||
import { dirname, join, basename, extname, resolve } from "node:path";
|
||||
import { compile, generateTargets, resolveWrnImports, type PageAst } from "@wrnexus/compiler";
|
||||
import type { Context, Middleware } from "@wrnexus/core";
|
||||
|
||||
/**
|
||||
@@ -48,13 +49,105 @@ export function runMiddleware(
|
||||
*/
|
||||
const moduleCache = new Map<string, Promise<Record<string, unknown>>>();
|
||||
const moduleVersions = new Map<string, number>();
|
||||
const browserArtifactPaths = new Map<string, string>();
|
||||
|
||||
type ImportMode = "legacy" | "compatible" | "explicit";
|
||||
interface CompileImportOptions {
|
||||
mode: ImportMode;
|
||||
aliases: Record<string, string>;
|
||||
autoImport: boolean;
|
||||
}
|
||||
const compileImportOptions = new Map<string, CompileImportOptions>();
|
||||
const warnedImportDiagnostics = new Set<string>();
|
||||
|
||||
export function setCompileImportOptions(
|
||||
appRoot: string,
|
||||
options: { mode?: ImportMode; aliases?: Record<string, string>; autoImport?: boolean } = {},
|
||||
): void {
|
||||
compileImportOptions.set(resolve(appRoot), {
|
||||
mode: options.mode ?? "compatible",
|
||||
aliases: { "@": "./app", ...(options.aliases ?? {}) },
|
||||
autoImport: options.autoImport ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
const compileInProgress = new Map<string, WrnCompileArtifacts>();
|
||||
|
||||
function projectRootForFile(file: string): string {
|
||||
let current = dirname(resolve(file));
|
||||
while (true) {
|
||||
if (existsSync(join(current, "app"))) return current;
|
||||
const parent = dirname(current);
|
||||
if (parent === current) return dirname(resolve(file));
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function rewriteArtifactImports(
|
||||
code: string,
|
||||
ast: PageAst,
|
||||
importer: string,
|
||||
target: "main" | "server" | "browser",
|
||||
): string {
|
||||
if (!ast.structuredImports.length) return code;
|
||||
const root = projectRootForFile(importer);
|
||||
const importOptions = compileImportOptions.get(resolve(root)) ?? {
|
||||
mode: "compatible" as const,
|
||||
aliases: { "@": "./app" },
|
||||
autoImport: true,
|
||||
};
|
||||
const resolved = resolveWrnImports(ast.structuredImports, importer, {
|
||||
appRoot: root,
|
||||
mode: importOptions.mode,
|
||||
aliases: importOptions.aliases,
|
||||
});
|
||||
let output = code;
|
||||
for (const entry of resolved) {
|
||||
if (entry.diagnostic) {
|
||||
const key = `${importer}:${entry.diagnostic.code}:${entry.declaration.source}`;
|
||||
if (entry.diagnostic.severity === "error") {
|
||||
throw new Error(`${entry.diagnostic.code}: ${entry.diagnostic.message}`);
|
||||
}
|
||||
if (!warnedImportDiagnostics.has(key)) {
|
||||
warnedImportDiagnostics.add(key);
|
||||
console.warn(`[wrnexus] ${entry.diagnostic.code}: ${entry.diagnostic.message}`);
|
||||
}
|
||||
}
|
||||
if (!entry.resolved || !entry.declaration.source) continue;
|
||||
if (!entry.declaration.source.startsWith(".") && !entry.declaration.source.startsWith("@/"))
|
||||
continue;
|
||||
let replacement = entry.resolved;
|
||||
if (entry.resolved.endsWith(".wrn")) {
|
||||
const dependencySource = readFileSync(entry.resolved, "utf8");
|
||||
const isStore = /\b(?:global|page)\s+store\s+[A-Za-z_$][\w$]*\s*\{/.test(dependencySource);
|
||||
const dependency = compileWireArtifacts(
|
||||
entry.resolved,
|
||||
moduleVersions.get(entry.resolved) ?? 0,
|
||||
);
|
||||
if (target === "browser") {
|
||||
if (!isStore) {
|
||||
// Components and layouts are compile-time dependencies in browser modules.
|
||||
output = output.replace(entry.declaration.raw, "");
|
||||
continue;
|
||||
}
|
||||
replacement = `/__wrnexus/client/${basename(dependency.browser).replace(/\.client\.mjs$/, ".mjs")}`;
|
||||
} else {
|
||||
replacement = target === "server" ? dependency.server : dependency.main;
|
||||
}
|
||||
}
|
||||
const escaped = entry.declaration.source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const specifier = replacement.startsWith("/") ? replacement : pathToFileURL(replacement).href;
|
||||
output = output.replace(new RegExp(`(["'])${escaped}\\1`, "g"), JSON.stringify(specifier));
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function loadModule(file: string): Promise<Record<string, unknown>> {
|
||||
let mod = moduleCache.get(file);
|
||||
if (!mod) {
|
||||
const version = moduleVersions.get(file) ?? 0;
|
||||
// `.wrn` files are compiled to TypeScript first, then imported.
|
||||
let target = file.endsWith(".wrn") ? compileWireToTs(file, version) : file;
|
||||
let target = file.endsWith(".wrn") ? compileWireArtifacts(file, version).main : file;
|
||||
let temporary = false;
|
||||
// Bun intentionally caches local TS/JS modules by filesystem path and ignores
|
||||
// URL query strings. A short-lived versioned sibling keeps relative imports
|
||||
@@ -116,31 +209,131 @@ function hashPath(s: string): string {
|
||||
* components) share one cache dir without colliding. Generated modules are
|
||||
* self-contained (no relative imports), so the cache location doesn't affect them.
|
||||
*/
|
||||
function compileWireToTs(file: string, version = 0): string {
|
||||
function importedValueBindings(ast: PageAst): Set<string> {
|
||||
const names = new Set<string>();
|
||||
for (const entry of ast.structuredImports) {
|
||||
if (entry.typeOnly) continue;
|
||||
if (entry.defaultImport) names.add(entry.defaultImport);
|
||||
if (entry.namespaceImport) names.add(entry.namespaceImport);
|
||||
for (const item of entry.namedImports) if (!item.typeOnly) names.add(item.local);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function validateConfiguredImports(source: string, ast: PageAst, file: string): void {
|
||||
const root = projectRootForFile(file);
|
||||
const options = compileImportOptions.get(resolve(root));
|
||||
if (!options || options.mode === "legacy") return;
|
||||
const imported = importedValueBindings(ast);
|
||||
const usedComponents = new Set(
|
||||
Array.from(source.matchAll(/<([A-Z][A-Za-z0-9_$]*)\b/g), (match) => match[1]!),
|
||||
);
|
||||
const missing = [...usedComponents].filter((name) => !imported.has(name));
|
||||
if (ast.layoutIsSymbol && ast.layout && !imported.has(ast.layout)) missing.push(ast.layout);
|
||||
if (!missing.length) return;
|
||||
const unique = [...new Set(missing)];
|
||||
const message = `WRN-IMPORT-IMPLICIT: ${file} uses ${unique.join(", ")} without explicit imports.`;
|
||||
if (options.mode === "explicit") throw new Error(message);
|
||||
const key = `${file}:WRN-IMPORT-IMPLICIT:${unique.join(",")}`;
|
||||
if (!warnedImportDiagnostics.has(key)) {
|
||||
warnedImportDiagnostics.add(key);
|
||||
console.warn(`[wrnexus] ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export interface WrnCompileArtifacts {
|
||||
main: string;
|
||||
browser: string;
|
||||
server: string;
|
||||
declarations: string;
|
||||
contract: string;
|
||||
rpc: string;
|
||||
}
|
||||
|
||||
export function compileWireArtifacts(file: string, version = 0): WrnCompileArtifacts {
|
||||
const active = compileInProgress.get(file);
|
||||
if (active) return active;
|
||||
const cacheDir = compileCacheDir ?? join(dirname(file), ".wrnexus");
|
||||
const name = basename(file).replace(/\.wrn$/, "");
|
||||
const suffix = version ? `-hmr-${version}` : "";
|
||||
const source = readFileSync(file, "utf8");
|
||||
// Include the source contents in the cache identity. Package managers, git
|
||||
// checkouts, archive extraction, and linked dependencies can all replace a
|
||||
// file while preserving (or moving backwards) its mtime. An mtime-only cache
|
||||
// then serves an older compiled component even across a clean build.
|
||||
const out = join(
|
||||
cacheDir,
|
||||
`${name}-${WRN_COMPILE_CACHE_VERSION}-${hashPath(file)}-${hashPath(source)}${suffix}.wrn.ts`,
|
||||
);
|
||||
|
||||
// The content hash makes this safe even when source timestamps are preserved.
|
||||
const stem = `${name}-${WRN_COMPILE_CACHE_VERSION}-${hashPath(file)}-${hashPath(source)}${suffix}`;
|
||||
const artifacts: WrnCompileArtifacts = {
|
||||
main: join(cacheDir, `${stem}.wrn.ts`),
|
||||
browser: join(cacheDir, `${stem}.client.mjs`),
|
||||
server: join(cacheDir, `${stem}.server.ts`),
|
||||
declarations: join(cacheDir, `${stem}.d.ts`),
|
||||
contract: join(cacheDir, `${stem}.contract.json`),
|
||||
rpc: join(cacheDir, `${stem}.rpc.json`),
|
||||
};
|
||||
compileInProgress.set(file, artifacts);
|
||||
try {
|
||||
if (statSync(out).isFile()) return out;
|
||||
} catch {
|
||||
/* cache missing → compile below */
|
||||
try {
|
||||
if (Object.values(artifacts).every((path) => statSync(path).isFile())) {
|
||||
browserArtifactPaths.set(`/__wrnexus/client/${stem}.mjs`, artifacts.browser);
|
||||
return artifacts;
|
||||
}
|
||||
} catch {
|
||||
// Compile missing artifact set below.
|
||||
}
|
||||
const result = compile(source, file);
|
||||
validateConfiguredImports(source, result.ast, file);
|
||||
const targets = generateTargets(result.ast);
|
||||
mkdirSync(cacheDir, { recursive: true });
|
||||
const browserPath = `/__wrnexus/client/${stem}.mjs`;
|
||||
const mainCode = rewriteArtifactImports(
|
||||
result.code.replaceAll("__WRNEXUS_CLIENT_MODULE__", browserPath),
|
||||
result.ast,
|
||||
file,
|
||||
"main",
|
||||
);
|
||||
writeFileSync(artifacts.main, mainCode, "utf8");
|
||||
writeFileSync(
|
||||
artifacts.browser,
|
||||
rewriteArtifactImports(targets.browser, result.ast, file, "browser"),
|
||||
"utf8",
|
||||
);
|
||||
browserArtifactPaths.set(browserPath, artifacts.browser);
|
||||
writeFileSync(
|
||||
artifacts.server,
|
||||
rewriteArtifactImports(targets.server, result.ast, file, "server"),
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(artifacts.declarations, targets.declarations, "utf8");
|
||||
writeFileSync(artifacts.contract, JSON.stringify(targets.contract, null, 2) + "\n", "utf8");
|
||||
writeFileSync(artifacts.rpc, JSON.stringify(targets.rpc, null, 2) + "\n", "utf8");
|
||||
return artifacts;
|
||||
} finally {
|
||||
compileInProgress.delete(file);
|
||||
}
|
||||
}
|
||||
|
||||
const code = compileWireFile(source, file);
|
||||
mkdirSync(cacheDir, { recursive: true });
|
||||
writeFileSync(out, code, "utf8");
|
||||
return out;
|
||||
export async function loadWrnServerModule(file: string): Promise<Record<string, unknown>> {
|
||||
const version = moduleVersions.get(file) ?? 0;
|
||||
const artifact = compileWireArtifacts(file, version).server;
|
||||
return import(pathToFileURL(artifact).href) as Promise<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export function wrnBrowserArtifact(file: string): string {
|
||||
return compileWireArtifacts(file, moduleVersions.get(file) ?? 0).browser;
|
||||
}
|
||||
|
||||
export function wrnBrowserArtifactUrl(file: string): string {
|
||||
const artifact = compileWireArtifacts(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;
|
||||
return new Response(readFileSync(artifact, "utf8"), {
|
||||
headers: {
|
||||
"content-type": "text/javascript; charset=utf-8",
|
||||
"cache-control": "no-store, max-age=0",
|
||||
pragma: "no-cache",
|
||||
expires: "0",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Forget one module and force its next dynamic import to bypass Bun's import cache. */
|
||||
|
||||
Reference in New Issue
Block a user