release: WRNexusJS 0.8.0
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
# @wrnexus/ai
|
||||
|
||||
Provider-neutral AI orchestration for OpenAI, Anthropic, Google and local OpenAI-compatible models,
|
||||
with streaming, structured output, tools, embeddings, vector search/RAG, conversation persistence,
|
||||
templates, guardrails, usage events, fallback, rate limits and evaluation reports.
|
||||
|
||||
> A tiny, zero-dependency Claude (Anthropic) client for WrNexus apps — generate and stream text with Claude from any server-side code.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
@@ -120,6 +124,34 @@ try {
|
||||
}
|
||||
```
|
||||
|
||||
### Multi-provider client
|
||||
|
||||
`createAIClient` adds named-provider selection and fallback, capability discovery,
|
||||
validated JSON output, validated tool execution, abort-aware exponential retries,
|
||||
and per-provider circuit breakers. Attempt events intentionally contain metadata
|
||||
only: prompts, credentials, and raw model responses are never passed to telemetry.
|
||||
|
||||
```ts
|
||||
import { anthropicProvider, createAIClient } from "@wrnexus/ai";
|
||||
|
||||
const ai = createAIClient({
|
||||
providers: [anthropicProvider()],
|
||||
retry: { attempts: 3, baseDelayMs: 100, maxDelayMs: 2_000 },
|
||||
circuitBreaker: { failureThreshold: 5, resetAfterMs: 30_000 },
|
||||
});
|
||||
|
||||
const result = await ai.generateObject<{ title: string }>("Return a JSON title", {
|
||||
validate: (value): value is { title: string } =>
|
||||
typeof value === "object" && value !== null && "title" in value,
|
||||
});
|
||||
```
|
||||
|
||||
Providers can return normalized `usage` (`inputTokens`, `outputTokens`,
|
||||
`totalTokens`, and `costUsd`) and `toolCalls`. Use `executeTools` with a named,
|
||||
validated tool registry; unknown tools and invalid arguments are rejected before
|
||||
application code runs. `deterministicAIProvider` supplies ordered or computed
|
||||
offline responses for tests and examples without API keys or network calls.
|
||||
|
||||
## Usage
|
||||
|
||||
### Return generated JSON from an API route
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "@wrnexus/ai",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
".": "./src/index.ts",
|
||||
"./platform": "./src/platform.ts"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,5 +245,46 @@ export function createAI(config: AIConfig = {}): AI {
|
||||
|
||||
return { generate, stream, streamResponse };
|
||||
}
|
||||
export { anthropicProvider, aiProvider, createAIClient } from "./providers.ts";
|
||||
export type { AIUsage, AIResult, AIProvider, AIClient, AIClientOptions } from "./providers.ts";
|
||||
export {
|
||||
anthropicProvider,
|
||||
aiProvider,
|
||||
createAIClient,
|
||||
deterministicAIProvider,
|
||||
} from "./providers.ts";
|
||||
export type {
|
||||
AIAttemptEvent,
|
||||
AICircuitBreakerOptions,
|
||||
AIClient,
|
||||
AIClientOptions,
|
||||
AIProvider,
|
||||
AIProviderCapabilities,
|
||||
AIResult,
|
||||
AIRetryOptions,
|
||||
AITool,
|
||||
AIToolCall,
|
||||
AIUsage,
|
||||
DeterministicAIProviderOptions,
|
||||
} from "./providers.ts";
|
||||
export {
|
||||
aiRateLimiter,
|
||||
createRagPipeline,
|
||||
evaluateAI,
|
||||
googleAIProvider,
|
||||
guardedProvider,
|
||||
localAIProvider,
|
||||
maxPromptLength,
|
||||
memoryConversationStore,
|
||||
memoryVectorStore,
|
||||
openAIEmbeddings,
|
||||
openAIProvider,
|
||||
promptTemplate,
|
||||
} from "./platform.ts";
|
||||
export type {
|
||||
AIGuardrail,
|
||||
ConversationStore,
|
||||
EmbeddingProvider,
|
||||
HttpAIProviderOptions,
|
||||
VectorMatch,
|
||||
VectorRecord,
|
||||
VectorStore,
|
||||
} from "./platform.ts";
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
import { AIError, type GenerateOptions, type Message } from "./index.ts";
|
||||
import type { AIProvider, AIResult } from "./providers.ts";
|
||||
|
||||
export interface HttpAIProviderOptions {
|
||||
apiKey?: string;
|
||||
model: string;
|
||||
baseUrl?: string;
|
||||
fetch?: typeof fetch;
|
||||
headers?: HeadersInit;
|
||||
}
|
||||
function key(configured: string | undefined, name: string): string {
|
||||
const value = configured ?? process.env[name];
|
||||
if (!value) throw new AIError(`Missing ${name}.`, 0, "authentication_error");
|
||||
return value;
|
||||
}
|
||||
function promptMessages(prompt: string | Message[], options?: GenerateOptions) {
|
||||
const messages =
|
||||
options?.messages ??
|
||||
(typeof prompt === "string" ? [{ role: "user" as const, content: prompt }] : prompt);
|
||||
return options?.system ? [{ role: "system", content: options.system }, ...messages] : messages;
|
||||
}
|
||||
|
||||
export function openAIProvider(options: HttpAIProviderOptions): AIProvider {
|
||||
const send = options.fetch ?? fetch;
|
||||
const base = (options.baseUrl ?? "https://api.openai.com/v1").replace(/\/$/, "");
|
||||
return {
|
||||
name: "openai",
|
||||
capabilities: { streaming: false, structuredOutput: true, tools: true, usage: true },
|
||||
async generate(prompt, call = {}) {
|
||||
const response = await send(`${base}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${key(options.apiKey, "OPENAI_API_KEY")}`,
|
||||
"content-type": "application/json",
|
||||
...Object.fromEntries(new Headers(options.headers)),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: call.model ?? options.model,
|
||||
messages: promptMessages(prompt, call),
|
||||
...(call.maxTokens ? { max_completion_tokens: call.maxTokens } : {}),
|
||||
}),
|
||||
signal: call.signal,
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new AIError(`OpenAI returned ${response.status}.`, response.status, "provider_error");
|
||||
const body = (await response.json()) as any;
|
||||
return {
|
||||
value: String(body.choices?.[0]?.message?.content ?? ""),
|
||||
provider: "openai",
|
||||
model: body.model,
|
||||
finishReason: body.choices?.[0]?.finish_reason,
|
||||
usage: {
|
||||
inputTokens: body.usage?.prompt_tokens,
|
||||
outputTokens: body.usage?.completion_tokens,
|
||||
totalTokens: body.usage?.total_tokens,
|
||||
},
|
||||
toolCalls: body.choices?.[0]?.message?.tool_calls?.map((tool: any) => ({
|
||||
id: String(tool.id),
|
||||
name: String(tool.function?.name),
|
||||
arguments: JSON.parse(tool.function?.arguments ?? "{}"),
|
||||
})),
|
||||
raw: body,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function googleAIProvider(options: HttpAIProviderOptions): AIProvider {
|
||||
const send = options.fetch ?? fetch;
|
||||
const base = (options.baseUrl ?? "https://generativelanguage.googleapis.com/v1beta").replace(
|
||||
/\/$/,
|
||||
"",
|
||||
);
|
||||
return {
|
||||
name: "google",
|
||||
capabilities: { streaming: false, structuredOutput: true, tools: true, usage: true },
|
||||
async generate(prompt, call = {}) {
|
||||
const response = await send(
|
||||
`${base}/models/${encodeURIComponent(call.model ?? options.model)}:generateContent?key=${encodeURIComponent(key(options.apiKey, "GOOGLE_AI_API_KEY"))}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...Object.fromEntries(new Headers(options.headers)),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
contents: promptMessages(prompt, call)
|
||||
.filter((message) => message.role !== "system")
|
||||
.map((message) => ({
|
||||
role: message.role === "assistant" ? "model" : "user",
|
||||
parts: [{ text: message.content }],
|
||||
})),
|
||||
...(call.system ? { systemInstruction: { parts: [{ text: call.system }] } } : {}),
|
||||
}),
|
||||
signal: call.signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok)
|
||||
throw new AIError(
|
||||
`Google AI returned ${response.status}.`,
|
||||
response.status,
|
||||
"provider_error",
|
||||
);
|
||||
const body = (await response.json()) as any;
|
||||
return {
|
||||
value: String(
|
||||
body.candidates?.[0]?.content?.parts?.map((part: any) => part.text ?? "").join("") ?? "",
|
||||
),
|
||||
provider: "google",
|
||||
model: call.model ?? options.model,
|
||||
finishReason: body.candidates?.[0]?.finishReason,
|
||||
usage: {
|
||||
inputTokens: body.usageMetadata?.promptTokenCount,
|
||||
outputTokens: body.usageMetadata?.candidatesTokenCount,
|
||||
totalTokens: body.usageMetadata?.totalTokenCount,
|
||||
},
|
||||
raw: body,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** OpenAI-compatible local servers including Ollama, llama.cpp and vLLM. */
|
||||
export function localAIProvider(
|
||||
options: Omit<HttpAIProviderOptions, "apiKey"> & { apiKey?: string },
|
||||
): AIProvider {
|
||||
const provider = openAIProvider({
|
||||
...options,
|
||||
apiKey: options.apiKey ?? "local",
|
||||
baseUrl: options.baseUrl ?? "http://localhost:11434/v1",
|
||||
});
|
||||
return {
|
||||
...provider,
|
||||
name: "local",
|
||||
generate: async (prompt, call) => ({
|
||||
...(await provider.generate(prompt, call)),
|
||||
provider: "local",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export interface EmbeddingProvider {
|
||||
name: string;
|
||||
embed(
|
||||
values: string[],
|
||||
options?: { model?: string; signal?: AbortSignal },
|
||||
): Promise<{ vectors: number[][]; usage?: { tokens?: number } }>;
|
||||
}
|
||||
export function openAIEmbeddings(options: HttpAIProviderOptions): EmbeddingProvider {
|
||||
return {
|
||||
name: "openai",
|
||||
async embed(values, call = {}) {
|
||||
const response = await (options.fetch ?? fetch)(
|
||||
`${(options.baseUrl ?? "https://api.openai.com/v1").replace(/\/$/, "")}/embeddings`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${key(options.apiKey, "OPENAI_API_KEY")}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ model: call.model ?? options.model, input: values }),
|
||||
signal: call.signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok)
|
||||
throw new AIError(`Embedding provider returned ${response.status}.`, response.status);
|
||||
const body = (await response.json()) as any;
|
||||
return {
|
||||
vectors: body.data.map((item: any) => item.embedding as number[]),
|
||||
usage: { tokens: body.usage?.total_tokens },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface VectorRecord<T = Record<string, unknown>> {
|
||||
id: string;
|
||||
vector: number[];
|
||||
text: string;
|
||||
metadata: T;
|
||||
}
|
||||
export interface VectorMatch<T = Record<string, unknown>> extends VectorRecord<T> {
|
||||
score: number;
|
||||
}
|
||||
export interface VectorStore<T = Record<string, unknown>> {
|
||||
upsert(records: VectorRecord<T>[]): Promise<void>;
|
||||
query(
|
||||
vector: number[],
|
||||
limit?: number,
|
||||
filter?: (metadata: T) => boolean,
|
||||
): Promise<VectorMatch<T>[]>;
|
||||
delete(ids: string[]): Promise<void>;
|
||||
}
|
||||
export function memoryVectorStore<T = Record<string, unknown>>(): VectorStore<T> {
|
||||
const records = new Map<string, VectorRecord<T>>();
|
||||
const cosine = (a: number[], b: number[]) => {
|
||||
if (a.length !== b.length || !a.length) return 0;
|
||||
let dot = 0,
|
||||
aa = 0,
|
||||
bb = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
dot += a[i]! * b[i]!;
|
||||
aa += a[i]! ** 2;
|
||||
bb += b[i]! ** 2;
|
||||
}
|
||||
return aa && bb ? dot / Math.sqrt(aa * bb) : 0;
|
||||
};
|
||||
return {
|
||||
async upsert(values) {
|
||||
for (const value of values) records.set(value.id, { ...value, vector: [...value.vector] });
|
||||
},
|
||||
async query(vector, limit = 5, filter) {
|
||||
return [...records.values()]
|
||||
.filter((record) => !filter || filter(record.metadata))
|
||||
.map((record) => ({ ...record, score: cosine(vector, record.vector) }))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, Math.max(0, limit));
|
||||
},
|
||||
async delete(ids) {
|
||||
for (const id of ids) records.delete(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createRagPipeline<T>(options: {
|
||||
embeddings: EmbeddingProvider;
|
||||
store: VectorStore<T>;
|
||||
generate: (prompt: string, options?: GenerateOptions) => Promise<AIResult<string>>;
|
||||
maxContextCharacters?: number;
|
||||
}) {
|
||||
return {
|
||||
async index(documents: Array<{ id: string; text: string; metadata: T }>) {
|
||||
const embedded = await options.embeddings.embed(documents.map((document) => document.text));
|
||||
await options.store.upsert(
|
||||
documents.map((document, index) => ({ ...document, vector: embedded.vectors[index]! })),
|
||||
);
|
||||
return documents.length;
|
||||
},
|
||||
async ask(
|
||||
question: string,
|
||||
call: GenerateOptions & { limit?: number; filter?: (metadata: T) => boolean } = {},
|
||||
) {
|
||||
const embedded = await options.embeddings.embed([question], { signal: call.signal });
|
||||
const matches = await options.store.query(embedded.vectors[0]!, call.limit, call.filter);
|
||||
const context = matches
|
||||
.map((match, index) => `[${index + 1}] ${match.text}`)
|
||||
.join("\n\n")
|
||||
.slice(0, options.maxContextCharacters ?? 12_000);
|
||||
const result = await options.generate(
|
||||
`Answer using only the supplied context. Cite sources as [n].\n\nContext:\n${context}\n\nQuestion: ${question}`,
|
||||
call,
|
||||
);
|
||||
return { ...result, sources: matches };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConversationStore {
|
||||
load(id: string): Promise<Message[]>;
|
||||
append(id: string, messages: Message[]): Promise<void>;
|
||||
clear(id: string): Promise<void>;
|
||||
}
|
||||
export function memoryConversationStore(maxMessages = 100): ConversationStore {
|
||||
const conversations = new Map<string, Message[]>();
|
||||
return {
|
||||
async load(id) {
|
||||
return [...(conversations.get(id) ?? [])];
|
||||
},
|
||||
async append(id, messages) {
|
||||
conversations.set(id, [...(conversations.get(id) ?? []), ...messages].slice(-maxMessages));
|
||||
},
|
||||
async clear(id) {
|
||||
conversations.delete(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
export function promptTemplate(template: string) {
|
||||
return (variables: Record<string, string | number>) =>
|
||||
template.replace(/\{\{\s*([\w.-]+)\s*\}\}/g, (_match, name: string) => {
|
||||
if (!(name in variables)) throw new Error(`WRN-AI-PROMPT-VARIABLE:${name}`);
|
||||
return String(variables[name]);
|
||||
});
|
||||
}
|
||||
export type AIGuardrail = (input: {
|
||||
prompt: string | Message[];
|
||||
output?: string;
|
||||
}) => void | Promise<void>;
|
||||
export const maxPromptLength =
|
||||
(maximum: number): AIGuardrail =>
|
||||
({ prompt }) => {
|
||||
const length =
|
||||
typeof prompt === "string"
|
||||
? prompt.length
|
||||
: prompt.reduce((sum, message) => sum + message.content.length, 0);
|
||||
if (length > maximum)
|
||||
throw new AIError("Prompt exceeds configured guardrail.", 400, "guardrail");
|
||||
};
|
||||
export function guardedProvider(provider: AIProvider, guardrails: AIGuardrail[]): AIProvider {
|
||||
return {
|
||||
...provider,
|
||||
async generate(prompt, options) {
|
||||
for (const guardrail of guardrails) await guardrail({ prompt });
|
||||
const result = await provider.generate(prompt, options);
|
||||
for (const guardrail of guardrails) await guardrail({ prompt, output: result.value });
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
export function aiRateLimiter(options: { limit: number; windowMs: number; now?: () => number }) {
|
||||
const buckets = new Map<string, { count: number; reset: number }>();
|
||||
const now = options.now ?? Date.now;
|
||||
return (key: string) => {
|
||||
const time = now();
|
||||
const bucket = buckets.get(key);
|
||||
if (!bucket || bucket.reset <= time) {
|
||||
buckets.set(key, { count: 1, reset: time + options.windowMs });
|
||||
return { allowed: true, remaining: options.limit - 1 };
|
||||
}
|
||||
if (bucket.count >= options.limit)
|
||||
return { allowed: false, remaining: 0, retryAfterMs: bucket.reset - time };
|
||||
bucket.count++;
|
||||
return { allowed: true, remaining: options.limit - bucket.count };
|
||||
};
|
||||
}
|
||||
export async function evaluateAI(
|
||||
cases: Array<{
|
||||
name: string;
|
||||
prompt: string;
|
||||
expected?: string;
|
||||
score?: (output: string) => number | Promise<number>;
|
||||
}>,
|
||||
generate: (prompt: string) => Promise<string>,
|
||||
) {
|
||||
const results = [];
|
||||
for (const item of cases) {
|
||||
const started = performance.now();
|
||||
const output = await generate(item.prompt);
|
||||
const score = item.score
|
||||
? await item.score(output)
|
||||
: item.expected === undefined
|
||||
? 1
|
||||
: output.includes(item.expected)
|
||||
? 1
|
||||
: 0;
|
||||
results.push({ name: item.name, output, score, durationMs: performance.now() - started });
|
||||
}
|
||||
return {
|
||||
results,
|
||||
score: results.length
|
||||
? results.reduce((sum, result) => sum + result.score, 0) / results.length
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
+229
-16
@@ -11,28 +11,79 @@ export interface AIUsage {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
costUsd?: number;
|
||||
}
|
||||
|
||||
export interface AIResult<T = string> {
|
||||
value: T;
|
||||
provider: string;
|
||||
model?: string;
|
||||
usage?: AIUsage;
|
||||
finishReason?: string;
|
||||
toolCalls?: AIToolCall[];
|
||||
raw?: unknown;
|
||||
}
|
||||
|
||||
export interface AIProviderCapabilities {
|
||||
streaming?: boolean;
|
||||
structuredOutput?: boolean;
|
||||
tools?: boolean;
|
||||
usage?: boolean;
|
||||
}
|
||||
|
||||
export interface AIToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: unknown;
|
||||
}
|
||||
|
||||
export interface AITool<TInput = unknown, TOutput = unknown> {
|
||||
name: string;
|
||||
description?: string;
|
||||
validate?: (value: unknown) => value is TInput;
|
||||
execute(input: TInput, context: { signal?: AbortSignal }): TOutput | Promise<TOutput>;
|
||||
}
|
||||
|
||||
export interface AIProvider {
|
||||
name: string;
|
||||
capabilities?: AIProviderCapabilities;
|
||||
generate(prompt: string | Message[], options?: GenerateOptions): Promise<AIResult<string>>;
|
||||
stream?(
|
||||
prompt: string | Message[],
|
||||
options?: GenerateOptions,
|
||||
): AsyncGenerator<string, void, unknown>;
|
||||
}
|
||||
|
||||
export interface AIRetryOptions {
|
||||
attempts?: number;
|
||||
baseDelayMs?: number;
|
||||
maxDelayMs?: number;
|
||||
retry?: (error: unknown) => boolean;
|
||||
}
|
||||
|
||||
export interface AICircuitBreakerOptions {
|
||||
failureThreshold?: number;
|
||||
resetAfterMs?: number;
|
||||
}
|
||||
|
||||
export interface AIClientOptions {
|
||||
providers: AIProvider[];
|
||||
fallback?: boolean;
|
||||
onAttempt?: (provider: string, error?: unknown) => void | Promise<void>;
|
||||
retry?: AIRetryOptions;
|
||||
circuitBreaker?: AICircuitBreakerOptions;
|
||||
/** Receives metadata only; prompts, provider raw responses, and credentials are never emitted. */
|
||||
onAttempt?: (event: AIAttemptEvent) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface AIAttemptEvent {
|
||||
provider: string;
|
||||
attempt: number;
|
||||
outcome: "start" | "success" | "error" | "circuit-open";
|
||||
durationMs?: number;
|
||||
error?: { name: string; type?: string; status?: number; message: string };
|
||||
usage?: AIUsage;
|
||||
}
|
||||
|
||||
export interface AIClient {
|
||||
generate(
|
||||
prompt: string | Message[],
|
||||
@@ -42,40 +93,82 @@ export interface AIClient {
|
||||
prompt: string | Message[],
|
||||
options?: GenerateOptions & { provider?: string; validate?: (value: unknown) => value is T },
|
||||
): Promise<AIResult<T>>;
|
||||
executeTools(
|
||||
result: AIResult,
|
||||
tools: AITool[],
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<Array<{ call: AIToolCall; value: unknown }>>;
|
||||
stream(
|
||||
prompt: string | Message[],
|
||||
options?: GenerateOptions & { provider?: string },
|
||||
): AsyncGenerator<string, void, unknown>;
|
||||
capabilities(provider?: string): Record<string, AIProviderCapabilities>;
|
||||
}
|
||||
|
||||
export function anthropicProvider(config: AIConfig = {}): AIProvider {
|
||||
const client = createAI(config);
|
||||
return {
|
||||
name: "anthropic",
|
||||
async generate(prompt: string | Message[], options?: GenerateOptions) {
|
||||
capabilities: { streaming: true, structuredOutput: true },
|
||||
async generate(prompt, options) {
|
||||
return {
|
||||
value: await client.generate(prompt, options),
|
||||
provider: "anthropic",
|
||||
model: options?.model ?? config.model,
|
||||
};
|
||||
},
|
||||
stream: (prompt: string | Message[], options?: GenerateOptions) =>
|
||||
client.stream(prompt, options),
|
||||
stream: (prompt, options) => client.stream(prompt, options),
|
||||
};
|
||||
}
|
||||
|
||||
export function aiProvider(name: string, client: AI): AIProvider {
|
||||
export function aiProvider(
|
||||
name: string,
|
||||
client: AI,
|
||||
capabilities: AIProviderCapabilities = {},
|
||||
): AIProvider {
|
||||
return {
|
||||
name,
|
||||
async generate(prompt: string | Message[], options?: GenerateOptions) {
|
||||
capabilities: { streaming: true, ...capabilities },
|
||||
async generate(prompt, options) {
|
||||
return {
|
||||
value: await client.generate(prompt, options),
|
||||
provider: name,
|
||||
model: options?.model,
|
||||
};
|
||||
},
|
||||
stream: (prompt: string | Message[], options?: GenerateOptions) =>
|
||||
client.stream(prompt, options),
|
||||
stream: (prompt, options) => client.stream(prompt, options),
|
||||
};
|
||||
}
|
||||
|
||||
export interface DeterministicAIProviderOptions {
|
||||
name?: string;
|
||||
responses?: Array<string | AIResult<string>>;
|
||||
handler?: (
|
||||
prompt: string | Message[],
|
||||
options?: GenerateOptions,
|
||||
) => string | AIResult<string> | Promise<string | AIResult<string>>;
|
||||
}
|
||||
|
||||
/** Offline provider for examples and tests. Responses are consumed in order. */
|
||||
export function deterministicAIProvider(options: DeterministicAIProviderOptions = {}): AIProvider {
|
||||
let index = 0;
|
||||
const name = options.name ?? "deterministic";
|
||||
return {
|
||||
name,
|
||||
capabilities: { streaming: true, structuredOutput: true, tools: true, usage: true },
|
||||
async generate(prompt, callOptions) {
|
||||
const selected = options.handler
|
||||
? await options.handler(prompt, callOptions)
|
||||
: options.responses?.[index++];
|
||||
if (selected === undefined)
|
||||
throw new AIError("No deterministic response configured", 0, "provider_error");
|
||||
return typeof selected === "string"
|
||||
? { value: selected, provider: name }
|
||||
: { ...selected, provider: name };
|
||||
},
|
||||
async *stream(prompt, callOptions) {
|
||||
yield (await this.generate(prompt, callOptions)).value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,27 +177,116 @@ function jsonText(value: string): string {
|
||||
return (fenced?.[1] ?? value).trim();
|
||||
}
|
||||
|
||||
function safeError(error: unknown): AIAttemptEvent["error"] {
|
||||
if (error instanceof AIError)
|
||||
return {
|
||||
name: error.name,
|
||||
type: error.type,
|
||||
status: error.status,
|
||||
message: error.message.slice(0, 300),
|
||||
};
|
||||
if (error instanceof Error) return { name: error.name, message: error.message.slice(0, 300) };
|
||||
return { name: "Error", message: "Unknown provider error" };
|
||||
}
|
||||
|
||||
function defaultRetry(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof AIError &&
|
||||
(error.status === 408 || error.status === 429 || error.status >= 500)
|
||||
);
|
||||
}
|
||||
|
||||
function abortError(): Error {
|
||||
return new DOMException("The operation was aborted", "AbortError");
|
||||
}
|
||||
|
||||
async function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
if (signal?.aborted) throw signal.reason ?? abortError();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, ms);
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
reject(signal.reason ?? abortError());
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function createAIClient(options: AIClientOptions): AIClient {
|
||||
if (!options.providers.length) throw new Error("WRN-AI-NO-PROVIDERS");
|
||||
const duplicate = options.providers.find(
|
||||
(provider, index) =>
|
||||
options.providers.findIndex((other) => other.name === provider.name) !== index,
|
||||
);
|
||||
if (duplicate) throw new Error(`WRN-AI-DUPLICATE-PROVIDER:${duplicate.name}`);
|
||||
const circuits = new Map<string, { failures: number; openedAt?: number }>();
|
||||
const select = (name?: string) =>
|
||||
name ? options.providers.filter((provider) => provider.name === name) : options.providers;
|
||||
const attempts = Math.max(1, options.retry?.attempts ?? 1);
|
||||
const failureThreshold = Math.max(1, options.circuitBreaker?.failureThreshold ?? 5);
|
||||
const resetAfterMs = Math.max(0, options.circuitBreaker?.resetAfterMs ?? 30_000);
|
||||
|
||||
const generate: AIClient["generate"] = async (prompt, callOptions = {}) => {
|
||||
const providers = select(callOptions.provider);
|
||||
if (!providers.length)
|
||||
throw new AIError(`Unknown AI provider: ${callOptions.provider}`, 0, "provider_error");
|
||||
let last: unknown;
|
||||
for (const provider of providers) {
|
||||
try {
|
||||
await options.onAttempt?.(provider.name);
|
||||
return await provider.generate(prompt, callOptions);
|
||||
} catch (error) {
|
||||
last = error;
|
||||
await options.onAttempt?.(provider.name, error);
|
||||
if (options.fallback === false || callOptions.provider) throw error;
|
||||
const circuit = circuits.get(provider.name) ?? { failures: 0 };
|
||||
if (circuit.openedAt !== undefined && Date.now() - circuit.openedAt < resetAfterMs) {
|
||||
await options.onAttempt?.({ provider: provider.name, attempt: 0, outcome: "circuit-open" });
|
||||
last = new AIError(`Circuit is open for provider: ${provider.name}`, 0, "circuit_open");
|
||||
if (callOptions.provider) throw last;
|
||||
continue;
|
||||
}
|
||||
if (circuit.openedAt !== undefined) {
|
||||
circuit.failures = 0;
|
||||
circuit.openedAt = undefined;
|
||||
}
|
||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||
if (callOptions.signal?.aborted) throw callOptions.signal.reason ?? abortError();
|
||||
const started = Date.now();
|
||||
await options.onAttempt?.({ provider: provider.name, attempt, outcome: "start" });
|
||||
try {
|
||||
const result = await provider.generate(prompt, callOptions);
|
||||
circuits.set(provider.name, { failures: 0 });
|
||||
await options.onAttempt?.({
|
||||
provider: provider.name,
|
||||
attempt,
|
||||
outcome: "success",
|
||||
durationMs: Date.now() - started,
|
||||
usage: result.usage,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
last = error;
|
||||
circuit.failures++;
|
||||
if (circuit.failures >= failureThreshold) circuit.openedAt = Date.now();
|
||||
circuits.set(provider.name, circuit);
|
||||
await options.onAttempt?.({
|
||||
provider: provider.name,
|
||||
attempt,
|
||||
outcome: "error",
|
||||
durationMs: Date.now() - started,
|
||||
error: safeError(error),
|
||||
});
|
||||
const retryable = (options.retry?.retry ?? defaultRetry)(error);
|
||||
if (!retryable || attempt === attempts || callOptions.signal?.aborted) break;
|
||||
const backoff = Math.min(
|
||||
options.retry?.maxDelayMs ?? 5_000,
|
||||
(options.retry?.baseDelayMs ?? 100) * 2 ** (attempt - 1),
|
||||
);
|
||||
await delay(backoff, callOptions.signal);
|
||||
}
|
||||
}
|
||||
if (options.fallback === false || callOptions.provider) throw last;
|
||||
}
|
||||
throw last;
|
||||
};
|
||||
|
||||
return {
|
||||
generate,
|
||||
async generateObject<T>(
|
||||
@@ -114,6 +296,16 @@ export function createAIClient(options: AIClientOptions): AIClient {
|
||||
validate?: (value: unknown) => value is T;
|
||||
} = {},
|
||||
) {
|
||||
const candidates = select(callOptions.provider);
|
||||
if (
|
||||
candidates.length &&
|
||||
candidates.every((provider) => provider.capabilities?.structuredOutput === false)
|
||||
)
|
||||
throw new AIError(
|
||||
"Selected provider does not support structured output",
|
||||
0,
|
||||
"capability_error",
|
||||
);
|
||||
const result = await generate(prompt, callOptions);
|
||||
let value: unknown;
|
||||
try {
|
||||
@@ -129,11 +321,27 @@ export function createAIClient(options: AIClientOptions): AIClient {
|
||||
);
|
||||
return { ...result, value: value as T };
|
||||
},
|
||||
async executeTools(result, tools, toolOptions = {}) {
|
||||
const registry = new Map(tools.map((tool) => [tool.name, tool]));
|
||||
const output: Array<{ call: AIToolCall; value: unknown }> = [];
|
||||
for (const call of result.toolCalls ?? []) {
|
||||
if (toolOptions.signal?.aborted) throw toolOptions.signal.reason ?? abortError();
|
||||
const tool = registry.get(call.name);
|
||||
if (!tool) throw new AIError(`Unknown AI tool: ${call.name}`, 0, "tool_error");
|
||||
if (tool.validate && !tool.validate(call.arguments))
|
||||
throw new AIError(`Invalid arguments for AI tool: ${call.name}`, 0, "tool_error");
|
||||
output.push({
|
||||
call,
|
||||
value: await tool.execute(call.arguments, { signal: toolOptions.signal }),
|
||||
});
|
||||
}
|
||||
return output;
|
||||
},
|
||||
async *stream(prompt, callOptions = {}) {
|
||||
const providers = select(callOptions.provider);
|
||||
let last: unknown;
|
||||
for (const provider of providers) {
|
||||
if (!provider.stream) continue;
|
||||
if (!provider.stream || provider.capabilities?.streaming === false) continue;
|
||||
try {
|
||||
yield* provider.stream(prompt, callOptions);
|
||||
return;
|
||||
@@ -146,5 +354,10 @@ export function createAIClient(options: AIClientOptions): AIClient {
|
||||
const result = await generate(prompt, callOptions);
|
||||
yield result.value;
|
||||
},
|
||||
capabilities(provider) {
|
||||
return Object.fromEntries(
|
||||
select(provider).map((item) => [item.name, { ...item.capabilities }]),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { AIError, createAI } from "../src/index.ts";
|
||||
import {
|
||||
AIError,
|
||||
createAI,
|
||||
createAIClient,
|
||||
deterministicAIProvider,
|
||||
type AIProvider,
|
||||
} from "../src/index.ts";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
@@ -54,3 +60,76 @@ test("streams fragmented SSE and keeps a final event without a newline", async (
|
||||
for await (const chunk of createAI({ apiKey: "secret" }).stream("Hi")) chunks.push(chunk);
|
||||
expect(chunks).toEqual(["A", "B"]);
|
||||
});
|
||||
|
||||
test("generates and validates structured output with an offline provider", async () => {
|
||||
const client = createAIClient({
|
||||
providers: [deterministicAIProvider({ responses: ['```json\n{"ok":true}\n```'] })],
|
||||
});
|
||||
const result = await client.generateObject<{ ok: boolean }>("ignored", {
|
||||
validate: (value): value is { ok: boolean } =>
|
||||
typeof value === "object" && value !== null && "ok" in value,
|
||||
});
|
||||
expect(result.value).toEqual({ ok: true });
|
||||
expect(client.capabilities().deterministic?.structuredOutput).toBe(true);
|
||||
});
|
||||
|
||||
test("retries transient failures and reports redacted metadata", async () => {
|
||||
let calls = 0;
|
||||
const events: unknown[] = [];
|
||||
const provider: AIProvider = {
|
||||
name: "retrying",
|
||||
async generate() {
|
||||
calls++;
|
||||
if (calls === 1) throw new AIError("temporary secret-token", 503, "overloaded");
|
||||
return { value: "done", provider: "retrying", usage: { totalTokens: 3, costUsd: 0.01 } };
|
||||
},
|
||||
};
|
||||
const result = await createAIClient({
|
||||
providers: [provider],
|
||||
retry: { attempts: 2, baseDelayMs: 0 },
|
||||
onAttempt: (event) => {
|
||||
events.push(event);
|
||||
},
|
||||
}).generate("private prompt");
|
||||
expect(result.value).toBe("done");
|
||||
expect(calls).toBe(2);
|
||||
expect(JSON.stringify(events)).not.toContain("private prompt");
|
||||
expect(JSON.stringify(events)).not.toContain("raw");
|
||||
});
|
||||
|
||||
test("opens a provider circuit and falls back", async () => {
|
||||
let failedCalls = 0;
|
||||
const failing: AIProvider = {
|
||||
name: "failing",
|
||||
async generate() {
|
||||
failedCalls++;
|
||||
throw new AIError("down", 503);
|
||||
},
|
||||
};
|
||||
const client = createAIClient({
|
||||
providers: [failing, deterministicAIProvider({ responses: ["one", "two"] })],
|
||||
retry: { attempts: 1 },
|
||||
circuitBreaker: { failureThreshold: 1, resetAfterMs: 60_000 },
|
||||
});
|
||||
expect((await client.generate("first")).value).toBe("one");
|
||||
expect((await client.generate("second")).value).toBe("two");
|
||||
expect(failedCalls).toBe(1);
|
||||
});
|
||||
|
||||
test("executes validated tool calls and forwards cancellation", async () => {
|
||||
const client = createAIClient({ providers: [deterministicAIProvider({ responses: ["ok"] })] });
|
||||
const result = {
|
||||
value: "",
|
||||
provider: "deterministic",
|
||||
toolCalls: [{ id: "1", name: "double", arguments: { value: 4 } }],
|
||||
};
|
||||
const output = await client.executeTools(result, [
|
||||
{
|
||||
name: "double",
|
||||
validate: (value): value is { value: number } =>
|
||||
typeof value === "object" && value !== null && "value" in value,
|
||||
execute: ({ value }) => value * 2,
|
||||
},
|
||||
]);
|
||||
expect(output[0]?.value).toBe(8);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
aiRateLimiter,
|
||||
createRagPipeline,
|
||||
evaluateAI,
|
||||
googleAIProvider,
|
||||
guardedProvider,
|
||||
localAIProvider,
|
||||
maxPromptLength,
|
||||
memoryConversationStore,
|
||||
memoryVectorStore,
|
||||
openAIProvider,
|
||||
promptTemplate,
|
||||
} from "../src/platform.ts";
|
||||
|
||||
describe("AI platform", () => {
|
||||
test("adapts OpenAI, Google and local providers", async () => {
|
||||
const openFetch = (async () =>
|
||||
Response.json({
|
||||
model: "gpt",
|
||||
choices: [{ message: { content: "hello" }, finish_reason: "stop" }],
|
||||
usage: { total_tokens: 3 },
|
||||
})) as unknown as typeof fetch;
|
||||
expect(
|
||||
(await openAIProvider({ model: "gpt", apiKey: "key", fetch: openFetch }).generate("hi"))
|
||||
.value,
|
||||
).toBe("hello");
|
||||
const googleFetch = (async () =>
|
||||
Response.json({
|
||||
candidates: [{ content: { parts: [{ text: "hola" }] } }],
|
||||
usageMetadata: { totalTokenCount: 2 },
|
||||
})) as unknown as typeof fetch;
|
||||
expect(
|
||||
(
|
||||
await googleAIProvider({ model: "gemini", apiKey: "key", fetch: googleFetch }).generate(
|
||||
"hi",
|
||||
)
|
||||
).value,
|
||||
).toBe("hola");
|
||||
expect(
|
||||
(await localAIProvider({ model: "llama", fetch: openFetch }).generate("hi")).provider,
|
||||
).toBe("local");
|
||||
});
|
||||
|
||||
test("indexes and retrieves a RAG answer", async () => {
|
||||
const store = memoryVectorStore<{ source: string }>();
|
||||
const embeddings = {
|
||||
name: "test",
|
||||
embed: async (values: string[]) => ({
|
||||
vectors: values.map((value) => (value.includes("Bun") ? [1, 0] : [0, 1])),
|
||||
}),
|
||||
};
|
||||
const rag = createRagPipeline({
|
||||
embeddings,
|
||||
store,
|
||||
generate: async (prompt) => ({
|
||||
value: prompt.includes("fast") ? "Bun [1]" : "none",
|
||||
provider: "test",
|
||||
}),
|
||||
});
|
||||
await rag.index([
|
||||
{ id: "bun", text: "Bun is fast", metadata: { source: "docs" } },
|
||||
{ id: "other", text: "Other", metadata: { source: "other" } },
|
||||
]);
|
||||
const result = await rag.ask("Bun runtime");
|
||||
expect(result.value).toBe("Bun [1]");
|
||||
expect(result.sources[0]?.id).toBe("bun");
|
||||
});
|
||||
|
||||
test("persists conversations, renders prompts, guards and rate limits", async () => {
|
||||
const conversations = memoryConversationStore(2);
|
||||
await conversations.append("one", [
|
||||
{ role: "user", content: "a" },
|
||||
{ role: "assistant", content: "b" },
|
||||
{ role: "user", content: "c" },
|
||||
]);
|
||||
expect(await conversations.load("one")).toHaveLength(2);
|
||||
expect(promptTemplate("Hello {{name}}")({ name: "Wire" })).toBe("Hello Wire");
|
||||
const provider = guardedProvider(
|
||||
{ name: "test", generate: async () => ({ value: "ok", provider: "test" }) },
|
||||
[maxPromptLength(3)],
|
||||
);
|
||||
await expect(provider.generate("long")).rejects.toThrow("guardrail");
|
||||
const limit = aiRateLimiter({ limit: 1, windowMs: 100, now: () => 0 });
|
||||
expect(limit("u").allowed).toBe(true);
|
||||
expect(limit("u").allowed).toBe(false);
|
||||
});
|
||||
|
||||
test("evaluates model output", async () => {
|
||||
const report = await evaluateAI(
|
||||
[{ name: "answer", prompt: "question", expected: "42" }],
|
||||
async () => "42",
|
||||
);
|
||||
expect(report.score).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -368,3 +368,14 @@ bun run validate:auth
|
||||
```
|
||||
|
||||
Read [SECURITY.md](./SECURITY.md) before production deployment.
|
||||
|
||||
## Package-owned UI blocks and route helpers
|
||||
|
||||
Authentication forms continue to compose `@wrnexus/ui` inputs, buttons, cards, alerts, badges, avatars, and PIN controls. The package also provides:
|
||||
|
||||
- `<AuthShell />`
|
||||
- `<AuthProviderButtons />`
|
||||
- `<AuthSecurityNotice />`
|
||||
- complete sign-in, sign-up, MFA, passkey, recovery, account-status, session, and impersonation blocks
|
||||
|
||||
Server helpers include `authRoute`, `authSuccess`, `authFailure`, `requireAuthUser`, `optionalAuthUser`, `currentAuthSession`, and `authComponentProps`.
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
component AccountStatus {
|
||||
props { status = "active" title = "Account status" activeMessage = "Your account is active and ready to use." pendingMessage = "Verify your contact details to activate your account." lockedMessage = "Your account is temporarily locked for security." disabledMessage = "Your account has been disabled." supportHref = "/support" color = "primary" size = "md" class = "" }
|
||||
props {
|
||||
status: string = "active"
|
||||
title: string = "Account status"
|
||||
activeMessage: string = "Your account is active and ready to use."
|
||||
pendingMessage: string = "Verify your contact details to activate your account."
|
||||
lockedMessage: string = "Your account is temporarily locked for security."
|
||||
disabledMessage: string = "Your account has been disabled."
|
||||
supportHref: string = "/support"
|
||||
color: string = "primary"
|
||||
size: string = "md"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
view {
|
||||
<section {...attrs} data-status='{status}' class='w-full max-w-lg rounded-[var(--wire-radius-lg)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-6 text-center {class}'>
|
||||
<span class='mx-auto flex size-14 items-center justify-center rounded-full bg-[var(--wire-color-surface-2)] text-[var(--wire-color-primary)] data-[status=locked]:text-[var(--wire-color-warning)] data-[status=disabled]:text-[var(--wire-color-danger)]'>{#if status == "active"}<span class="icon-[lucide--circle-check] size-7"></span>{:else if status == "pending"}<span class="icon-[lucide--clock-3] size-7"></span>{:else}<span class="icon-[lucide--shield-alert] size-7"></span>{/if}</span>
|
||||
<h1 class="mb-0 mt-4 text-2xl font-semibold">{title}</h1>
|
||||
<p class="mx-auto mt-2 max-w-md text-sm text-[var(--wire-color-muted)]">{status == "active" ? activeMessage : status == "pending" ? pendingMessage : status == "locked" ? lockedMessage : disabledMessage}</p>
|
||||
{#if status != "active"}<a href='{supportHref}' class="mt-4 inline-flex h-10 items-center justify-center rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-4 text-sm font-semibold text-white">Contact support</a>{/if}
|
||||
</section>
|
||||
<Card {...attrs} title='{title}' color='{color}' size='{size}' align="center" class='w-full max-w-lg {class}'>
|
||||
<div class="flex flex-col items-center text-center" data-status='{status}'>
|
||||
<span class='flex size-14 items-center justify-center rounded-full bg-[var(--wire-color-surface-2)] text-[var(--wire-color-primary)]'>
|
||||
{#if status == "active"}<span class="icon-[lucide--circle-check] size-7"></span>{:else if status == "pending"}<span class="icon-[lucide--clock-3] size-7"></span>{:else}<span class="icon-[lucide--shield-alert] size-7"></span>{/if}
|
||||
</span>
|
||||
<p class="mx-auto mt-3 max-w-md text-sm text-[var(--wire-color-muted)]">{status == "active" ? activeMessage : status == "pending" ? pendingMessage : status == "locked" ? lockedMessage : disabledMessage}</p>
|
||||
<Badge label='{status}' color='{status == "active" ? "success" : status == "pending" ? "warning" : "danger"}' variant="soft" size="sm" />
|
||||
{#if status != "active"}<Button href='{supportHref}' label="Contact support" color='{color}' size='{size}' class="mt-4" />{/if}
|
||||
</div>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
component AuthProviderButtons {
|
||||
outputs {
|
||||
select(payload: { provider: string; href: string })
|
||||
}
|
||||
|
||||
props {
|
||||
providers: unknown[] = []
|
||||
title: string = "Continue with"
|
||||
dividerLabel: string = "or"
|
||||
color: string = "primary"
|
||||
size: string = "md"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
functions {
|
||||
client function choose(provider) {
|
||||
output.select({ provider: provider.id || provider.name || provider.label, href: provider.href || "" })
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<div {...attrs} class='space-y-3 {class}'>
|
||||
{#if title}<p class="m-0 text-sm font-semibold text-[var(--wire-color-text)]">{title}</p>{/if}
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
{#each providers as provider}
|
||||
<Button
|
||||
label='{provider.label || provider.name || "Continue"}'
|
||||
href='{provider.href || ""}'
|
||||
icon='{provider.icon || "icon-[lucide--log-in]"}'
|
||||
variant='{provider.variant || "outline"}'
|
||||
color='{provider.color || color}'
|
||||
size='{size}'
|
||||
fullWidth="true"
|
||||
@click='choose(provider)'
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{#if dividerLabel}
|
||||
<div class="flex items-center gap-3 text-xs text-[var(--wire-color-muted)]"><span class="h-px flex-1 bg-[var(--wire-color-border)]"></span><span>{dividerLabel}</span><span class="h-px flex-1 bg-[var(--wire-color-border)]"></span></div>
|
||||
{/if}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
component AuthSecurityNotice {
|
||||
props {
|
||||
title: string = "Security notice"
|
||||
description: string = "Your session and credentials are protected by WRNexusJS security controls."
|
||||
color: string = "info"
|
||||
size: string = "sm"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
view {
|
||||
<Alert
|
||||
{...attrs}
|
||||
title='{title}'
|
||||
description='{description}'
|
||||
icon="icon-[lucide--shield-check]"
|
||||
color='{color}'
|
||||
size='{size}'
|
||||
variant="soft"
|
||||
class='text-[var(--wire-color-text)] {class}'
|
||||
/>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
component AuthShell {
|
||||
props {
|
||||
title: string = "Welcome"
|
||||
description: string = ""
|
||||
eyebrow: string = ""
|
||||
icon: string = "icon-[lucide--shield-check]"
|
||||
footer: string = ""
|
||||
color: string = "primary"
|
||||
size: string = "md"
|
||||
maxWidth: string = "md"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
view {
|
||||
<Card
|
||||
{...attrs}
|
||||
title='{title}'
|
||||
description='{description}'
|
||||
header='{eyebrow}'
|
||||
footer='{footer}'
|
||||
color='{color}'
|
||||
size='{size}'
|
||||
class='w-full {class}'
|
||||
class:max-w-sm='maxWidth === "sm"'
|
||||
class:max-w-md='maxWidth === "md"'
|
||||
class:max-w-lg='maxWidth === "lg"'
|
||||
class:max-w-xl='maxWidth === "xl"'
|
||||
class:max-w-2xl='maxWidth === "2xl"'
|
||||
class:max-w-none='maxWidth === "full"'
|
||||
>
|
||||
<div class="mb-5 flex size-12 items-center justify-center rounded-full bg-[color-mix(in_srgb,var(--wire-color-primary)_12%,transparent)] text-[var(--wire-color-primary)]">
|
||||
<span class='{icon}' aria-hidden="true"></span>
|
||||
</div>
|
||||
<slot></slot>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
@@ -1,40 +1,39 @@
|
||||
component DeviceSessions {
|
||||
props {
|
||||
sessions = []
|
||||
currentSessionId = ""
|
||||
title = "Active sessions"
|
||||
description = "Review devices signed in to your account."
|
||||
revokeAction = "/api/auth/sessions/revoke"
|
||||
revokeSchema = "auth-session-revoke"
|
||||
revokeLabel = "Sign out"
|
||||
successMessage = "Session revoked."
|
||||
color = "primary"
|
||||
size = "md"
|
||||
class = ""
|
||||
sessions: unknown[] = []
|
||||
currentSessionId: string = ""
|
||||
title: string = "Active sessions"
|
||||
description: string = "Review devices signed in to your account."
|
||||
revokeAction: string = "/api/auth/sessions/revoke"
|
||||
revokeSchema: string = "auth-session-revoke"
|
||||
revokeLabel: string = "Sign out"
|
||||
successMessage: string = "Session revoked."
|
||||
color: string = "primary"
|
||||
size: string = "md"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
view {
|
||||
<section {...attrs} data-wrnexus-runtime="auth" class='w-full max-w-2xl rounded-[var(--wire-radius-lg)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-6 text-[var(--wire-color-text)] {class}'>
|
||||
<h2 class="m-0 text-xl font-semibold">{title}</h2>
|
||||
<p class="mt-1 text-sm text-[var(--wire-color-muted)]">{description}</p>
|
||||
<div class="mt-5 divide-y divide-[var(--wire-color-border)]">
|
||||
<Card {...attrs} title='{title}' description='{description}' color='{color}' size='{size}' class='w-full max-w-2xl {class}'>
|
||||
<div class="divide-y divide-[var(--wire-color-border)]">
|
||||
{#each sessions as session}
|
||||
<article class="flex items-center gap-3 py-4">
|
||||
<span class="flex size-10 items-center justify-center rounded-full bg-[var(--wire-color-surface-2)]"><span class="icon-[lucide--monitor-smartphone] size-5"></span></span>
|
||||
<Avatar fallback='{session.userAgent || "Device"}' icon="icon-[lucide--monitor-smartphone]" size="sm" color='{color}' />
|
||||
<div class="min-w-0 flex-1"><p class="m-0 truncate text-sm font-semibold">{session.userAgent || "Unknown device"}</p><p class="m-0 mt-1 text-xs text-[var(--wire-color-muted)]">{session.ip || "Unknown IP"} · Last active {session.lastSeenAt}</p></div>
|
||||
{#if session.id == currentSessionId}
|
||||
<span class="rounded-full bg-[color-mix(in_srgb,var(--wire-color-success)_12%,transparent)] px-2 py-1 text-xs font-semibold text-[var(--wire-color-success)]">Current</span>
|
||||
<Badge label="Current" color="success" variant="soft" size="sm" />
|
||||
{:else}
|
||||
<form method="post" action='{revokeAction}' data-schema='{revokeSchema}' novalidate>
|
||||
<input type="hidden" name="sessionId" value='{session.id}' />
|
||||
<p data-error="sessionId" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
|
||||
<p data-error="_form" role="alert" class="m-0 hidden text-xs text-[var(--wire-color-danger)]"></p>
|
||||
<p data-success='{successMessage}' role="status" hidden class="m-0 text-xs text-[var(--wire-color-success)]">{successMessage}</p>
|
||||
<button type="submit" class="text-sm font-semibold text-[var(--wire-color-danger)] disabled:opacity-60">{revokeLabel}</button>
|
||||
<Button type="submit" label='{revokeLabel}' color="danger" variant="ghost" size="sm" />
|
||||
</form>
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
component ImpersonationBanner {
|
||||
props {
|
||||
visible = true
|
||||
targetName = "this user"
|
||||
stopAction = "/api/auth/impersonation/stop"
|
||||
stopSchema = "auth-empty"
|
||||
redirect = "/account"
|
||||
message = "You are viewing the application as"
|
||||
stopLabel = "Stop impersonating"
|
||||
color = "warning"
|
||||
size = "md"
|
||||
class = ""
|
||||
visible: boolean = true
|
||||
targetName: string = "this user"
|
||||
stopAction: string = "/api/auth/impersonation/stop"
|
||||
stopSchema: string = "auth-empty"
|
||||
redirect: string = "/account"
|
||||
message: string = "You are viewing the application as"
|
||||
stopLabel: string = "Stop impersonating"
|
||||
color: string = "warning"
|
||||
size: string = "md"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
view {
|
||||
{#if visible}
|
||||
<aside {...attrs} data-wrnexus-runtime="auth" role="status" class='flex w-full flex-wrap items-center justify-between gap-3 border-b border-[var(--wire-color-warning)] bg-[color-mix(in_srgb,var(--wire-color-warning)_14%,var(--wire-color-surface))] px-4 py-2 text-sm text-[var(--wire-color-text)] {class}'>
|
||||
<span class="flex items-center gap-2"><span class="icon-[lucide--scan-face] size-4 text-[var(--wire-color-warning)]"></span><span>{message} <strong>{targetName}</strong>.</span></span>
|
||||
<form method="post" action='{stopAction}' data-schema='{stopSchema}' data-redirect='{redirect}' novalidate><p data-error="_form" role="alert" class="m-0 hidden text-xs text-[var(--wire-color-danger)]"></p><button type="submit" class="inline-flex h-8 items-center rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-warning)] px-3 text-xs font-semibold text-black disabled:opacity-60">{stopLabel}</button></form>
|
||||
</aside>
|
||||
<Alert {...attrs} title='{message + " " + targetName}' icon="icon-[lucide--scan-face]" color='{color}' size='{size}' variant="soft" class='{class}'>
|
||||
<form method="post" action='{stopAction}' data-schema='{stopSchema}' data-redirect='{redirect}' novalidate class="mt-2">
|
||||
<p data-error="_form" role="alert" class="m-0 hidden text-xs text-[var(--wire-color-danger)]"></p>
|
||||
<Button type="submit" label='{stopLabel}' color='{color}' size="sm" />
|
||||
</form>
|
||||
</Alert>
|
||||
{/if}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,42 @@
|
||||
component PasskeyButton {
|
||||
props {
|
||||
mode = "authenticate"
|
||||
label = "Continue with a passkey"
|
||||
registerLabel = "Add a passkey"
|
||||
identifier = ""
|
||||
rpId = ""
|
||||
rpName = "WRNexusJS"
|
||||
passkeyName = "Passkey"
|
||||
optionsEndpoint = ""
|
||||
verifyEndpoint = ""
|
||||
redirect = ""
|
||||
mfaHref = "/two-factor"
|
||||
conditional = false
|
||||
fullWidth = false
|
||||
loadingMessage = "Waiting for your passkey…"
|
||||
successMessage = "Passkey verified."
|
||||
color = "primary"
|
||||
size = "md"
|
||||
class = ""
|
||||
@event passkeyRegistered = function
|
||||
@event passkeyAuthenticated = function
|
||||
@event error = function
|
||||
outputs {
|
||||
passkeyRegistered(payload: { credentialId: string; response?: object })
|
||||
passkeyAuthenticated(payload: { userId?: string; sessionId?: string; response?: object })
|
||||
error(payload: { code: string; message: string })
|
||||
}
|
||||
|
||||
props {
|
||||
mode: string = "authenticate"
|
||||
label: string = "Continue with a passkey"
|
||||
registerLabel: string = "Add a passkey"
|
||||
identifier: string = ""
|
||||
rpId: string = ""
|
||||
rpName: string = "WRNexusJS"
|
||||
passkeyName: string = "Passkey"
|
||||
optionsEndpoint: string = ""
|
||||
verifyEndpoint: string = ""
|
||||
redirect: string = ""
|
||||
mfaHref: string = "/two-factor"
|
||||
conditional: boolean = false
|
||||
fullWidth: boolean = false
|
||||
loadingMessage: string = "Waiting for your passkey…"
|
||||
successMessage: string = "Passkey verified."
|
||||
color: string = "primary"
|
||||
size: string = "md"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
view {
|
||||
<div {...attrs} data-wrnexus-runtime="auth" data-auth-passkey='{mode}' data-identifier='{identifier}' data-rp-id='{rpId}' data-rp-name='{rpName}' data-passkey-name='{passkeyName}' data-options-endpoint='{optionsEndpoint}' data-verify-endpoint='{verifyEndpoint}' data-redirect='{redirect}' data-mfa-href='{mfaHref}' data-conditional='{conditional}' data-loading-message='{loadingMessage}' data-success-message='{successMessage}' data-auth-state="idle" aria-busy="false" class='space-y-2 {class}'>
|
||||
<button type="button" class='inline-flex h-11 items-center justify-center gap-2 rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] px-4 text-sm font-semibold text-[var(--wire-color-text)] hover:bg-[var(--wire-color-surface-2)] disabled:cursor-wait disabled:opacity-60 {fullWidth ? "w-full" : ""}'>
|
||||
<span aria-hidden="true" class="icon-[lucide--key-round] size-4"></span>
|
||||
{mode == "register" ? registerLabel : label}
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
label='{mode == "register" ? registerLabel : label}'
|
||||
icon="icon-[lucide--key-round]"
|
||||
variant="outline"
|
||||
color='{color}'
|
||||
size='{size}'
|
||||
fullWidth='{fullWidth}'
|
||||
/>
|
||||
<p data-auth-status hidden role="status" class="m-0 text-xs text-[var(--wire-color-muted)]"></p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,86 +1,35 @@
|
||||
component RecoveryCodes {
|
||||
props {
|
||||
codes = []
|
||||
schema = "auth-recovery-codes"
|
||||
title = "Recovery codes"
|
||||
description = "Store these codes somewhere safe. Each code can be used once."
|
||||
downloadLabel = "Download codes"
|
||||
regenerateLabel = "Generate new codes"
|
||||
regenerateAction = "/api/auth/recovery-codes"
|
||||
successMessage = "New recovery codes generated. Previous unused codes are no longer valid."
|
||||
count = 10
|
||||
color = "primary"
|
||||
size = "md"
|
||||
class = ""
|
||||
codes: unknown[] = []
|
||||
schema: string = "auth-recovery-codes"
|
||||
title: string = "Recovery codes"
|
||||
description: string = "Store these codes somewhere safe. Each code can be used once."
|
||||
downloadLabel: string = "Download codes"
|
||||
regenerateLabel: string = "Generate new codes"
|
||||
regenerateAction: string = "/api/auth/recovery-codes"
|
||||
successMessage: string = "New recovery codes generated. Previous unused codes are no longer valid."
|
||||
count: number = 10
|
||||
color: string = "primary"
|
||||
size: string = "md"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
view {
|
||||
<section
|
||||
{...attrs}
|
||||
data-wrnexus-runtime="auth"
|
||||
data-auth-recovery-codes
|
||||
data-recovery-filename="wrnexus-recovery-codes.txt"
|
||||
class='w-full max-w-xl rounded-[var(--wire-radius-lg)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-6 {class}'
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="m-0 text-xl font-semibold">{title}</h2>
|
||||
<p class="mt-1 text-sm text-[var(--wire-color-muted)]">{description}</p>
|
||||
</div>
|
||||
<span class="icon-[lucide--shield-keyhole] size-6 text-[var(--wire-color-primary)]"></span>
|
||||
<Card {...attrs} title='{title}' description='{description}' color='{color}' size='{size}' class='w-full max-w-xl {class}' data-wrnexus-runtime="auth" data-auth-recovery-codes data-recovery-filename="wrnexus-recovery-codes.txt">
|
||||
<Alert title="Keep these codes private" description="Anyone with a recovery code may be able to access your account." icon="icon-[lucide--shield-alert]" color="warning" variant="soft" size="sm" />
|
||||
<div data-recovery-code-list class="mt-4 grid grid-cols-2 gap-2 rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-surface-2)] p-4 font-mono text-sm sm:grid-cols-3">
|
||||
{#each codes as code}<code data-recovery-code class="rounded bg-[var(--wire-color-surface)] px-2 py-1.5 text-center">{code}</code>{/each}
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-recovery-code-list
|
||||
class="mt-5 grid grid-cols-2 gap-2 rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-surface-2)] p-4 font-mono text-sm sm:grid-cols-3"
|
||||
>
|
||||
{#each codes as code}
|
||||
<code
|
||||
data-recovery-code
|
||||
class="rounded bg-[var(--wire-color-surface)] px-2 py-1.5 text-center"
|
||||
>{code}</code>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-recovery-download
|
||||
class="h-10 rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] px-4 text-sm font-semibold"
|
||||
>
|
||||
{downloadLabel}
|
||||
</button>
|
||||
|
||||
<form
|
||||
method="post"
|
||||
action='{regenerateAction}'
|
||||
data-schema='{schema}'
|
||||
novalidate
|
||||
class="flex-1"
|
||||
>
|
||||
<Button type="button" label='{downloadLabel}' icon="icon-[lucide--download]" variant="outline" color='{color}' size='{size}' data-recovery-download />
|
||||
<form method="post" action='{regenerateAction}' data-schema='{schema}' novalidate class="min-w-48 flex-1">
|
||||
<input type="hidden" name="count" value='{count}' />
|
||||
<p data-error="count" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
|
||||
<p
|
||||
data-error="_form"
|
||||
role="alert"
|
||||
class="m-0 hidden text-xs text-[var(--wire-color-danger)]"
|
||||
></p>
|
||||
<p
|
||||
data-success='{successMessage}'
|
||||
role="status"
|
||||
hidden
|
||||
class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-success)_10%,transparent)] p-3 text-xs text-[var(--wire-color-success)]"
|
||||
>
|
||||
{successMessage}
|
||||
</p>
|
||||
<button
|
||||
type="submit"
|
||||
class="h-10 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] px-4 text-sm font-semibold disabled:cursor-wait disabled:opacity-60"
|
||||
>
|
||||
{regenerateLabel}
|
||||
</button>
|
||||
<p data-error="_form" role="alert" class="m-0 hidden text-xs text-[var(--wire-color-danger)]"></p>
|
||||
<p data-success='{successMessage}' role="status" hidden class="m-0 text-xs text-[var(--wire-color-success)]">{successMessage}</p>
|
||||
<Button type="submit" label='{regenerateLabel}' icon="icon-[lucide--refresh-cw]" variant="outline" color='{color}' size='{size}' fullWidth="true" />
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/auth",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
@@ -52,7 +52,7 @@
|
||||
"@wrnexus/db": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/bun": "^1.3.14",
|
||||
"@wrnexus/syntax": "workspace:*",
|
||||
"typescript": "^5.9.2"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Context } from "@wrnexus/core";
|
||||
import type { AuthEngine } from "./engine.ts";
|
||||
import type { AuthSession, AuthUser } from "./types.ts";
|
||||
|
||||
export type AuthRouteName =
|
||||
| "signIn"
|
||||
| "signUp"
|
||||
| "signOut"
|
||||
| "forgotPassword"
|
||||
| "resetPassword"
|
||||
| "verifyEmail"
|
||||
| "verifyPhone"
|
||||
| "twoFactor"
|
||||
| "sessions"
|
||||
| "passkeys";
|
||||
|
||||
const DEFAULT_AUTH_ROUTES: Record<AuthRouteName, string> = {
|
||||
signIn: "/sign-in",
|
||||
signUp: "/sign-up",
|
||||
signOut: "/api/auth/logout",
|
||||
forgotPassword: "/forgot-password",
|
||||
resetPassword: "/reset-password",
|
||||
verifyEmail: "/verify-email",
|
||||
verifyPhone: "/verify-phone",
|
||||
twoFactor: "/two-factor",
|
||||
sessions: "/account/sessions",
|
||||
passkeys: "/account/passkeys",
|
||||
};
|
||||
|
||||
export function authRoute(
|
||||
name: AuthRouteName,
|
||||
options: { basePath?: string; overrides?: Partial<Record<AuthRouteName, string>> } = {},
|
||||
): string {
|
||||
const route = options.overrides?.[name] ?? DEFAULT_AUTH_ROUTES[name];
|
||||
if (!options.basePath || route.startsWith("http://") || route.startsWith("https://"))
|
||||
return route;
|
||||
return `${options.basePath.replace(/\/$/, "")}/${route.replace(/^\//, "")}`;
|
||||
}
|
||||
|
||||
export function authSuccess<T extends Record<string, unknown>>(
|
||||
data: T,
|
||||
init: ResponseInit = {},
|
||||
): Response {
|
||||
return Response.json({ ok: true, ...data }, { status: init.status ?? 200, ...init });
|
||||
}
|
||||
|
||||
export function authFailure(
|
||||
code: string,
|
||||
message: string,
|
||||
status = 400,
|
||||
details?: Record<string, unknown>,
|
||||
): Response {
|
||||
return Response.json(
|
||||
{ ok: false, error: { code, message, ...(details ? { details } : {}) } },
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
|
||||
export function requireAuthUser(ctx: Context): AuthUser {
|
||||
if (!ctx.user || typeof ctx.user !== "object") {
|
||||
throw new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
return ctx.user as AuthUser;
|
||||
}
|
||||
|
||||
export function optionalAuthUser(ctx: Context): AuthUser | null {
|
||||
return ctx.user && typeof ctx.user === "object" ? (ctx.user as AuthUser) : null;
|
||||
}
|
||||
|
||||
export async function currentAuthSession(
|
||||
engine: AuthEngine,
|
||||
sessionId: string | undefined,
|
||||
): Promise<AuthSession | null> {
|
||||
if (!sessionId) return null;
|
||||
const session = await engine.store.findSession(sessionId);
|
||||
return session ?? null;
|
||||
}
|
||||
|
||||
export function authComponentProps(
|
||||
input: Record<string, unknown>,
|
||||
defaults: { color?: string; size?: string; class?: string } = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
color: defaults.color ?? "primary",
|
||||
size: defaults.size ?? "md",
|
||||
class: defaults.class ?? "",
|
||||
...input,
|
||||
};
|
||||
}
|
||||
@@ -101,3 +101,5 @@ export {
|
||||
type AuthSchemaSet,
|
||||
type AuthSchemaOverrides,
|
||||
} from "./validation.ts";
|
||||
|
||||
export * from "./helpers.ts";
|
||||
|
||||
@@ -297,7 +297,7 @@ export function authPlugin(options: AuthPluginOptions = {}) {
|
||||
|
||||
return definePlugin({
|
||||
name: "@wrnexus/auth",
|
||||
version: "0.5.0",
|
||||
version: "0.8.0",
|
||||
enforce: "post",
|
||||
|
||||
componentDirs(context) {
|
||||
|
||||
@@ -166,10 +166,55 @@ export interface LoginAttempt {
|
||||
riskLevel: AuthRiskLevel;
|
||||
}
|
||||
|
||||
export const AUTH_SECURITY_EVENT_TYPES = [
|
||||
"account.registered",
|
||||
"account.status-changed",
|
||||
"delivery.failed",
|
||||
"device.revoked",
|
||||
"device.trusted",
|
||||
"identity.email-verification-requested",
|
||||
"identity.email-verified",
|
||||
"identity.otp-verified",
|
||||
"identity.phone-verification-requested",
|
||||
"identity.phone-verified",
|
||||
"impersonation.denied",
|
||||
"impersonation.ended",
|
||||
"impersonation.started",
|
||||
"invitation.accepted",
|
||||
"invitation.created",
|
||||
"login.failed",
|
||||
"login.magic-link",
|
||||
"login.oauth",
|
||||
"login.otp",
|
||||
"login.otp-verified",
|
||||
"login.passkey",
|
||||
"login.succeeded",
|
||||
"mfa.failed",
|
||||
"mfa.otp-verified",
|
||||
"mfa.recovery-code-used",
|
||||
"mfa.recovery-codes-generated",
|
||||
"mfa.succeeded",
|
||||
"mfa.totp-disabled",
|
||||
"mfa.totp-enabled",
|
||||
"mfa.totp-verified",
|
||||
"oauth.linked",
|
||||
"oauth.unlinked",
|
||||
"passkey.registered",
|
||||
"password.changed",
|
||||
"password.reset",
|
||||
"password.reset-requested",
|
||||
"session.revoked",
|
||||
"session.revoked-all",
|
||||
] as const;
|
||||
|
||||
export type KnownAuthSecurityEventType = (typeof AUTH_SECURITY_EVENT_TYPES)[number];
|
||||
/** Known framework events plus application-defined extension events. */
|
||||
export type AuthSecurityEventType = KnownAuthSecurityEventType | (string & {});
|
||||
|
||||
export interface AuthSecurityEvent {
|
||||
id: string;
|
||||
userId?: string;
|
||||
type: string;
|
||||
type: AuthSecurityEventType;
|
||||
severity: "info" | "warning" | "critical";
|
||||
actorUserId?: string;
|
||||
sessionId?: string;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { parse } from "@wrnexus/syntax";
|
||||
|
||||
const componentsDir = join(import.meta.dir, "..", "components");
|
||||
|
||||
test("all auth package components parse", () => {
|
||||
for (const file of readdirSync(componentsDir).filter((name) => name.endsWith(".wrn"))) {
|
||||
expect(() => parse(readFileSync(join(componentsDir, file), "utf8"))).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
test("auth form blocks use WRNexus UI controls", () => {
|
||||
const formFiles = [
|
||||
"SignIn.wrn",
|
||||
"SignUp.wrn",
|
||||
"ForgotPassword.wrn",
|
||||
"ResetPassword.wrn",
|
||||
"OtpSignIn.wrn",
|
||||
"TwoFactorChallenge.wrn",
|
||||
"VerifyEmail.wrn",
|
||||
"VerifyPhone.wrn",
|
||||
"InvitationAccept.wrn",
|
||||
"MagicLinkSignIn.wrn",
|
||||
];
|
||||
for (const file of formFiles) {
|
||||
const source = readFileSync(join(componentsDir, file), "utf8");
|
||||
expect(source).toMatch(/<(?:Input|PinInput|StrongPassword|TogglePassword|Checkbox|Select)\b/);
|
||||
expect(source).toContain("<Button");
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/authz",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/benchmark",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"type": "module",
|
||||
"description": "Deterministic benchmark runner and performance regression budgets for WRNexusJS.",
|
||||
"main": "src/index.ts",
|
||||
|
||||
Vendored
+65
-1
@@ -3,7 +3,71 @@
|
||||
Bounded in-memory/tag caching and HTTP response caching for WRNexusJS. Supports request deduplication, tag invalidation, ETags, fresh/stale states, and optional detached stale revalidation.
|
||||
|
||||
```ts
|
||||
import { TagCache, responseCache } from "@wrnexus/cache";
|
||||
import { connectCacheInvalidation, TagCache, responseCache } from "@wrnexus/cache";
|
||||
const cache = new TagCache({ ttlMs: 60_000, staleWhileRevalidateMs: 300_000 });
|
||||
export default responseCache({ cache, tags: ["products"] });
|
||||
```
|
||||
|
||||
`TagCache` bounds entries with LRU-style eviction, deduplicates concurrent
|
||||
loaders, and prevents an invalidated in-flight loader from repopulating stale
|
||||
data. Use `lookup()` when fresh/stale state matters, or `getOrLoad()` for
|
||||
stampede-safe loading.
|
||||
|
||||
For multi-instance applications, connect the cache to any compatible pub/sub
|
||||
bus (including `@wrnexus/pubsub`). Namespaces isolate applications sharing the
|
||||
same broker. Local invalidation happens first and the returned promise confirms
|
||||
cross-instance publication; failures remain visible to the caller.
|
||||
|
||||
```ts
|
||||
import { connectCacheInvalidation, TagCache } from "@wrnexus/cache";
|
||||
import { createPubSub } from "@wrnexus/pubsub";
|
||||
import { redisDriver } from "@wrnexus/pubsub/redis";
|
||||
|
||||
const cache = new TagCache({ maxEntries: 10_000 });
|
||||
const bus = createPubSub(redisDriver(process.env.REDIS_URL));
|
||||
const invalidation = connectCacheInvalidation(cache, bus, {
|
||||
namespace: "storefront-production",
|
||||
onError: (error) => logger.error("cache invalidation failed", { error }),
|
||||
});
|
||||
|
||||
await invalidation.invalidateTag("products");
|
||||
await invalidation.delete("product:42");
|
||||
|
||||
// Unsubscribes this cache only; the shared bus remains owned by the app.
|
||||
invalidation.close();
|
||||
await bus.close();
|
||||
```
|
||||
|
||||
## Framework cache layers
|
||||
|
||||
`CacheCoordinator` keeps the four cache lifetimes explicit:
|
||||
|
||||
- `coordinator.request()` creates request-only deduplication.
|
||||
- `coordinator.data` caches loader/query results.
|
||||
- `coordinator.component` caches reusable rendered fragments.
|
||||
- `coordinator.page` caches complete safe documents.
|
||||
|
||||
All cross-request layers are bounded, tag-aware, stale-while-revalidate capable,
|
||||
stampede-safe, and expose `withLock()` for exclusive per-key work. `inspect()`
|
||||
returns metadata without cached values. Development applications expose that
|
||||
inspection through the Cache panel and `GET /__wrnexus/cache`.
|
||||
|
||||
Pages and components can opt in declaratively:
|
||||
|
||||
```wrn
|
||||
cache {
|
||||
scope = "page"
|
||||
strategy = "stale-while-revalidate"
|
||||
ttl = "5m"
|
||||
stale = "10m"
|
||||
tags = ["catalog", "marketing"]
|
||||
vary = ["tenant", "language"]
|
||||
}
|
||||
```
|
||||
|
||||
Omit `scope` to cache named loader data. Use `scope = "page"` for full-page
|
||||
caching. Component policies cache their rendered fragment. Authenticated user
|
||||
and tenant identities are always included automatically; page caches also vary
|
||||
by language, theme, and accent. Add header names or `cookie:name` entries for
|
||||
other application-specific variation. Pages containing CSRF forms are never
|
||||
stored in the full-page cache.
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/cache",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"type": "module",
|
||||
"description": "Tag-aware memory and response caching with stale-while-revalidate for WRNexusJS.",
|
||||
"main": "src/index.ts",
|
||||
|
||||
Vendored
+100
@@ -0,0 +1,100 @@
|
||||
import { TagCache, type CacheEvent, type CacheSetOptions, type TagCacheOptions } from "./memory.ts";
|
||||
|
||||
export type CacheLayerName = "data" | "component" | "page";
|
||||
|
||||
export interface CacheInspection {
|
||||
layers: Record<CacheLayerName, ReturnType<TagCache<unknown>["snapshot"]>>;
|
||||
recentEvents: Array<CacheEvent & { layer: CacheLayerName }>;
|
||||
}
|
||||
|
||||
export interface CacheCoordinatorOptions extends Omit<TagCacheOptions, "onEvent"> {
|
||||
eventLimit?: number;
|
||||
onEvent?: (event: CacheEvent & { layer: CacheLayerName }) => void;
|
||||
}
|
||||
|
||||
/** A request-lifetime cache: deduplicates work without leaking values between requests. */
|
||||
export class RequestCache {
|
||||
private pending = new Map<string, Promise<unknown>>();
|
||||
|
||||
getOrLoad<V>(key: string, loader: () => V | Promise<V>): Promise<V> {
|
||||
const existing = this.pending.get(key);
|
||||
if (existing) return existing as Promise<V>;
|
||||
const value = Promise.resolve().then(loader);
|
||||
this.pending.set(key, value);
|
||||
return value as Promise<V>;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/** Owns the three cross-request cache layers and creates isolated request caches. */
|
||||
export class CacheCoordinator {
|
||||
readonly data: TagCache<unknown>;
|
||||
readonly component: TagCache<unknown>;
|
||||
readonly page: TagCache<unknown>;
|
||||
private readonly events: Array<CacheEvent & { layer: CacheLayerName }> = [];
|
||||
private readonly eventLimit: number;
|
||||
|
||||
constructor(options: CacheCoordinatorOptions = {}) {
|
||||
const { eventLimit = 200, onEvent, ...cacheOptions } = options;
|
||||
this.eventLimit = Math.max(1, eventLimit);
|
||||
const create = (layer: CacheLayerName) =>
|
||||
new TagCache<unknown>({
|
||||
...cacheOptions,
|
||||
onEvent: (event) => {
|
||||
const item = { ...event, layer };
|
||||
this.events.push(item);
|
||||
if (this.events.length > this.eventLimit)
|
||||
this.events.splice(0, this.events.length - this.eventLimit);
|
||||
onEvent?.(item);
|
||||
},
|
||||
});
|
||||
this.data = create("data");
|
||||
this.component = create("component");
|
||||
this.page = create("page");
|
||||
}
|
||||
|
||||
request(): RequestCache {
|
||||
return new RequestCache();
|
||||
}
|
||||
|
||||
layer(name: CacheLayerName): TagCache<unknown> {
|
||||
return this[name];
|
||||
}
|
||||
|
||||
getOrLoad<V>(
|
||||
layer: CacheLayerName,
|
||||
key: string,
|
||||
loader: () => V | Promise<V>,
|
||||
options?: CacheSetOptions,
|
||||
): Promise<V> {
|
||||
return this.layer(layer).getOrLoad(key, loader, options) as Promise<V>;
|
||||
}
|
||||
|
||||
invalidateTags(tags: Iterable<string>): number {
|
||||
return (
|
||||
this.data.invalidateTags(tags) +
|
||||
this.component.invalidateTags(tags) +
|
||||
this.page.invalidateTags(tags)
|
||||
);
|
||||
}
|
||||
|
||||
inspect(): CacheInspection {
|
||||
return {
|
||||
layers: {
|
||||
data: this.data.snapshot(),
|
||||
component: this.component.snapshot(),
|
||||
page: this.page.snapshot(),
|
||||
},
|
||||
recentEvents: this.events.map((event) => ({ ...event, tags: event.tags && [...event.tags] })),
|
||||
};
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.data.clear();
|
||||
this.component.clear();
|
||||
this.page.clear();
|
||||
}
|
||||
}
|
||||
Vendored
+94
@@ -0,0 +1,94 @@
|
||||
import type { TagCache } from "./memory.ts";
|
||||
|
||||
export interface CacheInvalidationBus {
|
||||
publish(topic: string, message: unknown): void | Promise<void>;
|
||||
subscribe(pattern: string, handler: (message: unknown) => void | Promise<void>): () => void;
|
||||
}
|
||||
|
||||
export interface DistributedInvalidationOptions {
|
||||
namespace?: string;
|
||||
instanceId?: string;
|
||||
onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
type InvalidationMessage =
|
||||
| { source: string; operation: "tags"; tags: string[] }
|
||||
| { source: string; operation: "key"; key: string }
|
||||
| { source: string; operation: "clear" };
|
||||
|
||||
export interface DistributedInvalidation {
|
||||
invalidateTag(tag: string): Promise<number>;
|
||||
invalidateTags(tags: Iterable<string>): Promise<number>;
|
||||
delete(key: string): Promise<boolean>;
|
||||
clear(): Promise<void>;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Propagate cache invalidations over any structurally compatible pub/sub bus.
|
||||
* The bus is intentionally not closed because applications commonly share it.
|
||||
*/
|
||||
export function connectCacheInvalidation<V>(
|
||||
cache: TagCache<V>,
|
||||
bus: CacheInvalidationBus,
|
||||
options: DistributedInvalidationOptions = {},
|
||||
): DistributedInvalidation {
|
||||
const namespace = options.namespace?.trim() || "default";
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(namespace)) {
|
||||
throw new TypeError("cache invalidation namespace contains unsupported characters");
|
||||
}
|
||||
const source = options.instanceId?.trim() || crypto.randomUUID();
|
||||
const topic = `wrnexus:cache:${namespace}:invalidate`;
|
||||
let closed = false;
|
||||
|
||||
const publish = async (message: InvalidationMessage): Promise<void> => {
|
||||
if (closed) throw new Error("WRN-CACHE-INVALIDATION-CLOSED: invalidation channel is closed");
|
||||
try {
|
||||
await bus.publish(topic, message);
|
||||
} catch (error) {
|
||||
options.onError?.(error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribe = bus.subscribe(topic, (value) => {
|
||||
if (!value || typeof value !== "object") return;
|
||||
const message = value as Partial<InvalidationMessage>;
|
||||
if (message.source === source) return;
|
||||
if (message.operation === "tags" && Array.isArray(message.tags)) {
|
||||
cache.invalidateTags(message.tags.filter((tag): tag is string => typeof tag === "string"));
|
||||
} else if (message.operation === "key" && typeof message.key === "string") {
|
||||
cache.delete(message.key);
|
||||
} else if (message.operation === "clear") {
|
||||
cache.clear();
|
||||
}
|
||||
});
|
||||
|
||||
const invalidateTags = async (input: Iterable<string>): Promise<number> => {
|
||||
const tags = [...new Set(input)].filter(Boolean);
|
||||
const removed = cache.invalidateTags(tags);
|
||||
await publish({ source, operation: "tags", tags });
|
||||
return removed;
|
||||
};
|
||||
|
||||
return {
|
||||
async invalidateTag(tag) {
|
||||
return invalidateTags([tag]);
|
||||
},
|
||||
invalidateTags,
|
||||
async delete(key) {
|
||||
const removed = cache.delete(key);
|
||||
await publish({ source, operation: "key", key });
|
||||
return removed;
|
||||
},
|
||||
async clear() {
|
||||
cache.clear();
|
||||
await publish({ source, operation: "clear" });
|
||||
},
|
||||
close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
unsubscribe();
|
||||
},
|
||||
};
|
||||
}
|
||||
Vendored
+9
@@ -1,4 +1,13 @@
|
||||
export { TagCache } from "./memory.ts";
|
||||
export type { CacheEntry, CacheLookup, CacheSetOptions, TagCacheOptions } from "./memory.ts";
|
||||
export type { CacheEvent, CacheSnapshotEntry } from "./memory.ts";
|
||||
export { CacheCoordinator, RequestCache } from "./coordinator.ts";
|
||||
export type { CacheCoordinatorOptions, CacheInspection, CacheLayerName } from "./coordinator.ts";
|
||||
export { responseCache } from "./response.ts";
|
||||
export type { CachedResponse, ResponseCacheOptions } from "./response.ts";
|
||||
export { connectCacheInvalidation } from "./distributed.ts";
|
||||
export type {
|
||||
CacheInvalidationBus,
|
||||
DistributedInvalidation,
|
||||
DistributedInvalidationOptions,
|
||||
} from "./distributed.ts";
|
||||
|
||||
Vendored
+95
-7
@@ -19,35 +19,66 @@ export interface TagCacheOptions {
|
||||
staleWhileRevalidateMs?: number;
|
||||
maxEntries?: number;
|
||||
clock?: () => number;
|
||||
onEvent?: (event: CacheEvent) => void;
|
||||
}
|
||||
|
||||
export interface CacheEvent {
|
||||
operation: "hit" | "stale" | "miss" | "set" | "delete" | "invalidate" | "clear" | "load";
|
||||
key?: string;
|
||||
tags?: string[];
|
||||
at: number;
|
||||
}
|
||||
|
||||
export interface CacheSnapshotEntry {
|
||||
key: string;
|
||||
state: "fresh" | "stale";
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
staleUntil: number;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export class TagCache<V = unknown> {
|
||||
private entries = new Map<string, CacheEntry<V>>();
|
||||
private tagIndex = new Map<string, Set<string>>();
|
||||
private pending = new Map<string, Promise<V>>();
|
||||
private locks = new Map<string, Promise<void>>();
|
||||
private revisions = new Map<string, number>();
|
||||
private readonly ttlMs: number;
|
||||
private readonly staleMs: number;
|
||||
private readonly maxEntries: number;
|
||||
private readonly clock: () => number;
|
||||
private readonly onEvent?: (event: CacheEvent) => void;
|
||||
|
||||
constructor(options: TagCacheOptions = {}) {
|
||||
this.ttlMs = options.ttlMs ?? 60_000;
|
||||
this.staleMs = options.staleWhileRevalidateMs ?? 0;
|
||||
this.maxEntries = Math.max(1, options.maxEntries ?? 10_000);
|
||||
this.clock = options.clock ?? Date.now;
|
||||
this.onEvent = options.onEvent;
|
||||
}
|
||||
|
||||
private emit(event: Omit<CacheEvent, "at">): void {
|
||||
this.onEvent?.({ ...event, at: this.clock() });
|
||||
}
|
||||
|
||||
lookup(key: string): CacheLookup<V> {
|
||||
const entry = this.entries.get(key);
|
||||
if (!entry) return { state: "miss" };
|
||||
if (!entry) {
|
||||
this.emit({ operation: "miss", key });
|
||||
return { state: "miss" };
|
||||
}
|
||||
const now = this.clock();
|
||||
if (entry.staleUntil <= now) {
|
||||
this.delete(key);
|
||||
this.emit({ operation: "miss", key });
|
||||
return { state: "miss" };
|
||||
}
|
||||
this.entries.delete(key);
|
||||
this.entries.set(key, entry);
|
||||
return { state: entry.expiresAt > now ? "fresh" : "stale", entry };
|
||||
const state = entry.expiresAt > now ? "fresh" : "stale";
|
||||
this.emit({ operation: state === "fresh" ? "hit" : "stale", key, tags: entry.tags });
|
||||
return { state, entry };
|
||||
}
|
||||
|
||||
get(key: string): V | undefined {
|
||||
@@ -56,7 +87,12 @@ export class TagCache<V = unknown> {
|
||||
}
|
||||
|
||||
set(key: string, value: V, options: CacheSetOptions = {}): void {
|
||||
this.delete(key);
|
||||
this.bump(key);
|
||||
this.store(key, value, options);
|
||||
}
|
||||
|
||||
private store(key: string, value: V, options: CacheSetOptions): void {
|
||||
this.removeEntry(key);
|
||||
const now = this.clock();
|
||||
const ttlMs = Math.max(0, options.ttlMs ?? this.ttlMs);
|
||||
const staleMs = Math.max(0, options.staleWhileRevalidateMs ?? this.staleMs);
|
||||
@@ -69,6 +105,7 @@ export class TagCache<V = unknown> {
|
||||
tags,
|
||||
};
|
||||
this.entries.set(key, entry);
|
||||
this.emit({ operation: "set", key, tags });
|
||||
for (const tag of tags) {
|
||||
const keys = this.tagIndex.get(tag) ?? new Set<string>();
|
||||
keys.add(key);
|
||||
@@ -90,10 +127,12 @@ export class TagCache<V = unknown> {
|
||||
if (hit.state === "fresh") return hit.entry.value;
|
||||
if (hit.state === "stale") {
|
||||
if (!this.pending.has(key)) {
|
||||
const revision = this.revision(key);
|
||||
this.emit({ operation: "load", key, tags: options.tags });
|
||||
const refresh = Promise.resolve()
|
||||
.then(loader)
|
||||
.then((value) => {
|
||||
this.set(key, value, options);
|
||||
if (this.revision(key) === revision) this.store(key, value, options);
|
||||
return value;
|
||||
})
|
||||
.finally(() => this.pending.delete(key));
|
||||
@@ -103,10 +142,12 @@ export class TagCache<V = unknown> {
|
||||
}
|
||||
const existing = this.pending.get(key);
|
||||
if (existing) return existing;
|
||||
const revision = this.revision(key);
|
||||
this.emit({ operation: "load", key, tags: options.tags });
|
||||
const pending = Promise.resolve()
|
||||
.then(loader)
|
||||
.then((value) => {
|
||||
this.set(key, value, options);
|
||||
if (this.revision(key) === revision) this.store(key, value, options);
|
||||
return value;
|
||||
})
|
||||
.finally(() => this.pending.delete(key));
|
||||
@@ -114,7 +155,30 @@ export class TagCache<V = unknown> {
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** Serialize arbitrary cache-adjacent work for a key without storing its result. */
|
||||
async withLock<T>(key: string, task: () => T | Promise<T>): Promise<T> {
|
||||
const previous = this.locks.get(key) ?? Promise.resolve();
|
||||
let release!: () => void;
|
||||
const current = new Promise<void>((resolve) => (release = resolve));
|
||||
const queued = previous.then(() => current);
|
||||
this.locks.set(key, queued);
|
||||
await previous;
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
release();
|
||||
if (this.locks.get(key) === queued) this.locks.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
delete(key: string): boolean {
|
||||
const removed = this.removeEntry(key);
|
||||
if (removed || this.pending.has(key)) this.bump(key);
|
||||
if (removed) this.emit({ operation: "delete", key });
|
||||
return removed;
|
||||
}
|
||||
|
||||
private removeEntry(key: string): boolean {
|
||||
const entry = this.entries.get(key);
|
||||
if (!entry) return false;
|
||||
this.entries.delete(key);
|
||||
@@ -129,23 +193,47 @@ export class TagCache<V = unknown> {
|
||||
invalidateTag(tag: string): number {
|
||||
const keys = [...(this.tagIndex.get(tag) ?? [])];
|
||||
for (const key of keys) this.delete(key);
|
||||
this.emit({ operation: "invalidate", tags: [tag] });
|
||||
return keys.length;
|
||||
}
|
||||
|
||||
invalidateTags(tags: Iterable<string>): number {
|
||||
const requested = [...tags];
|
||||
const keys = new Set<string>();
|
||||
for (const tag of tags) for (const key of this.tagIndex.get(tag) ?? []) keys.add(key);
|
||||
for (const tag of requested) for (const key of this.tagIndex.get(tag) ?? []) keys.add(key);
|
||||
for (const key of keys) this.delete(key);
|
||||
this.emit({ operation: "invalidate", tags: requested });
|
||||
return keys.size;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
for (const key of new Set([...this.entries.keys(), ...this.pending.keys()])) this.bump(key);
|
||||
this.entries.clear();
|
||||
this.tagIndex.clear();
|
||||
this.pending.clear();
|
||||
this.emit({ operation: "clear" });
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.entries.size;
|
||||
}
|
||||
|
||||
snapshot(): CacheSnapshotEntry[] {
|
||||
const now = this.clock();
|
||||
return [...this.entries.entries()].map(([key, entry]) => ({
|
||||
key,
|
||||
state: entry.expiresAt > now ? "fresh" : "stale",
|
||||
createdAt: entry.createdAt,
|
||||
expiresAt: entry.expiresAt,
|
||||
staleUntil: entry.staleUntil,
|
||||
tags: [...entry.tags],
|
||||
}));
|
||||
}
|
||||
|
||||
private revision(key: string): number {
|
||||
return this.revisions.get(key) ?? 0;
|
||||
}
|
||||
|
||||
private bump(key: string): void {
|
||||
this.revisions.set(key, this.revision(key) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+104
-1
@@ -1,7 +1,32 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { TagCache, responseCache } from "../src/index.ts";
|
||||
import {
|
||||
CacheCoordinator,
|
||||
connectCacheInvalidation,
|
||||
RequestCache,
|
||||
TagCache,
|
||||
responseCache,
|
||||
} from "../src/index.ts";
|
||||
|
||||
describe("@wrnexus/cache", () => {
|
||||
test("separates request, data, component, and page cache lifetimes", async () => {
|
||||
const coordinator = new CacheCoordinator({ ttlMs: 100, eventLimit: 10 });
|
||||
coordinator.data.set("user:1", { id: 1 }, { tags: ["users"] });
|
||||
coordinator.component.set("avatar:1", "html", { tags: ["users"] });
|
||||
coordinator.page.set("/users", "document", { tags: ["users"] });
|
||||
expect(coordinator.inspect().layers.data[0]?.key).toBe("user:1");
|
||||
expect(coordinator.invalidateTags(["users"])).toBe(3);
|
||||
expect(
|
||||
coordinator.inspect().recentEvents.some((event) => event.operation === "invalidate"),
|
||||
).toBe(true);
|
||||
|
||||
const request = new RequestCache();
|
||||
let calls = 0;
|
||||
const [first, second] = await Promise.all([
|
||||
request.getOrLoad("permissions", async () => ++calls),
|
||||
request.getOrLoad("permissions", async () => ++calls),
|
||||
]);
|
||||
expect([first, second, calls]).toEqual([1, 1, 1]);
|
||||
});
|
||||
test("supports fresh, stale, and tag invalidation", async () => {
|
||||
let now = 0;
|
||||
const cache = new TagCache<number>({ ttlMs: 10, staleWhileRevalidateMs: 10, clock: () => now });
|
||||
@@ -23,6 +48,84 @@ describe("@wrnexus/cache", () => {
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
test("provides exclusive per-key locks", async () => {
|
||||
const cache = new TagCache();
|
||||
const order: string[] = [];
|
||||
let release!: () => void;
|
||||
const first = cache.withLock("catalog", async () => {
|
||||
order.push("first:start");
|
||||
await new Promise<void>((resolve) => (release = resolve));
|
||||
order.push("first:end");
|
||||
});
|
||||
await Promise.resolve();
|
||||
const second = cache.withLock("catalog", () => order.push("second"));
|
||||
await Promise.resolve();
|
||||
expect(order).toEqual(["first:start"]);
|
||||
release();
|
||||
await Promise.all([first, second]);
|
||||
expect(order).toEqual(["first:start", "first:end", "second"]);
|
||||
});
|
||||
|
||||
test("invalidation during a load prevents stale work from repopulating the cache", async () => {
|
||||
const cache = new TagCache<number>();
|
||||
let release!: (value: number) => void;
|
||||
const loading = cache.getOrLoad(
|
||||
"x",
|
||||
() => new Promise<number>((resolve) => (release = resolve)),
|
||||
);
|
||||
await Promise.resolve();
|
||||
cache.clear();
|
||||
release(7);
|
||||
expect(await loading).toBe(7);
|
||||
expect(cache.lookup("x").state).toBe("miss");
|
||||
});
|
||||
|
||||
test("propagates tag, key, and clear invalidations across instances", async () => {
|
||||
const subscriptions = new Map<string, Set<(message: unknown) => void | Promise<void>>>();
|
||||
const bus = {
|
||||
async publish(topic: string, message: unknown) {
|
||||
await Promise.all(
|
||||
[...(subscriptions.get(topic) ?? [])].map((handler) => Promise.resolve(handler(message))),
|
||||
);
|
||||
},
|
||||
subscribe(topic: string, handler: (message: unknown) => void | Promise<void>) {
|
||||
const handlers = subscriptions.get(topic) ?? new Set();
|
||||
handlers.add(handler);
|
||||
subscriptions.set(topic, handlers);
|
||||
return () => handlers.delete(handler);
|
||||
},
|
||||
};
|
||||
const first = new TagCache<number>();
|
||||
const second = new TagCache<number>();
|
||||
const firstChannel = connectCacheInvalidation(first, bus, {
|
||||
namespace: "catalog",
|
||||
instanceId: "one",
|
||||
});
|
||||
const secondChannel = connectCacheInvalidation(second, bus, {
|
||||
namespace: "catalog",
|
||||
instanceId: "two",
|
||||
});
|
||||
for (const cache of [first, second]) cache.set("product:1", 1, { tags: ["products"] });
|
||||
|
||||
expect(await firstChannel.invalidateTag("products")).toBe(1);
|
||||
expect(second.lookup("product:1").state).toBe("miss");
|
||||
first.set("one", 1);
|
||||
second.set("one", 1);
|
||||
await secondChannel.delete("one");
|
||||
expect(first.lookup("one").state).toBe("miss");
|
||||
first.set("all", 1);
|
||||
second.set("all", 1);
|
||||
await firstChannel.clear();
|
||||
expect(second.size).toBe(0);
|
||||
|
||||
firstChannel.close();
|
||||
firstChannel.close();
|
||||
await expect(firstChannel.invalidateTag("products")).rejects.toThrow(
|
||||
"WRN-CACHE-INVALIDATION-CLOSED",
|
||||
);
|
||||
secondChannel.close();
|
||||
});
|
||||
|
||||
test("response middleware does not call next twice for stale entries", async () => {
|
||||
let now = 0;
|
||||
const cache = new TagCache<any>({ ttlMs: 1, staleWhileRevalidateMs: 100, clock: () => now });
|
||||
|
||||
@@ -360,3 +360,14 @@ const engine = createCaptchaEngine({ secret, generators: [wordChallenge] });
|
||||
```
|
||||
|
||||
Applications may also implement `CaptchaStore`, `CaptchaAudioRenderer`, or use `defineCaptchaProvider()` for a completely custom service.
|
||||
|
||||
## Helper and block kit
|
||||
|
||||
The package exports `captchaTokenFrom`, `captchaHeaders`, `captchaFields`, `verifyCaptcha`, `verifyCaptchaOrThrow`, `captchaResultResponse`, and `captchaContext` for consistent server and client integration.
|
||||
|
||||
Enable the CAPTCHA plugin to use the low-level `<Captcha />` challenge plus complete UI-composed blocks:
|
||||
|
||||
- `<CaptchaField />`
|
||||
- `<CaptchaStatus />`
|
||||
|
||||
`CaptchaField` composes `Card` from `@wrnexus/ui` and keeps the CAPTCHA-specific size separate from the surrounding UI size.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
component CaptchaField {
|
||||
props {
|
||||
title: string = "Security verification"
|
||||
description: string = "Complete the challenge before submitting."
|
||||
provider: string = "self-hosted"
|
||||
type: string = "alphanumeric"
|
||||
action: string = "form-submit"
|
||||
color: string = "primary"
|
||||
size: string = "md"
|
||||
captchaSize: string = "normal"
|
||||
class: string = ""
|
||||
}
|
||||
view {
|
||||
<Card {...attrs} title='{title}' description='{description}' color='{color}' size='{size}' class='{class}'>
|
||||
<Captcha provider='{provider}' type='{type}' action='{action}' color='{color}' size='{captchaSize}' compact='{size == "sm"}' />
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
component CaptchaStatus {
|
||||
props {
|
||||
success: boolean = false
|
||||
message: string = ""
|
||||
successMessage: string = "Security verification completed."
|
||||
failureMessage: string = "Security verification is required."
|
||||
color: string = "primary"
|
||||
size: string = "sm"
|
||||
class: string = ""
|
||||
}
|
||||
view {
|
||||
<Alert
|
||||
{...attrs}
|
||||
title='{success ? "Verified" : "Verification required"}'
|
||||
description='{message || (success ? successMessage : failureMessage)}'
|
||||
icon='{success ? "icon-[lucide--shield-check]" : "icon-[lucide--shield-alert]"}'
|
||||
color='{success ? "success" : color}'
|
||||
variant="soft"
|
||||
size='{size}'
|
||||
class='{class}'
|
||||
/>
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/captcha",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"description": "First-class CAPTCHA challenges, providers, verification guards, page gates, and WRNexusJS UI.",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
@@ -43,10 +43,11 @@
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
"@wrnexus/plugin": "workspace:*",
|
||||
"@wrnexus/validation": "workspace:*"
|
||||
"@wrnexus/validation": "workspace:*",
|
||||
"@wrnexus/ui": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/bun": "^1.3.14",
|
||||
"typescript": "^5.9.2",
|
||||
"@wrnexus/syntax": "workspace:*"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { Context } from "@wrnexus/core";
|
||||
import type {
|
||||
CaptchaEngine,
|
||||
CaptchaProvider,
|
||||
CaptchaVerificationResult,
|
||||
VerifyCaptchaInput,
|
||||
} from "./types.ts";
|
||||
import { selfHostedProvider } from "./providers/self-hosted.ts";
|
||||
|
||||
export function captchaTokenFrom(
|
||||
value: Request | Headers | FormData | URLSearchParams | Record<string, unknown>,
|
||||
field = "wrn-captcha-response",
|
||||
): Promise<string | undefined> | string | undefined {
|
||||
if (value instanceof Request) {
|
||||
const header = value.headers.get("x-wrn-captcha-token");
|
||||
if (header) return header;
|
||||
return value
|
||||
.clone()
|
||||
.formData()
|
||||
.then((form) => {
|
||||
const token = form.get(field) ?? form.get("captchaToken") ?? form.get("responseToken");
|
||||
return typeof token === "string" ? token : undefined;
|
||||
})
|
||||
.catch(async () => {
|
||||
try {
|
||||
const body = (await value.clone().json()) as Record<string, unknown>;
|
||||
const token = body[field] ?? body.captchaToken ?? body.responseToken;
|
||||
return token === undefined ? undefined : String(token);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (value instanceof Headers) {
|
||||
return value.get("x-wrn-captcha-token") ?? value.get("x-captcha-token") ?? undefined;
|
||||
}
|
||||
if (value instanceof FormData || value instanceof URLSearchParams) {
|
||||
const token = value.get(field) ?? value.get("captchaToken") ?? value.get("responseToken");
|
||||
return typeof token === "string" ? token : undefined;
|
||||
}
|
||||
const token = value[field] ?? value.captchaToken ?? value.responseToken;
|
||||
return token === undefined ? undefined : String(token);
|
||||
}
|
||||
|
||||
export function captchaHeaders(token: string): HeadersInit {
|
||||
return { "x-wrn-captcha-token": token };
|
||||
}
|
||||
|
||||
export function captchaFields(
|
||||
token: string,
|
||||
field = "wrn-captcha-response",
|
||||
): Record<string, string> {
|
||||
return { [field]: token };
|
||||
}
|
||||
|
||||
export async function verifyCaptcha(
|
||||
providerOrEngine: CaptchaProvider | CaptchaEngine,
|
||||
input: VerifyCaptchaInput,
|
||||
): Promise<CaptchaVerificationResult> {
|
||||
const provider: CaptchaProvider =
|
||||
"client" in providerOrEngine
|
||||
? (providerOrEngine as CaptchaProvider)
|
||||
: selfHostedProvider(providerOrEngine as CaptchaEngine);
|
||||
return provider.verify(input);
|
||||
}
|
||||
|
||||
export async function verifyCaptchaOrThrow(
|
||||
providerOrEngine: CaptchaProvider | CaptchaEngine,
|
||||
input: VerifyCaptchaInput,
|
||||
): Promise<CaptchaVerificationResult> {
|
||||
const result = await verifyCaptcha(providerOrEngine, input);
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`WRN-CAPTCHA-${String(result.code ?? "FAILED").toUpperCase()}: ${result.message ?? "CAPTCHA verification failed"}`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function captchaResultResponse(result: CaptchaVerificationResult): Response {
|
||||
return Response.json(result, {
|
||||
status: result.success ? 200 : 403,
|
||||
headers: { "cache-control": "no-store", "x-content-type-options": "nosniff" },
|
||||
});
|
||||
}
|
||||
|
||||
export function captchaContext(ctx: Context): CaptchaVerificationResult | null {
|
||||
const result = ctx.locals.captcha;
|
||||
return result && typeof result === "object" ? (result as CaptchaVerificationResult) : null;
|
||||
}
|
||||
@@ -12,3 +12,4 @@ export * from "./providers/index.ts";
|
||||
export * from "./challenges/index.ts";
|
||||
export * from "./audio/index.ts";
|
||||
export * from "./crypto.ts";
|
||||
export * from "./helpers.ts";
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { captchaFields, captchaHeaders, captchaTokenFrom } from "../src/index.ts";
|
||||
|
||||
describe("CAPTCHA helper kit", () => {
|
||||
test("reads standard response tokens from forms, headers, and objects", async () => {
|
||||
const form = new FormData();
|
||||
form.set("wrn-captcha-response", "form-token");
|
||||
expect(await captchaTokenFrom(form)).toBe("form-token");
|
||||
expect(await captchaTokenFrom(new Headers({ "x-wrn-captcha-token": "header-token" }))).toBe(
|
||||
"header-token",
|
||||
);
|
||||
expect(await captchaTokenFrom({ "wrn-captcha-response": "object-token" })).toBe("object-token");
|
||||
});
|
||||
|
||||
test("creates consistent fields and headers", () => {
|
||||
expect(captchaFields("token")).toEqual({ "wrn-captcha-response": "token" });
|
||||
expect(new Headers(captchaHeaders("token")).get("x-wrn-captcha-token")).toBe("token");
|
||||
});
|
||||
});
|
||||
+54
-2
@@ -1,5 +1,18 @@
|
||||
# @wrnexus/cli
|
||||
|
||||
Production parity commands:
|
||||
|
||||
```bash
|
||||
wrnexus build .
|
||||
wrnexus preview . --port=3000
|
||||
wrnexus dev . --production-runtime
|
||||
```
|
||||
|
||||
`preview` refuses to start without `dist/server.js` and executes that exact
|
||||
artifact with the production profile. Production-runtime development rebuilds
|
||||
the same minified artifact after app, public, or configuration changes and
|
||||
keeps the last good server running when a rebuild fails.
|
||||
|
||||
> The `wrnexus` command-line tool that scaffolds, runs, builds, tests, and manages WrNexus apps.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
@@ -26,6 +39,30 @@ bunx wrnexus dev
|
||||
|
||||
## Commands
|
||||
|
||||
### Local production services
|
||||
|
||||
`wrnexus dev . --services` starts the application and the bounded local database,
|
||||
cache, mail, SMS, webhook, storage, queue, cron, authentication and metrics simulator.
|
||||
It generates a localhost/`*.localhost` development certificate under
|
||||
`.wrnexus/certificates/` and serves both the application and service console over HTTPS.
|
||||
Trust that certificate locally to remove the browser warning. Use `--services-http` only
|
||||
when an external development proxy already terminates TLS.
|
||||
|
||||
### Exact production runtime with live updates
|
||||
|
||||
`wrnexus dev . --production-runtime` rebuilds and executes `dist/server.js` with
|
||||
production resolution, serialization, caching, headers and assets. The supervisor keeps
|
||||
the last good process when a build fails. On a successful rebuild the opt-in production
|
||||
HMR socket reconnects, requests the new document and morphs it into the browser; ordinary
|
||||
`wrnexus preview` and deployed production servers never include that client.
|
||||
|
||||
### API platform
|
||||
|
||||
`wrnexus api generate [app-dir]` (or `api docs`) derives operations from file routes and
|
||||
emits `generated/api/openapi.json`, safe static documentation, Postman collection, curl examples,
|
||||
and TypeScript, JavaScript, Java, Go and Python SDKs. Generate one client with
|
||||
`wrnexus sdk generate <language> [app-dir]`.
|
||||
|
||||
Every command accepts an optional `[app-dir]` (defaults to `.`). Commands that read config or `.env` also accept `--profile=<name>` (see [Profiles](#profiles)).
|
||||
|
||||
| Command | Purpose |
|
||||
@@ -46,10 +83,17 @@ Every command accepts an optional `[app-dir]` (defaults to `.`). Commands that r
|
||||
| `wrnexus db <cmd>` | Database migrations and tooling (see [db](#wrnexus-db)). |
|
||||
| `wrnexus test [app-dir] [--watch]` | Run the app's tests via `bun test` (defaults to the `test` profile). |
|
||||
| `wrnexus profiles [app-dir]` | List config profiles and their `.env` files, marking the active one. |
|
||||
| `wrnexus compatibility check` | Check whether behavior defaults are explicitly pinned and current. |
|
||||
| `wrnexus compatibility explain` | Explain configured, effective, and current compatibility behavior. |
|
||||
| `wrnexus compatibility upgrade` | Back up config and explicitly opt into reviewed current behavior. |
|
||||
| `wrnexus help` | Print usage. |
|
||||
|
||||
`wrnexus g` is an alias for `wrnexus generate`.
|
||||
|
||||
Compatibility upgrades never happen implicitly. New applications pin
|
||||
`compatibilityDate` and `frameworkBehaviour`; existing applications use
|
||||
`wrnexus compatibility explain` before the backed-up, idempotent upgrade command.
|
||||
|
||||
### `wrnexus dev`
|
||||
|
||||
Supervises a child dev-server process (from `@wrnexus/dev-server`). The child owns file watching and HMR: CSS and client-island edits update the live page over a WebSocket with no restart; when a server module changes, the child exits with a restart code and the supervisor respawns it (the browser reconnects and morphs in the new HTML). On startup it regenerates typed DB queries and typed routes (best effort). Use `--port=` to change the port (default `3000`).
|
||||
@@ -76,7 +120,9 @@ bun dist/server.js # PORT env var optional
|
||||
|
||||
### `wrnexus create`
|
||||
|
||||
Scaffolds a new app from an inline (dependency-free) template — `package.json`, `.gitignore`, config, and starter `app/` files. Use `npm run dev` during development, `npm run build && npm start` for production, or `npm run production` to build and start in one command. The generated production server currently requires Bun even when npm is used to manage packages and scripts.
|
||||
Scaffolds a complete v0.8 app from an inline template. The generated project includes strict TypeScript, ESLint and Prettier, editor recommendations, environment templates, database migrations, locales, schemas, tests, API/middleware/realtime examples, Tailwind and Iconify, PWA/mobile defaults, and the framework package kits. Its `wrnexus.config.ts` documents the current imports, types, stores, performance, observability, tenancy, build, navigation, theme, i18n, database, storage, realtime, security, and profile configuration.
|
||||
|
||||
Use `bun run dev` during development, `bun run check` for the complete typecheck/lint/test/format gate, `bun run build && bun run start` for production, or `bun run production` to build and start in one command.
|
||||
|
||||
### `wrnexus update`
|
||||
|
||||
@@ -162,7 +208,7 @@ wrnexus db status --db=analytics
|
||||
|
||||
### `wrnexus workspace` and `wrnexus gateway`
|
||||
|
||||
`workspace <name>` scaffolds a monorepo: several WrNexus apps under `apps/*` and shared libraries under `packages/*`, plus a `wrnexus.workspace.ts` that maps each app to the domains it serves. `gateway` runs every app behind one port and routes by `Host` header, with optional per-app auth and gateway-wide security (trusted hosts, rate limit, security headers, access log).
|
||||
`workspace <name>` scaffolds a monorepo: complete v0.8 apps under `apps/*`, shared libraries under `packages/*`, root TypeScript/lint/format/editor/environment tooling, and a `wrnexus.workspace.ts` that maps each app to the domains it serves. `gateway` runs every app behind one port and routes by `Host` header, with optional per-app auth and gateway-wide security (trusted hosts, rate limit, security headers, access log). Newly added workspace apps use the same current scaffold.
|
||||
|
||||
```bash
|
||||
wrnexus workspace acme
|
||||
@@ -241,6 +287,12 @@ wrnexus update --latest
|
||||
wrnexus doctor
|
||||
```
|
||||
|
||||
Use `wrnexus doctor --fix [app-dir]` to apply conservative repairs before the
|
||||
health check: create missing `app/pages` and a default config, align skewed
|
||||
`@wrnexus/*` dependency ranges, record the current migration marker, and format
|
||||
only syntax-valid `.wrn` files. Invalid JSON or WRN sources are reported/skipped
|
||||
instead of overwritten; repeat runs are idempotent.
|
||||
|
||||
## Profiles
|
||||
|
||||
Pass `--profile=<name>` to `dev`, `build`, `db` (or set `WRNEXUS_PROFILE`) to select a config profile. The CLI publishes `WRNEXUS_PROFILE` so config loaders and the dev child pick it up, and loads that profile's `.env` cascade (`.env`, `.env.local`, `.env.<profile>`, `.env.<profile>.local`) into `process.env`.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
@@ -20,8 +20,13 @@
|
||||
"@wrnexus/ui": "workspace:*",
|
||||
"@wrnexus/validation": "workspace:*",
|
||||
"@wrnexus/i18n": "workspace:*",
|
||||
"@wrnexus/mcp": "workspace:*",
|
||||
"@wrnexus/playground": "workspace:*",
|
||||
"@wrnexus/db": "workspace:*",
|
||||
"@wrnexus/plugin": "workspace:*",
|
||||
"@wrnexus/syntax": "workspace:*"
|
||||
"@wrnexus/syntax": "workspace:*",
|
||||
"@wrnexus/typecheck": "workspace:*",
|
||||
"@wrnexus/security": "workspace:*",
|
||||
"selfsigned": "^5.5.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,25 @@ interface BuildReport {
|
||||
frameworkVersion: string;
|
||||
generatedAt: string;
|
||||
adapter: string;
|
||||
routes: Array<{ path: string; source: string; sourceBytes: number; dynamicParams: string[] }>;
|
||||
routes: Array<{
|
||||
path: string;
|
||||
source: string;
|
||||
sourceBytes: number;
|
||||
dynamicParams: string[];
|
||||
optimization?: {
|
||||
staticNodes: number;
|
||||
reactiveRegions: number;
|
||||
eliminatedBranches: number;
|
||||
unusedState: string[];
|
||||
unusedHandlers: string[];
|
||||
constantProps: string[];
|
||||
unusedLocalCssClasses: string[];
|
||||
batchableStateUpdates: number;
|
||||
memoizableComponents: string[];
|
||||
preloadDependencies: string[];
|
||||
serverOnlyModules: string[];
|
||||
};
|
||||
}>;
|
||||
assets: Array<{ file: string; bytes: number }>;
|
||||
measurements: Record<string, number>;
|
||||
budgetViolations: Array<{ metric: string; budget: number; actual: number; overBy: number }>;
|
||||
@@ -42,6 +60,21 @@ export function runAnalyze(appRoot: string, args: string[]): boolean {
|
||||
for (const asset of report.assets.slice(0, 12)) {
|
||||
console.log(` ${bytes(asset.bytes).padStart(10)} ${asset.file}`);
|
||||
}
|
||||
console.log("\nCompiler optimizations:");
|
||||
for (const route of report.routes.filter((item) => item.optimization)) {
|
||||
const optimization = route.optimization!;
|
||||
console.log(
|
||||
` ${route.path}: ${optimization.staticNodes} static nodes, ${optimization.reactiveRegions} reactive regions, ${optimization.eliminatedBranches} branches removed`,
|
||||
);
|
||||
if (
|
||||
optimization.unusedState.length ||
|
||||
optimization.unusedHandlers.length ||
|
||||
optimization.unusedLocalCssClasses.length
|
||||
)
|
||||
console.log(
|
||||
` candidates: ${[...optimization.unusedState, ...optimization.unusedHandlers, ...optimization.unusedLocalCssClasses].join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (report.budgetViolations.length) {
|
||||
console.log("\nBudget violations:");
|
||||
for (const violation of report.budgetViolations) {
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { basename, dirname, join, relative, resolve } from "node:path";
|
||||
import { buildRouter } from "@wrnexus/router";
|
||||
|
||||
export type SdkLanguage = "typescript" | "javascript" | "java" | "go" | "python";
|
||||
export interface ApiOperation {
|
||||
id: string;
|
||||
method: string;
|
||||
path: string;
|
||||
source: string;
|
||||
summary?: string;
|
||||
description?: string;
|
||||
webhook?: { event: string; payloadSchema?: string; signatureHeader?: string };
|
||||
}
|
||||
export interface ApiArtifacts {
|
||||
operations: ApiOperation[];
|
||||
files: string[];
|
||||
}
|
||||
|
||||
function methods(file: string): string[] {
|
||||
const source = readFileSync(file, "utf8");
|
||||
const values = [
|
||||
...source.matchAll(
|
||||
/export\s+(?:async\s+)?(?:const|function)\s+(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\b/g,
|
||||
),
|
||||
].map((match) => match[1]!);
|
||||
if (file.endsWith(".wrn"))
|
||||
for (const match of source.matchAll(/\bapi\s+(GET|POST|PUT|PATCH|DELETE)\s+/g))
|
||||
values.push(match[1]!);
|
||||
return [...new Set(values.length ? values : ["GET"])];
|
||||
}
|
||||
function openapiPath(path: string): string {
|
||||
return path
|
||||
.replace(/\[\.\.\.([A-Za-z_$][\w$]*)\]/g, "{$1}")
|
||||
.replace(/\[([A-Za-z_$][\w$]*)\]/g, "{$1}")
|
||||
.replace(/:([A-Za-z_$][\w$]*)/g, "{$1}")
|
||||
.replace(/\*([A-Za-z_$][\w$]*)/g, "{$1}");
|
||||
}
|
||||
function operationId(method: string, path: string): string {
|
||||
const words =
|
||||
`${method.toLowerCase()}-${path.replace(/^\/api\/?/, "").replace(/[^A-Za-z0-9]+/g, "-") || "root"}`
|
||||
.split("-")
|
||||
.filter(Boolean);
|
||||
return (
|
||||
words[0]! +
|
||||
words
|
||||
.slice(1)
|
||||
.map((word) => word[0]!.toUpperCase() + word.slice(1))
|
||||
.join("")
|
||||
);
|
||||
}
|
||||
|
||||
export function inspectApi(appRoot: string): ApiOperation[] {
|
||||
const root = resolve(appRoot);
|
||||
const router = buildRouter(join(root, "app"));
|
||||
return router.api.flatMap((route) => {
|
||||
const sourceText = readFileSync(route.file, "utf8");
|
||||
const field = (name: string) =>
|
||||
new RegExp(`${name}\\s*:\\s*["']([^"']+)["']`).exec(sourceText)?.[1];
|
||||
const webhook = /\b(?:defineWebhook\s*\(|webhook\s*=)\s*\{/.test(sourceText)
|
||||
? {
|
||||
event: field("event") ?? operationId("event", route.raw),
|
||||
payloadSchema: field("payloadSchema"),
|
||||
signatureHeader: field("signatureHeader"),
|
||||
}
|
||||
: undefined;
|
||||
return methods(route.file).map((method) => ({
|
||||
id: operationId(method, route.raw),
|
||||
method,
|
||||
path: openapiPath(route.raw),
|
||||
source: relative(root, route.file).replace(/\\/g, "/"),
|
||||
summary: field("summary"),
|
||||
description: field("description"),
|
||||
webhook,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
export function createOpenApi(operations: ApiOperation[], title = "WRNexus API") {
|
||||
const paths: Record<string, Record<string, unknown>> = {};
|
||||
for (const operation of operations) {
|
||||
const parameters = [...operation.path.matchAll(/\{([^}]+)\}/g)].map((match) => ({
|
||||
name: match[1],
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
}));
|
||||
(paths[operation.path] ??= {})[operation.method.toLowerCase()] = {
|
||||
operationId: operation.id,
|
||||
summary: operation.summary ?? `${operation.method} ${operation.path}`,
|
||||
...(operation.description ? { description: operation.description } : {}),
|
||||
tags: ["API"],
|
||||
parameters,
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Successful response",
|
||||
content: { "application/json": { schema: {} } },
|
||||
},
|
||||
"400": { description: "Invalid request" },
|
||||
"500": { description: "Internal error" },
|
||||
},
|
||||
"x-wrnexus-source": operation.source,
|
||||
};
|
||||
}
|
||||
const webhooks = Object.fromEntries(
|
||||
operations
|
||||
.filter((operation) => operation.webhook)
|
||||
.map((operation) => [
|
||||
operation.webhook!.event,
|
||||
{
|
||||
post: {
|
||||
summary: operation.summary ?? operation.webhook!.event,
|
||||
description: operation.description,
|
||||
parameters: operation.webhook!.signatureHeader
|
||||
? [
|
||||
{
|
||||
name: operation.webhook!.signatureHeader,
|
||||
in: "header",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: operation.webhook!.payloadSchema
|
||||
? { $ref: operation.webhook!.payloadSchema }
|
||||
: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: { "200": { description: "Webhook accepted" } },
|
||||
"x-wrnexus-source": operation.source,
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
return {
|
||||
openapi: "3.1.0",
|
||||
info: { title, version: "0.8.0" },
|
||||
paths,
|
||||
...(Object.keys(webhooks).length ? { webhooks } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function sdk(language: SdkLanguage, operations: ApiOperation[]): string {
|
||||
const route = (operation: ApiOperation) =>
|
||||
operation.path.replace(/\{([^}]+)\}/g, "${encodeURIComponent(params.$1)}");
|
||||
if (language === "typescript" || language === "javascript")
|
||||
return `${language === "typescript" ? "export type RequestOptions = { baseUrl?: string; headers?: HeadersInit };\ntype ApiEnvelope = { data?: unknown; error?: { message?: string } };\n" : ""}const request = async (${language === "typescript" ? "method: string, path: string, body: unknown, options: RequestOptions = {}" : "method, path, body, options = {}"}) => { const response = await globalThis.fetch((options.baseUrl || "") + path, { method, headers: { "content-type": "application/json", ...(options.headers || {}) }, body: body === undefined ? undefined : JSON.stringify(body) }); const value${language === "typescript" ? ": ApiEnvelope" : ""} = await response.json(); if (!response.ok) throw Object.assign(new Error(value?.error?.message || "API request failed"), { status: response.status, body: value }); return value.data ?? value; };\n${operations.map((operation) => `export const ${operation.id} = (params${language === "typescript" ? ": Record<string, string> = {}" : " = {}"}, body${language === "typescript" ? ": unknown" : ""}, options${language === "typescript" ? ": RequestOptions = {}" : " = {}"}) => { ${operation.path.includes("{") ? "" : "void params; "}return request("${operation.method}", \`${route(operation)}\`, body, options); };`).join("\n")}\n`;
|
||||
if (language === "python")
|
||||
return `import json, urllib.request\n\nclass WrnexusApi:\n def __init__(self, base_url): self.base_url = base_url.rstrip('/')\n def request(self, method, path, body=None):\n data = None if body is None else json.dumps(body).encode()\n request = urllib.request.Request(self.base_url + path, data=data, method=method, headers={'content-type':'application/json'})\n with urllib.request.urlopen(request) as response: return json.load(response)\n${operations.map((operation) => ` def ${operation.id}(self, path, body=None): return self.request('${operation.method}', path, body)`).join("\n")}\n`;
|
||||
if (language === "go")
|
||||
return `package wrnexussdk\n\nimport ("bytes"; "encoding/json"; "fmt"; "net/http")\ntype Client struct { BaseURL string; HTTP *http.Client }\nfunc (c *Client) Request(method, path string, body any) (map[string]any, error) { data,_:=json.Marshal(body); req,_:=http.NewRequest(method,c.BaseURL+path,bytes.NewReader(data)); req.Header.Set("content-type","application/json"); client:=c.HTTP;if client==nil{client=http.DefaultClient};res,err:=client.Do(req);if err!=nil{return nil,err};defer res.Body.Close();if res.StatusCode>=400{return nil,fmt.Errorf("API status %d",res.StatusCode)};var out map[string]any;err=json.NewDecoder(res.Body).Decode(&out);return out,err }\n`;
|
||||
return `package dev.wrnexus.sdk;\nimport java.net.URI; import java.net.http.*;\npublic final class WrnexusApi { private final String baseUrl; private final HttpClient http = HttpClient.newHttpClient(); public WrnexusApi(String baseUrl){this.baseUrl=baseUrl;} public String request(String method,String path,String json)throws Exception{var request=HttpRequest.newBuilder(URI.create(baseUrl+path)).header("content-type","application/json").method(method,HttpRequest.BodyPublishers.ofString(json==null?"":json)).build();var response=http.send(request,HttpResponse.BodyHandlers.ofString());if(response.statusCode()>=400)throw new IllegalStateException("API status "+response.statusCode());return response.body();} }\n`;
|
||||
}
|
||||
|
||||
function write(path: string, value: string, files: string[]): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, value);
|
||||
files.push(path);
|
||||
}
|
||||
const escapeHtml = (value: unknown) =>
|
||||
String(value ?? "").replace(
|
||||
/[&<>"']/g,
|
||||
(character) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]!,
|
||||
);
|
||||
export function generateApiArtifacts(
|
||||
appRoot: string,
|
||||
languages: SdkLanguage[] = ["typescript"],
|
||||
): ApiArtifacts {
|
||||
const root = resolve(appRoot);
|
||||
const output = join(root, "generated", "api");
|
||||
const operations = inspectApi(root);
|
||||
const files: string[] = [];
|
||||
const spec = createOpenApi(operations, `${basename(root)} API`);
|
||||
write(join(output, "openapi.json"), JSON.stringify(spec, null, 2) + "\n", files);
|
||||
write(
|
||||
join(output, "postman.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
info: {
|
||||
name: spec.info.title,
|
||||
schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
||||
},
|
||||
item: operations.map((operation) => ({
|
||||
name: operation.id,
|
||||
request: { method: operation.method, url: `{{baseUrl}}${operation.path}` },
|
||||
})),
|
||||
variable: [{ key: "baseUrl", value: "http://localhost:3000" }],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
files,
|
||||
);
|
||||
write(
|
||||
join(output, "index.html"),
|
||||
`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${escapeHtml(spec.info.title)}</title><style>body{font:16px system-ui;max-width:960px;margin:auto;padding:2rem}code,pre{background:#f4f4f5;padding:.2rem .4rem}article{border-bottom:1px solid #ddd;padding:1rem 0}</style><h1>${escapeHtml(spec.info.title)}</h1><p>OpenAPI 3.1 · ${operations.length} operations · <a href="openapi.json">specification</a></p>${operations.map((operation) => `<article><h2><code>${escapeHtml(operation.method)}</code> ${escapeHtml(operation.path)}</h2><p>${escapeHtml(operation.summary ?? operation.id)}</p>${operation.description ? `<p>${escapeHtml(operation.description)}</p>` : ""}${operation.webhook ? `<p>Webhook event: <code>${escapeHtml(operation.webhook.event)}</code>${operation.webhook.signatureHeader ? ` · signature: <code>${escapeHtml(operation.webhook.signatureHeader)}</code>` : ""}</p>` : ""}<small>${escapeHtml(operation.source)}</small></article>`).join("")}`,
|
||||
files,
|
||||
);
|
||||
write(
|
||||
join(output, "examples.md"),
|
||||
`# API examples\n\n${operations.map((operation) => `## ${operation.id}\n\n\`\`\`bash\ncurl -X ${operation.method} "http://localhost:3000${operation.path}"\n\`\`\`\n`).join("\n")}`,
|
||||
files,
|
||||
);
|
||||
const extensions = {
|
||||
typescript: "ts",
|
||||
javascript: "js",
|
||||
java: "java",
|
||||
go: "go",
|
||||
python: "py",
|
||||
} as const;
|
||||
for (const language of languages)
|
||||
write(
|
||||
join(output, "sdk", language, `wrnexus-api.${extensions[language]}`),
|
||||
sdk(language, operations),
|
||||
files,
|
||||
);
|
||||
return { operations, files };
|
||||
}
|
||||
|
||||
export function runApiCommand(appRoot: string, kind: "api" | "sdk", args: string[]): ApiArtifacts {
|
||||
const supported: SdkLanguage[] = ["typescript", "javascript", "java", "go", "python"];
|
||||
const language = args.find((value) => supported.includes(value as SdkLanguage)) as
|
||||
SdkLanguage | undefined;
|
||||
if (kind === "sdk" && !language)
|
||||
throw new Error("WRN-SDK-LANGUAGE: choose typescript, javascript, java, go, or python.");
|
||||
const result = generateApiArtifacts(appRoot, kind === "sdk" ? [language!] : supported);
|
||||
console.log(
|
||||
`✓ Generated ${result.operations.length} API operations and ${result.files.length} artifacts in generated/api`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
+185
-8
@@ -21,15 +21,19 @@ import {
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { basename, extname, join, resolve } from "node:path";
|
||||
import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
||||
import { buildRouter, type Route } from "@wrnexus/router";
|
||||
import { getReactiveRuntime } from "@wrnexus/csr";
|
||||
import {
|
||||
analyzeRuntimeImports,
|
||||
analyzeRuntimeRequirements,
|
||||
assertValidAst,
|
||||
generate,
|
||||
parse,
|
||||
type RuntimeRequirements,
|
||||
type DeploymentRuntime,
|
||||
runtimeCapabilities,
|
||||
resolveWrnImports,
|
||||
} from "@wrnexus/compiler";
|
||||
import {
|
||||
loadAppConfig,
|
||||
@@ -64,6 +68,38 @@ const INLINE_CSS_LIMIT_BYTES = 4096;
|
||||
|
||||
const fwd = (p: string) => p.replace(/\\/g, "/");
|
||||
|
||||
function deploymentRuntime(adapter: string | undefined): DeploymentRuntime | undefined {
|
||||
if (!adapter) return "bun";
|
||||
if (["bun", "node", "edge", "worker", "service-worker", "browser"].includes(adapter))
|
||||
return adapter as DeploymentRuntime;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function validateRuntimeCapabilities(appRoot: string, adapter?: string): void {
|
||||
const runtime = deploymentRuntime(adapter);
|
||||
if (!runtime || runtime === "bun" || runtime === "node") return;
|
||||
const appDir = join(resolve(appRoot), "app");
|
||||
if (!existsSync(appDir)) return;
|
||||
const files: string[] = [];
|
||||
const walk = (directory: string) => {
|
||||
for (const name of readdirSync(directory)) {
|
||||
const file = join(directory, name);
|
||||
const stat = statSync(file);
|
||||
if (stat.isDirectory()) walk(file);
|
||||
else if (/\.(?:[cm]?[jt]s|wrn)$/.test(file)) files.push(file);
|
||||
}
|
||||
};
|
||||
walk(appDir);
|
||||
const diagnostics = files.flatMap((file) =>
|
||||
analyzeRuntimeImports(readFileSync(file, "utf8"), runtime).map(
|
||||
(diagnostic) =>
|
||||
`${fwd(file.slice(resolve(appRoot).length + 1))}: ${diagnostic.code} ${diagnostic.message}`,
|
||||
),
|
||||
);
|
||||
if (diagnostics.length)
|
||||
throw new Error(`Runtime capability validation failed:\n${diagnostics.join("\n")}`);
|
||||
}
|
||||
|
||||
export async function runBuild(appRoot: string): Promise<void> {
|
||||
const root = resolve(appRoot);
|
||||
const appDir = join(root, "app");
|
||||
@@ -72,6 +108,8 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
const reactivePath = join(distDir, "reactive.js");
|
||||
const publicDir = join(root, "public");
|
||||
const distPublicDir = join(distDir, "public");
|
||||
const config = await loadAppConfig(root);
|
||||
validateRuntimeCapabilities(root, config.build?.adapter);
|
||||
|
||||
console.log(`Building ${appDir} -> ${distDir}`);
|
||||
|
||||
@@ -83,11 +121,14 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
console.log(`✓ Public: ${distPublicDir}`);
|
||||
}
|
||||
|
||||
const config = await loadAppConfig(root);
|
||||
const discoveredPlugins = await discoverPlugins(root, config.plugins, {
|
||||
includeDevDependencies: true,
|
||||
strict: true,
|
||||
warn: (message) => console.warn(`[wrnexus:plugin] ${message}`),
|
||||
runtime: deploymentRuntime(config.build?.adapter),
|
||||
capabilities: [...runtimeCapabilities(deploymentRuntime(config.build?.adapter) ?? "bun")],
|
||||
enforcePermissions: config.pluginPermissions?.enforce,
|
||||
grantedPermissions: config.pluginPermissions?.grants,
|
||||
});
|
||||
const pluginRunner = createPluginRunner(discoveredPlugins, {
|
||||
root,
|
||||
@@ -100,8 +141,27 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
await pluginRunner.configure(config as Record<string, unknown>);
|
||||
await pluginRunner.configResolved(config as Readonly<Record<string, unknown>>);
|
||||
const pluginContributions = await pluginRunner.contributions();
|
||||
const { generateApplicationTypes } = await import("./types.ts");
|
||||
generateApplicationTypes(root, pluginContributions);
|
||||
const componentDirs = [uiComponentsDir(), ...pluginContributions.componentDirs];
|
||||
await pluginRunner.hook("buildStart");
|
||||
const virtualModules = new Map<string, string>();
|
||||
for (const [index, module] of pluginContributions.virtualModules.entries()) {
|
||||
const output = join(compiledDir, `virtual-${index}.ts`);
|
||||
writeFileSync(
|
||||
output,
|
||||
await module.load({
|
||||
root,
|
||||
mode: "production",
|
||||
command: "build",
|
||||
profile: process.env.WRNEXUS_PROFILE,
|
||||
metadata: new Map(),
|
||||
warn: (message) => console.warn(`[wrnexus:plugin] ${message}`),
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
virtualModules.set(module.id, output);
|
||||
}
|
||||
|
||||
// `.wrn` route files are compiled once into deterministic intermediate modules.
|
||||
// Plugin AST/code transforms run only when configured, so existing applications
|
||||
@@ -109,12 +169,18 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
let compiledCount = 0;
|
||||
const compiledFiles = 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");
|
||||
let ast = parse(source);
|
||||
assertValidAst(ast, { file, accessibility: true });
|
||||
ast = await pluginRunner.transformAst(ast, file);
|
||||
if (ast.renderMode === "partial-static") partialStaticFiles.add(file);
|
||||
runtimeAnalysis.set(file, analyzeRuntimeRequirements(ast));
|
||||
const pluginDiagnostics = await pluginRunner.diagnostics(ast, file);
|
||||
const errors = pluginDiagnostics.filter((diagnostic) => diagnostic.severity === "error");
|
||||
@@ -126,11 +192,46 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
errors.map((diagnostic) => `[${diagnostic.code}] ${diagnostic.message}`).join("\n"),
|
||||
);
|
||||
}
|
||||
let code = `// compiled from .wrn\n${generate(ast)}`;
|
||||
code = await pluginRunner.transformCode(code, file);
|
||||
const out = join(compiledDir, `route${compiledCount++}.ts`);
|
||||
writeFileSync(out, code, "utf8");
|
||||
compiledFiles.set(file, out);
|
||||
try {
|
||||
let code = `// compiled from .wrn\n${generate(ast)}`;
|
||||
code = await pluginRunner.transformCode(code, 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}`;
|
||||
code = code.replace(
|
||||
new RegExp(`(["'])${sourcePattern}\\1`, "g"),
|
||||
JSON.stringify(specifier),
|
||||
);
|
||||
}
|
||||
const resolvedImports = resolveWrnImports(ast.structuredImports, file, {
|
||||
appRoot: root,
|
||||
mode: config.imports?.mode ?? "compatible",
|
||||
aliases: config.imports?.aliases,
|
||||
});
|
||||
for (const imported of resolvedImports) {
|
||||
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)!;
|
||||
}
|
||||
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),
|
||||
);
|
||||
}
|
||||
writeFileSync(out, code, "utf8");
|
||||
} catch (error) {
|
||||
compiledFiles.delete(file);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const importPathFor = (file: string): string => fwd(compiledFiles.get(file) ?? file);
|
||||
// Regenerate typed DB queries (app/db/queries/*.sql → queries.gen.ts) first, so
|
||||
@@ -235,6 +336,37 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
...router.layouts.map((layout) => layout.file),
|
||||
]);
|
||||
for (const file of wrnFiles) await compileWrn(file);
|
||||
const partialShells = new Map<string, { shell: string; regions: number }>();
|
||||
const partialPages = router.pages.filter((route) => partialStaticFiles.has(route.file));
|
||||
if (partialPages.length > 0) {
|
||||
const { precomputePartialStaticShell } = await import("@wrnexus/dev-server");
|
||||
const moduleCache = new Map<string, Record<string, unknown>>();
|
||||
const loadCompiled = async (file: string): Promise<Record<string, unknown>> => {
|
||||
const existing = moduleCache.get(file);
|
||||
if (existing) return existing;
|
||||
const output = compiledFiles.get(file);
|
||||
if (!output) throw new Error(`WRN-PARTIAL-STATIC-MODULE: ${file} was not compiled`);
|
||||
const loaded = (await import(pathToFileURL(output).href)) as Record<string, unknown>;
|
||||
moduleCache.set(file, loaded);
|
||||
return loaded;
|
||||
};
|
||||
const components = await Promise.all(
|
||||
router.components.map(async (component) => ({
|
||||
name: component.name,
|
||||
mod: await loadCompiled(component.file),
|
||||
})),
|
||||
);
|
||||
for (const route of partialPages) {
|
||||
const result = await precomputePartialStaticShell(await loadCompiled(route.file), components);
|
||||
partialShells.set(route.raw, result);
|
||||
}
|
||||
writeFileSync(
|
||||
join(distDir, "partial-shells.json"),
|
||||
JSON.stringify(Object.fromEntries(partialShells), null, 2) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
console.log(`✓ Partial shells: ${partialShells.size} build-time route shell(s)`);
|
||||
}
|
||||
const assetHash = createHash("sha256");
|
||||
const emittedPluginAssets = await emitPluginAssets(
|
||||
pluginContributions,
|
||||
@@ -339,7 +471,8 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
const parts = routes.map((r) => {
|
||||
const v = `m${counter++}`;
|
||||
imports.push(`import * as ${v} from ${JSON.stringify(importPathFor(r.file))};`);
|
||||
return ` { raw: ${JSON.stringify(r.raw)}, mod: ${v} },`;
|
||||
const partial = partialShells.get(r.raw);
|
||||
return ` { raw: ${JSON.stringify(r.raw)}, mod: ${v}${partial ? `, staticShell: ${JSON.stringify(partial.shell)}` : ""} },`;
|
||||
});
|
||||
return parts.length ? `\n${parts.join("\n")}\n ` : "";
|
||||
};
|
||||
@@ -428,6 +561,7 @@ await createProductionServer(
|
||||
observability: ${JSON.stringify(config.observability ?? {})},
|
||||
tenancy: ${JSON.stringify(config.tenancy ?? {})},
|
||||
navigation: ${JSON.stringify(config.navigation ?? {})},
|
||||
developmentRuntime: process.env.WRNEXUS_PRODUCTION_DEV === "1",
|
||||
},
|
||||
);
|
||||
`;
|
||||
@@ -485,12 +619,32 @@ await createProductionServer(
|
||||
source: migration.entry ?? "inline",
|
||||
}));
|
||||
report.componentDirs = componentDirs.map(fwd);
|
||||
report.partialStaticShells = [...partialShells].map(([route, value]) => ({
|
||||
route,
|
||||
regions: value.regions,
|
||||
bytes: Buffer.byteLength(value.shell, "utf8"),
|
||||
}));
|
||||
const violations = checkPerformanceBudgets(
|
||||
config.performance?.budgets ?? {},
|
||||
report.measurements,
|
||||
);
|
||||
report.budgetViolations = violations;
|
||||
writeFileSync(join(distDir, "build-report.json"), JSON.stringify(report, null, 2) + "\n", "utf8");
|
||||
const contributedAdapter = pluginContributions.deploymentAdapters.find(
|
||||
(adapter) => adapter.name === config.build?.adapter,
|
||||
);
|
||||
if (contributedAdapter)
|
||||
await contributedAdapter.build(
|
||||
{ distDir, report },
|
||||
{
|
||||
root,
|
||||
mode: "production",
|
||||
command: "build",
|
||||
profile: process.env.WRNEXUS_PROFILE,
|
||||
metadata: new Map(),
|
||||
warn: (message) => console.warn(`[wrnexus:plugin] ${message}`),
|
||||
},
|
||||
);
|
||||
await pluginRunner.hook("buildEnd", report);
|
||||
|
||||
console.log(`✓ Server: ${join(distDir, "server.js")}`);
|
||||
@@ -636,6 +790,7 @@ interface BuildReport {
|
||||
clientRuntimes?: Array<{ id: string; publicPath: string; type: string; load: string }>;
|
||||
migrations?: Array<{ id: string; database: string; source: string }>;
|
||||
componentDirs?: string[];
|
||||
partialStaticShells?: Array<{ route: string; regions: number; bytes: number }>;
|
||||
generatedAt: string;
|
||||
root: string;
|
||||
adapter: string;
|
||||
@@ -650,6 +805,10 @@ interface BuildReport {
|
||||
needsClientRuntime: boolean;
|
||||
needsServerRuntime: boolean;
|
||||
hydrationStrategy: string | null;
|
||||
reasons: string[];
|
||||
optimization: RuntimeRequirements["optimization"];
|
||||
cachePolicy: Record<string, string>;
|
||||
requiredPermission: string | null;
|
||||
}>;
|
||||
assets: Array<{ file: string; bytes: number }>;
|
||||
measurements: { routeJsBytes: number; routeCssBytes: number; imageBytes: number };
|
||||
@@ -709,6 +868,24 @@ function createBuildReport(input: {
|
||||
needsClientRuntime: input.runtimeAnalysis.get(route.file)?.needsClientRuntime ?? true,
|
||||
needsServerRuntime: input.runtimeAnalysis.get(route.file)?.needsServerRuntime ?? true,
|
||||
hydrationStrategy: input.runtimeAnalysis.get(route.file)?.hydrationStrategy ?? null,
|
||||
reasons: input.runtimeAnalysis.get(route.file)?.reasons ?? [
|
||||
"runtime requirements unavailable",
|
||||
],
|
||||
optimization: input.runtimeAnalysis.get(route.file)?.optimization ?? {
|
||||
staticNodes: 0,
|
||||
reactiveRegions: 0,
|
||||
eliminatedBranches: 0,
|
||||
unusedState: [],
|
||||
unusedHandlers: [],
|
||||
constantProps: [],
|
||||
unusedLocalCssClasses: [],
|
||||
batchableStateUpdates: 0,
|
||||
memoizableComponents: [],
|
||||
preloadDependencies: [],
|
||||
serverOnlyModules: [],
|
||||
},
|
||||
cachePolicy: input.runtimeAnalysis.get(route.file)?.cachePolicy ?? {},
|
||||
requiredPermission: input.runtimeAnalysis.get(route.file)?.requiredPermission ?? null,
|
||||
})),
|
||||
assets,
|
||||
measurements: {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import {
|
||||
CURRENT_COMPATIBILITY_DATE,
|
||||
CURRENT_FRAMEWORK_BEHAVIOUR,
|
||||
loadRawConfig,
|
||||
resolveCompatibility,
|
||||
} from "@wrnexus/styles";
|
||||
|
||||
const CONFIG_NAMES = ["wrnexus.config.ts", "wrnexus.config.js", "wrnexus.config.mjs"];
|
||||
|
||||
function configPath(root: string): string | undefined {
|
||||
return CONFIG_NAMES.map((name) => join(root, name)).find(existsSync);
|
||||
}
|
||||
|
||||
export async function compatibilityReport(appRoot: string) {
|
||||
return resolveCompatibility(await loadRawConfig(resolve(appRoot)));
|
||||
}
|
||||
|
||||
export function upgradeCompatibility(appRoot: string): {
|
||||
file: string;
|
||||
backup: string;
|
||||
changed: boolean;
|
||||
} {
|
||||
const root = resolve(appRoot);
|
||||
const file = configPath(root);
|
||||
if (!file) throw new Error("WRN-COMPATIBILITY-NO-CONFIG: wrnexus.config.ts was not found.");
|
||||
const source = readFileSync(file, "utf8");
|
||||
let updated = source;
|
||||
const replace = (name: string, value: string) => {
|
||||
const pattern = new RegExp(`(^\\s*${name}\\s*:\\s*)(?:["'][^"']*["']|\\d+)(\\s*,?)`, "m");
|
||||
if (pattern.test(updated)) updated = updated.replace(pattern, `$1${value}$2`);
|
||||
else {
|
||||
const object = /(?:const\s+config[^=]*=|defineConfig\s*\(|export\s+default)\s*\{/m;
|
||||
if (!object.test(updated))
|
||||
throw new Error("WRN-COMPATIBILITY-CONFIG-SHAPE: unable to locate the root config object.");
|
||||
updated = updated.replace(object, (match) => `${match}\n ${name}: ${value},`);
|
||||
}
|
||||
};
|
||||
replace("compatibilityDate", JSON.stringify(CURRENT_COMPATIBILITY_DATE));
|
||||
replace("frameworkBehaviour", String(CURRENT_FRAMEWORK_BEHAVIOUR));
|
||||
if (updated === source) return { file, backup: "", changed: false };
|
||||
const directory = join(root, ".wrnexus", "compatibility-backups");
|
||||
mkdirSync(directory, { recursive: true });
|
||||
const backup = join(directory, `${Date.now()}-${basename(file)}`);
|
||||
copyFileSync(file, backup);
|
||||
writeFileSync(file, updated, "utf8");
|
||||
return { file, backup, changed: true };
|
||||
}
|
||||
|
||||
export async function runCompatibilityCommand(
|
||||
appRoot: string,
|
||||
command = "check",
|
||||
args: string[] = [],
|
||||
): Promise<boolean> {
|
||||
if (command === "upgrade") {
|
||||
const result = upgradeCompatibility(appRoot);
|
||||
if (args.includes("--json")) console.log(JSON.stringify(result, null, 2));
|
||||
else
|
||||
console.log(
|
||||
result.changed
|
||||
? `✓ Compatibility policy upgraded\n backup: ${result.backup}`
|
||||
: "✓ Compatibility policy already current",
|
||||
);
|
||||
}
|
||||
const report = await compatibilityReport(appRoot);
|
||||
if (args.includes("--json")) console.log(JSON.stringify(report, null, 2));
|
||||
else {
|
||||
console.log(`Compatibility date: ${report.effectiveDate} (current ${report.currentDate})`);
|
||||
console.log(
|
||||
`Framework behaviour: ${report.effectiveBehaviour} (current ${report.currentBehaviour})`,
|
||||
);
|
||||
for (const message of report.messages) console.log(`- ${message}`);
|
||||
}
|
||||
return !report.needsUpgrade && !report.future;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import {
|
||||
ContractRegistry,
|
||||
checkContractCompatibility,
|
||||
type ContractSnapshot,
|
||||
} from "@wrnexus/validation";
|
||||
|
||||
const CURRENT_FILE = "wrnexus.contracts.json";
|
||||
const BASELINE_FILE = join(".wrnexus", "contracts.json");
|
||||
|
||||
function validateSnapshot(value: unknown, source: string): ContractSnapshot {
|
||||
const snapshot = value as Partial<ContractSnapshot>;
|
||||
if (snapshot?.format !== 1 || !Array.isArray(snapshot.contracts)) {
|
||||
throw new Error(`WRN-CONTRACT-FORMAT: ${source} is not a version 1 contract snapshot.`);
|
||||
}
|
||||
return snapshot as ContractSnapshot;
|
||||
}
|
||||
|
||||
async function currentSnapshot(appRoot: string): Promise<ContractSnapshot> {
|
||||
const modulePath = join(appRoot, "app", "contracts.ts");
|
||||
if (existsSync(modulePath)) {
|
||||
const imported = (await import(`${modulePath}?t=${Date.now()}`)) as {
|
||||
default?: ContractRegistry | ContractSnapshot;
|
||||
contracts?: ContractRegistry | ContractSnapshot;
|
||||
};
|
||||
const value = imported.contracts ?? imported.default;
|
||||
if (value instanceof ContractRegistry) return value.snapshot();
|
||||
return validateSnapshot(value, modulePath);
|
||||
}
|
||||
const jsonPath = join(appRoot, CURRENT_FILE);
|
||||
if (!existsSync(jsonPath)) {
|
||||
throw new Error(
|
||||
`WRN-CONTRACT-SOURCE: create app/contracts.ts exporting a ContractRegistry, or ${CURRENT_FILE}.`,
|
||||
);
|
||||
}
|
||||
return validateSnapshot(JSON.parse(await readFile(jsonPath, "utf8")), jsonPath);
|
||||
}
|
||||
|
||||
export interface ContractCommandResult {
|
||||
ok: boolean;
|
||||
issueCount: number;
|
||||
baseline: string;
|
||||
}
|
||||
|
||||
export async function runContractsCommand(
|
||||
root: string,
|
||||
command = "check",
|
||||
args: string[] = [],
|
||||
): Promise<ContractCommandResult> {
|
||||
const appRoot = resolve(root);
|
||||
const baseline = join(appRoot, BASELINE_FILE);
|
||||
const current = await currentSnapshot(appRoot);
|
||||
if (command === "snapshot") {
|
||||
await mkdir(dirname(baseline), { recursive: true });
|
||||
await writeFile(baseline, `${JSON.stringify(current, null, 2)}\n`, "utf8");
|
||||
if (args.includes("--json")) console.log(JSON.stringify({ ok: true, baseline }));
|
||||
else console.log(`✓ Contract baseline written: ${baseline}`);
|
||||
return { ok: true, issueCount: 0, baseline };
|
||||
}
|
||||
if (command !== "check") throw new Error(`WRN-CONTRACT-COMMAND: unknown command '${command}'.`);
|
||||
if (!existsSync(baseline)) {
|
||||
throw new Error(`WRN-CONTRACT-BASELINE: no baseline found; run 'wrnexus contracts snapshot'.`);
|
||||
}
|
||||
const previous = validateSnapshot(JSON.parse(await readFile(baseline, "utf8")), baseline);
|
||||
const issues = checkContractCompatibility(previous, current);
|
||||
if (args.includes("--json")) {
|
||||
console.log(JSON.stringify({ ok: issues.length === 0, issues, baseline }, null, 2));
|
||||
} else if (issues.length === 0) {
|
||||
console.log(`✓ ${current.contracts.length} contracts are backward compatible.`);
|
||||
} else {
|
||||
console.error(`Breaking contract changes detected (${issues.length}):`);
|
||||
for (const issue of issues) {
|
||||
console.error(` ${issue.code} ${issue.contract}: ${issue.message}`);
|
||||
if (issue.consumers.length) console.error(` Consumers: ${issue.consumers.join(", ")}`);
|
||||
}
|
||||
}
|
||||
return { ok: issues.length === 0, issueCount: issues.length, baseline };
|
||||
}
|
||||
+178
-12
@@ -30,6 +30,7 @@ coverage/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.*.example
|
||||
|
||||
# Logs and runtime files
|
||||
*.log
|
||||
@@ -43,6 +44,7 @@ logs/
|
||||
*.db-wal
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
uploads/
|
||||
|
||||
# Generated native projects
|
||||
mobile/android/
|
||||
@@ -77,20 +79,41 @@ Thumbs.db
|
||||
"build": "wrnexus build .",
|
||||
"start": "bun dist/server.js",
|
||||
"production": "bun run build && bun run start",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "wrnexus test .",
|
||||
"test:watch": "wrnexus test . --watch",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier . --write",
|
||||
"format:check": "prettier . --check",
|
||||
"check": "bun run lint && bun run format:check"
|
||||
"doctor": "wrnexus doctor .",
|
||||
"analyze": "wrnexus analyze .",
|
||||
"inspect": "wrnexus inspect packages .",
|
||||
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/ai": "${frameworkVersion}",
|
||||
"@wrnexus/auth": "${frameworkVersion}",
|
||||
"@wrnexus/captcha": "${frameworkVersion}",
|
||||
"@wrnexus/core": "${frameworkVersion}",
|
||||
"@wrnexus/csr": "${frameworkVersion}",
|
||||
"@wrnexus/db": "${frameworkVersion}",
|
||||
"@wrnexus/dev-server": "${frameworkVersion}",
|
||||
"@wrnexus/encryption": "${frameworkVersion}",
|
||||
"@wrnexus/helpers": "${frameworkVersion}",
|
||||
"@wrnexus/i18n": "${frameworkVersion}",
|
||||
"@wrnexus/image": "${frameworkVersion}",
|
||||
"@wrnexus/jwt": "${frameworkVersion}",
|
||||
"@wrnexus/observability": "${frameworkVersion}",
|
||||
"@wrnexus/realtime": "${frameworkVersion}",
|
||||
"@wrnexus/security": "${frameworkVersion}",
|
||||
"@wrnexus/store": "${frameworkVersion}",
|
||||
"@wrnexus/styles": "${frameworkVersion}",
|
||||
"@wrnexus/tracking": "${frameworkVersion}",
|
||||
"@wrnexus/ui": "${frameworkVersion}",
|
||||
"@wrnexus/uploader": "${frameworkVersion}",
|
||||
"@wrnexus/validation": "${frameworkVersion}",
|
||||
"@wrnexus/db": "${frameworkVersion}"
|
||||
"@wrnexus/authz": "${frameworkVersion}"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@wrnexus/cli": "${frameworkVersion}",
|
||||
@@ -124,9 +147,22 @@ Thumbs.db
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "@wrnexus/core"
|
||||
},
|
||||
"include": ["app", "wrnexus.config.ts"],
|
||||
"include": ["app", "test", "wrnexus.config.ts"],
|
||||
"exclude": ["node_modules", "dist", "**/dist", "**/.wrnexus"]
|
||||
}
|
||||
`,
|
||||
".env.example": `# Copy to .env for local development. Never commit real secrets.
|
||||
WRNEXUS_PUBLIC_ORIGIN=http://localhost:3000
|
||||
DATABASE_URL=file:./dev.db
|
||||
REDIS_URL=redis://localhost:6379
|
||||
AUTH_SECRET=replace-with-at-least-32-random-characters
|
||||
ENCRYPTION_KEY=replace-with-a-base64-encoded-32-byte-key
|
||||
ANTHROPIC_API_KEY=
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||
`,
|
||||
".env.test.example": `WRNEXUS_PUBLIC_ORIGIN=http://localhost:3000
|
||||
DATABASE_URL=file:./test.db
|
||||
AUTH_SECRET=test-only-secret-replace-outside-tests
|
||||
`,
|
||||
"eslint.config.js": `import { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -220,6 +256,56 @@ trim_trailing_whitespace = true
|
||||
"wrnexus.config.ts": `import type { AppConfig } from "@wrnexus/styles";
|
||||
|
||||
const config: AppConfig = {
|
||||
compatibilityDate: "2026-08-02",
|
||||
frameworkBehaviour: 1,
|
||||
// v0.8 defaults: explicit imports, strict template types, safe stores, and
|
||||
// automatic progressive navigation. Package plugins are discovered from the
|
||||
// installed packages above; add custom plugins to this array when needed.
|
||||
plugins: [],
|
||||
imports: { mode: "explicit", autoImport: true, aliases: { "@": "./app" } },
|
||||
types: {
|
||||
strict: true,
|
||||
noImplicitAny: true,
|
||||
strictNullChecks: true,
|
||||
checkTemplates: true,
|
||||
checkComponentProps: true,
|
||||
generateDeclarations: true,
|
||||
},
|
||||
functions: { legacyDefaultRuntime: "current" },
|
||||
stores: { strictMutations: true, persistence: true },
|
||||
compatibility: {
|
||||
legacyEmit: false,
|
||||
legacyEventProps: false,
|
||||
legacyComponentDiscovery: false,
|
||||
stringLayouts: false,
|
||||
},
|
||||
experimental: {},
|
||||
|
||||
performance: {
|
||||
enforcement: "warn",
|
||||
analyze: true,
|
||||
budgets: {
|
||||
routeJsBytes: 50 * 1024,
|
||||
routeCssBytes: 25 * 1024,
|
||||
lcpMs: 2_500,
|
||||
inpMs: 200,
|
||||
cls: 0.1,
|
||||
},
|
||||
},
|
||||
observability: {
|
||||
enabled: true,
|
||||
serviceName: "APP_SLUG",
|
||||
serverTiming: true,
|
||||
sampleRate: 1,
|
||||
exporter: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ? "otlp" : "none",
|
||||
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||
webVitals: true,
|
||||
},
|
||||
tenancy: { mode: "domain", required: false, rootDomains: ["localhost"] },
|
||||
build: { cache: true, sourceMaps: true, report: true, adapter: "bun" },
|
||||
navigation: { mode: "auto" },
|
||||
devToolbar: { enabled: true, position: "bottom-center", openEditor: true },
|
||||
|
||||
mobile: {
|
||||
enabled: true,
|
||||
appId: "com.example.APP_SLUG",
|
||||
@@ -272,18 +358,77 @@ const config: AppConfig = {
|
||||
// // Or self-host (fastest, no third party) — drop files in public/fonts/:
|
||||
// // local: [{ family: "Inter", src: "/fonts/inter.woff2", weight: "100 900", preload: true }],
|
||||
|
||||
// security: {
|
||||
// cors: {
|
||||
// enabled: true,
|
||||
// origin: ["http://localhost:5173"],
|
||||
// },
|
||||
// },
|
||||
theme: { palette: "violet", default: "light" },
|
||||
i18n: { default: "en", locales: ["en"] },
|
||||
db: { driver: "sqlite", url: process.env.DATABASE_URL ?? "file:./dev.db" },
|
||||
databases: {},
|
||||
storage: {
|
||||
default: "public",
|
||||
stores: {
|
||||
public: {
|
||||
driver: "local",
|
||||
access: "public",
|
||||
dir: "uploads/public",
|
||||
maxBytes: 10_000_000,
|
||||
accept: ["image/*", "application/pdf"],
|
||||
},
|
||||
private: {
|
||||
driver: "local",
|
||||
access: "private",
|
||||
dir: "uploads/private",
|
||||
maxBytes: 10_000_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
realtime: { scale: Boolean(process.env.REDIS_URL), redisUrl: process.env.REDIS_URL },
|
||||
port: Number(process.env.PORT ?? 3000),
|
||||
security: {
|
||||
cors: { enabled: false },
|
||||
},
|
||||
profiles: {
|
||||
development: {},
|
||||
test: {
|
||||
db: { driver: "sqlite", url: "file:./test.db" },
|
||||
observability: { exporter: "none", sampleRate: 0 },
|
||||
},
|
||||
staging: {
|
||||
seo: { robots: "noindex,nofollow" },
|
||||
performance: { enforcement: "error" },
|
||||
build: { sourceMaps: true, report: true },
|
||||
},
|
||||
production: {
|
||||
seo: { canonicalBase: process.env.WRNEXUS_PUBLIC_ORIGIN },
|
||||
performance: { enforcement: "error" },
|
||||
build: { sourceMaps: false, report: true },
|
||||
devToolbar: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
`,
|
||||
"public/robots.txt": `User-agent: *
|
||||
Allow: /
|
||||
`,
|
||||
"app/locales/en.json": `{
|
||||
"common": {
|
||||
"appName": "APP_NAME",
|
||||
"welcome": "Welcome to APP_NAME"
|
||||
}
|
||||
}
|
||||
`,
|
||||
"app/db/migrations/0001_init.sql": `-- Create application tables here.
|
||||
-- Run with: bunx wrnexus db migrate
|
||||
`,
|
||||
"app/db/seed.ts": `// Add deterministic development seed data here.
|
||||
export async function seed(): Promise<void> {}
|
||||
`,
|
||||
"app/schemas/contact.ts": `import { v } from "@wrnexus/validation";
|
||||
|
||||
export const contactSchema = v.object({
|
||||
email: v.string().email(),
|
||||
message: v.string().min(10).max(2_000),
|
||||
});
|
||||
`,
|
||||
"app/styles/global.css": `/*
|
||||
* Global stylesheet. Tailwind v4 is compiled by the styles.process hook in
|
||||
@@ -441,10 +586,11 @@ component Counter {
|
||||
"app/api/ai.ts": `// POST /api/ai { "prompt": "..." } → Claude's reply.
|
||||
// Set ANTHROPIC_API_KEY in your environment (e.g. a .env file) to enable this.
|
||||
import { createAI } from "@wrnexus/ai";
|
||||
import type { Context } from "@wrnexus/core";
|
||||
|
||||
const ai = createAI(); // reads ANTHROPIC_API_KEY; defaults to claude-opus-4-8
|
||||
|
||||
export const POST = async (ctx) => {
|
||||
export const POST = async (ctx: Context) => {
|
||||
if (!process.env.ANTHROPIC_API_KEY) {
|
||||
return Response.json({ error: "Set ANTHROPIC_API_KEY to use AI." }, { status: 501 });
|
||||
}
|
||||
@@ -455,10 +601,14 @@ export const POST = async (ctx) => {
|
||||
return ai.streamResponse(prompt);
|
||||
};
|
||||
`,
|
||||
"app/middleware/logger.ts": `export default async function logger(ctx, next) {
|
||||
"app/middleware/logger.ts": `import type { Middleware } from "@wrnexus/core";
|
||||
|
||||
const logger: Middleware = async (ctx, next) => {
|
||||
console.log(ctx.req.method, ctx.url.pathname);
|
||||
return next();
|
||||
}
|
||||
};
|
||||
|
||||
export default logger;
|
||||
`,
|
||||
"app/realtime/chat.ts": `// ws://<host>/realtime/chat — a simple broadcast room.
|
||||
//
|
||||
@@ -480,6 +630,22 @@ export default defineRoom({
|
||||
client.room.broadcast({ type: "message", data: msg });
|
||||
},
|
||||
});
|
||||
`,
|
||||
"test/smoke.test.ts": `import { expect, test } from "bun:test";
|
||||
import { parseOrThrow } from "@wrnexus/validation";
|
||||
import { contactSchema } from "../app/schemas/contact.ts";
|
||||
|
||||
test("starter validation schema accepts a contact request", () => {
|
||||
expect(
|
||||
parseOrThrow(contactSchema, {
|
||||
email: "hello@example.com",
|
||||
message: "Hello from the generated application.",
|
||||
}),
|
||||
).toEqual({
|
||||
email: "hello@example.com",
|
||||
message: "Hello from the generated application.",
|
||||
});
|
||||
});
|
||||
`,
|
||||
};
|
||||
|
||||
|
||||
+30
-1
@@ -20,6 +20,8 @@ import { pathToFileURL } from "node:url";
|
||||
import { loadAppConfig, type AppConfig } from "@wrnexus/styles";
|
||||
import {
|
||||
generateQueriesFile,
|
||||
analyzeMigrations,
|
||||
loadMigrations,
|
||||
migrate,
|
||||
parseQueries,
|
||||
rollback,
|
||||
@@ -125,6 +127,18 @@ export async function runDbCommand(
|
||||
const dbBase = dbBaseOf(appDir, dbName);
|
||||
const migrationsDir = join(dbBase, "migrations");
|
||||
const label = dbName ? ` (db: ${dbName})` : "";
|
||||
const safetyIssues = analyzeMigrations(loadMigrations(migrationsDir));
|
||||
|
||||
if (sub === "check") {
|
||||
if (!safetyIssues.length) console.log(`✓ Migration rollout safety check passed${label}.`);
|
||||
for (const issue of safetyIssues) {
|
||||
console[issue.severity === "error" ? "error" : "warn"](
|
||||
`${issue.code} ${issue.migration}: ${issue.statement}\n ${issue.recommendation}`,
|
||||
);
|
||||
}
|
||||
if (safetyIssues.some((issue) => issue.severity === "error")) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (dbName && !config.databases?.[dbName]) {
|
||||
console.error(
|
||||
@@ -213,6 +227,21 @@ export async function runDbCommand(
|
||||
break;
|
||||
}
|
||||
case "migrate": {
|
||||
const pending = new Set(
|
||||
(await status(db, migrationsDir))
|
||||
.filter((migration) => !migration.applied)
|
||||
.map((migration) => migration.name),
|
||||
);
|
||||
const pendingIssues = safetyIssues.filter((issue) => pending.has(issue.migration));
|
||||
const blockers = pendingIssues.filter((issue) => issue.severity === "error");
|
||||
for (const issue of pendingIssues.filter((item) => item.severity === "warning")) {
|
||||
console.warn(`${issue.code} ${issue.migration}: ${issue.recommendation}`);
|
||||
}
|
||||
if (blockers.length && !args.includes("--allow-breaking")) {
|
||||
throw new Error(
|
||||
`WRN-DB-UNSAFE-MIGRATION: ${blockers.length} breaking rollout operation(s) found. Run 'wrnexus db check' and use an expand/contract migration; --allow-breaking explicitly overrides this gate.`,
|
||||
);
|
||||
}
|
||||
const applied = await migrate(db, migrationsDir);
|
||||
console.log(
|
||||
applied.length
|
||||
@@ -234,7 +263,7 @@ export async function runDbCommand(
|
||||
}
|
||||
default:
|
||||
console.error(
|
||||
"Usage: wrnexus db <migrate|rollback|status|generate|seed|studio [table]|new [name] [--from-models]> [--db=<name>]",
|
||||
"Usage: wrnexus db <check|migrate|rollback|status|generate|seed|studio [table]|new [name] [--from-models]> [--db=<name>]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { generateDocker } from "./docker.ts";
|
||||
|
||||
export const DEPLOY_TARGETS = [
|
||||
"docker",
|
||||
"kubernetes",
|
||||
"systemd",
|
||||
"railway",
|
||||
"render",
|
||||
"fly",
|
||||
] as const;
|
||||
export type DeployTarget = (typeof DEPLOY_TARGETS)[number];
|
||||
|
||||
const ENVIRONMENT = `# Copy to .env.production and replace every required value.
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
HOST=0.0.0.0
|
||||
DATABASE_URL=postgres://USER:PASSWORD@HOST:5432/DB
|
||||
SESSION_SECRET=REPLACE_WITH_AT_LEAST_32_RANDOM_BYTES
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT=https://collector.example.com
|
||||
`;
|
||||
|
||||
const OPERATIONS = `# WRNexus deployment operations
|
||||
|
||||
- Liveness: \`GET /healthz\`
|
||||
- Readiness: \`GET /readyz\` (includes registered dependency checks)
|
||||
- Migrations: run \`bunx wrnexus db migrate --profile=production\` once per release before scaling.
|
||||
- Shutdown: the Bun production server drains on SIGTERM/SIGINT.
|
||||
- Assets: \`dist/public\` files are content-addressed and may be cached immutably by a CDN.
|
||||
- Secrets: provide \`DATABASE_URL\` and \`SESSION_SECRET\` through the platform secret store; never commit production env files.
|
||||
- Logs: stdout/stderr are structured for platform collection. Configure OTLP for centralized telemetry.
|
||||
- Scaling: start with 250m CPU/256Mi memory, use readiness probes, and scale horizontally from request latency and CPU.
|
||||
`;
|
||||
|
||||
const KUBERNETES = `apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: wrnexus
|
||||
spec:
|
||||
selector: { app: wrnexus }
|
||||
ports: [{ name: http, port: 80, targetPort: 3000 }]
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: wrnexus
|
||||
spec:
|
||||
replicas: 2
|
||||
selector: { matchLabels: { app: wrnexus } }
|
||||
template:
|
||||
metadata: { labels: { app: wrnexus } }
|
||||
spec:
|
||||
containers:
|
||||
- name: app
|
||||
image: ghcr.io/OWNER/APP:latest
|
||||
ports: [{ containerPort: 3000 }]
|
||||
envFrom: [{ secretRef: { name: wrnexus-secrets } }]
|
||||
livenessProbe: { httpGet: { path: /healthz, port: 3000 }, initialDelaySeconds: 5 }
|
||||
readinessProbe: { httpGet: { path: /readyz, port: 3000 }, initialDelaySeconds: 5 }
|
||||
resources:
|
||||
requests: { cpu: 250m, memory: 256Mi }
|
||||
limits: { cpu: "1", memory: 512Mi }
|
||||
lifecycle: { preStop: { exec: { command: ["sh", "-c", "sleep 5"] } } }
|
||||
terminationGracePeriodSeconds: 30
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: wrnexus-migrate
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
containers:
|
||||
- name: migrate
|
||||
image: ghcr.io/OWNER/APP:latest
|
||||
command: ["bunx", "wrnexus", "db", "migrate", "--profile=production"]
|
||||
envFrom: [{ secretRef: { name: wrnexus-secrets } }]
|
||||
`;
|
||||
|
||||
const SYSTEMD = `[Unit]
|
||||
Description=WRNexus application
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/srv/wrnexus
|
||||
EnvironmentFile=/etc/wrnexus/wrnexus.env
|
||||
ExecStartPre=/usr/bin/bunx wrnexus db migrate --profile=production
|
||||
ExecStart=/usr/bin/bun dist/server.js
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
TimeoutStopSec=30
|
||||
User=wrnexus
|
||||
Group=wrnexus
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/srv/wrnexus
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`;
|
||||
|
||||
const NGINX = `server {
|
||||
listen 80;
|
||||
server_name example.com;
|
||||
location /assets/ { root /srv/wrnexus/dist/public; expires 1y; add_header Cache-Control "public, immutable"; }
|
||||
location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Request-ID $request_id; }
|
||||
}
|
||||
`;
|
||||
|
||||
const RAILWAY = `[build]
|
||||
builder = "DOCKERFILE"
|
||||
|
||||
[deploy]
|
||||
startCommand = "bun dist/server.js"
|
||||
healthcheckPath = "/readyz"
|
||||
restartPolicyType = "ON_FAILURE"
|
||||
preDeployCommand = ["bunx wrnexus db migrate --profile=production"]
|
||||
`;
|
||||
|
||||
const RENDER = `services:
|
||||
- type: web
|
||||
name: wrnexus
|
||||
runtime: docker
|
||||
healthCheckPath: /readyz
|
||||
preDeployCommand: bunx wrnexus db migrate --profile=production
|
||||
envVars:
|
||||
- key: DATABASE_URL
|
||||
sync: false
|
||||
- key: SESSION_SECRET
|
||||
sync: false
|
||||
`;
|
||||
|
||||
const FLY = `app = "wrnexus-app"
|
||||
primary_region = "bom"
|
||||
|
||||
[build]
|
||||
dockerfile = "Dockerfile"
|
||||
[env]
|
||||
PORT = "3000"
|
||||
[http_service]
|
||||
internal_port = 3000
|
||||
force_https = true
|
||||
auto_stop_machines = "stop"
|
||||
auto_start_machines = true
|
||||
min_machines_running = 1
|
||||
[[http_service.checks]]
|
||||
path = "/readyz"
|
||||
interval = "15s"
|
||||
timeout = "2s"
|
||||
[deploy]
|
||||
release_command = "bunx wrnexus db migrate --profile=production"
|
||||
`;
|
||||
|
||||
function write(root: string, relative: string, content: string, files: string[]): void {
|
||||
const path = join(root, relative);
|
||||
if (existsSync(path)) return;
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, content, "utf8");
|
||||
files.push(relative);
|
||||
}
|
||||
|
||||
export function generateDeployment(appRoot: string, target: string): string[] {
|
||||
if (!DEPLOY_TARGETS.includes(target as DeployTarget))
|
||||
throw new Error(`WRN-DEPLOY-TARGET: use ${DEPLOY_TARGETS.join(" | ")}.`);
|
||||
const root = resolve(appRoot);
|
||||
const files: string[] = [];
|
||||
if (target === "docker" || ["kubernetes", "railway", "render", "fly"].includes(target))
|
||||
generateDocker(root);
|
||||
write(root, ".env.production.example", ENVIRONMENT, files);
|
||||
write(root, "deploy/README.md", OPERATIONS, files);
|
||||
if (target === "kubernetes") write(root, "deploy/kubernetes.yaml", KUBERNETES, files);
|
||||
if (target === "systemd") {
|
||||
write(root, "deploy/wrnexus.service", SYSTEMD, files);
|
||||
write(root, "deploy/nginx.conf", NGINX, files);
|
||||
}
|
||||
if (target === "railway") write(root, "railway.toml", RAILWAY, files);
|
||||
if (target === "render") write(root, "render.yaml", RENDER, files);
|
||||
if (target === "fly") write(root, "fly.toml", FLY, files);
|
||||
return files;
|
||||
}
|
||||
+81
-2
@@ -13,6 +13,7 @@
|
||||
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { resolve, join } from "node:path";
|
||||
import { existsSync, watch, type FSWatcher } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { RESTART_EXIT_CODE } from "@wrnexus/dev-server";
|
||||
|
||||
@@ -20,7 +21,12 @@ import { RESTART_EXIT_CODE } from "@wrnexus/dev-server";
|
||||
// it works whether @wrnexus/dev-server is a workspace or an installed dependency.
|
||||
const SERVE_ENTRY = fileURLToPath(import.meta.resolve("@wrnexus/dev-server/serve-entry"));
|
||||
|
||||
export function runDev(appRoot: string, port: number, hostname = "::"): void {
|
||||
export function runDev(
|
||||
appRoot: string,
|
||||
port: number,
|
||||
hostname = "::",
|
||||
tls?: { certFile: string; keyFile: string },
|
||||
): void {
|
||||
const appDir = join(resolve(appRoot), "app");
|
||||
let child: ChildProcess | null = null;
|
||||
let shuttingDown = false;
|
||||
@@ -28,7 +34,15 @@ export function runDev(appRoot: string, port: number, hostname = "::"): void {
|
||||
const spawnChild = (): void => {
|
||||
child = spawn(
|
||||
process.execPath, // the Bun binary
|
||||
[SERVE_ENTRY, appDir, String(port), "development", hostname],
|
||||
[
|
||||
SERVE_ENTRY,
|
||||
appDir,
|
||||
String(port),
|
||||
"development",
|
||||
hostname,
|
||||
"true",
|
||||
...(tls ? [tls.certFile, tls.keyFile] : []),
|
||||
],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
|
||||
@@ -81,3 +95,68 @@ export function runDev(appRoot: string, port: number, hostname = "::"): void {
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
}
|
||||
|
||||
export async function runProductionDev(
|
||||
appRoot: string,
|
||||
port: number,
|
||||
hostname = "::",
|
||||
): Promise<void> {
|
||||
const root = resolve(appRoot);
|
||||
let child: ChildProcess | undefined;
|
||||
let building = false;
|
||||
let pending = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const { runBuild } = await import("./build.ts");
|
||||
|
||||
const rebuild = async (): Promise<void> => {
|
||||
if (building) {
|
||||
pending = true;
|
||||
return;
|
||||
}
|
||||
building = true;
|
||||
try {
|
||||
await runBuild(root);
|
||||
child?.kill();
|
||||
const { runPreview } = await import("./preview.ts");
|
||||
child = runPreview(root, { port, hostname, developmentRuntime: true });
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[wrnexus] production-runtime rebuild failed; keeping the last good server.",
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
building = false;
|
||||
if (pending) {
|
||||
pending = false;
|
||||
void rebuild();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
console.log(`\n ⚡ WrNexus dev (exact production runtime) — ${root}`);
|
||||
await rebuild();
|
||||
const watchers: FSWatcher[] = [];
|
||||
for (const name of [
|
||||
"app",
|
||||
"public",
|
||||
"wrnexus.config.ts",
|
||||
"wrnexus.config.js",
|
||||
"wrnexus.config.mjs",
|
||||
]) {
|
||||
const target = join(root, name);
|
||||
if (!existsSync(target)) continue;
|
||||
watchers.push(
|
||||
watch(target, { recursive: true }, () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => void rebuild(), 120);
|
||||
}),
|
||||
);
|
||||
}
|
||||
const shutdown = (): void => {
|
||||
watchers.forEach((watcher) => watcher.close());
|
||||
child?.kill();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
||||
import { extname, join, resolve } from "node:path";
|
||||
import { diagnose } from "@wrnexus/syntax";
|
||||
import { diagnose, formatWrn } from "@wrnexus/syntax";
|
||||
import { buildRouter, findRouteConflicts } from "@wrnexus/router";
|
||||
import { loadAppConfig, validateAppConfig } from "@wrnexus/styles";
|
||||
import { createPluginRunner, discoverPlugins } from "@wrnexus/plugin";
|
||||
@@ -12,6 +12,12 @@ export interface DoctorCheck {
|
||||
level?: "error" | "warning";
|
||||
}
|
||||
|
||||
export interface DoctorRepair {
|
||||
name: string;
|
||||
changed: boolean;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
function parseVersion(value: string): [number, number, number] {
|
||||
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(value.replace(/^[^\d]*/, ""));
|
||||
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : [0, 0, 0];
|
||||
@@ -53,6 +59,81 @@ function frameworkRanges(pkg: Record<string, unknown>): Map<string, string[]> {
|
||||
return ranges;
|
||||
}
|
||||
|
||||
export function repairProject(appRoot: string): DoctorRepair[] {
|
||||
const root = resolve(appRoot);
|
||||
const repairs: DoctorRepair[] = [];
|
||||
const pages = join(root, "app", "pages");
|
||||
if (!existsSync(pages)) {
|
||||
mkdirSync(pages, { recursive: true });
|
||||
repairs.push({ name: "app/pages", changed: true, detail: "created app/pages" });
|
||||
}
|
||||
|
||||
const configNames = ["wrnexus.config.ts", "wrnexus.config.mjs", "wrnexus.config.js"];
|
||||
if (!configNames.some((name) => existsSync(join(root, name)))) {
|
||||
writeFileSync(join(root, "wrnexus.config.ts"), "export default {};\n", "utf8");
|
||||
repairs.push({ name: "configuration", changed: true, detail: "created wrnexus.config.ts" });
|
||||
}
|
||||
|
||||
const pkgPath = join(root, "package.json");
|
||||
if (existsSync(pkgPath)) {
|
||||
try {
|
||||
const source = readFileSync(pkgPath, "utf8");
|
||||
const pkg = JSON.parse(source) as Record<string, unknown>;
|
||||
const ranges = frameworkRanges(pkg);
|
||||
const preferred = [...ranges.keys()].sort((left, right) => {
|
||||
const a = parseVersion(left);
|
||||
const b = parseVersion(right);
|
||||
return b[0] - a[0] || b[1] - a[1] || b[2] - a[2];
|
||||
})[0];
|
||||
let changed = false;
|
||||
if (preferred && ranges.size > 1) {
|
||||
for (const field of ["dependencies", "devDependencies", "peerDependencies"]) {
|
||||
const dependencies = pkg[field] as Record<string, string> | undefined;
|
||||
for (const name of Object.keys(dependencies ?? {})) {
|
||||
if (name.startsWith("@wrnexus/") && dependencies![name] !== preferred) {
|
||||
dependencies![name] = preferred;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const marker = (pkg.wrnexus as Record<string, unknown> | undefined) ?? {};
|
||||
if (!marker.version || !versionAtLeast(String(marker.version), "0.8.0")) {
|
||||
marker.version = "0.8.0";
|
||||
pkg.wrnexus = marker;
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf8");
|
||||
repairs.push({
|
||||
name: "package.json",
|
||||
changed: true,
|
||||
detail: preferred
|
||||
? `aligned framework packages to ${preferred}`
|
||||
: "recorded 0.8.0 marker",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
repairs.push({ name: "package.json", changed: false, detail: "skipped invalid JSON" });
|
||||
}
|
||||
}
|
||||
|
||||
let formatted = 0;
|
||||
for (const file of walk(join(root, "app"), ".wrn")) {
|
||||
const source = readFileSync(file, "utf8");
|
||||
if (diagnose(source).some((item) => item.severity === "error")) continue;
|
||||
const output = formatWrn(source, { tabSize: 2, printWidth: 100, multilineAttributes: true });
|
||||
if (output !== source) {
|
||||
writeFileSync(file, output, "utf8");
|
||||
formatted++;
|
||||
}
|
||||
}
|
||||
if (formatted) {
|
||||
repairs.push({ name: "WRN formatting", changed: true, detail: `formatted ${formatted} files` });
|
||||
}
|
||||
return repairs;
|
||||
}
|
||||
|
||||
export function inspectProject(appRoot: string): DoctorCheck[] {
|
||||
const root = resolve(appRoot);
|
||||
const checks: DoctorCheck[] = [];
|
||||
@@ -174,8 +255,12 @@ export function inspectProject(appRoot: string): DoctorCheck[] {
|
||||
return checks;
|
||||
}
|
||||
|
||||
export async function runDoctor(appRoot: string): Promise<boolean> {
|
||||
export async function runDoctor(
|
||||
appRoot: string,
|
||||
options: { fix?: boolean } = {},
|
||||
): Promise<boolean> {
|
||||
const root = resolve(appRoot);
|
||||
const repairs = options.fix ? repairProject(root) : [];
|
||||
const checks = inspectProject(root);
|
||||
try {
|
||||
const config = await loadAppConfig(root);
|
||||
@@ -257,6 +342,8 @@ export async function runDoctor(appRoot: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
console.log("WRNexus doctor\n");
|
||||
for (const repair of repairs) console.log(` ↻ ${repair.name}: ${repair.detail}`);
|
||||
if (repairs.length) console.log("");
|
||||
for (const check of checks) {
|
||||
const optional = check.level === "warning";
|
||||
console.log(` ${check.ok ? "✓" : optional ? "⚠" : "✗"} ${check.name}: ${check.detail}`);
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
interface ExplainRoute {
|
||||
kind: "page" | "api" | "realtime";
|
||||
path: string;
|
||||
source: string;
|
||||
execution: string;
|
||||
canPrerender: boolean;
|
||||
needsClientRuntime: boolean;
|
||||
needsServerRuntime: boolean;
|
||||
hydrationStrategy: string | null;
|
||||
reasons?: string[];
|
||||
cachePolicy?: Record<string, string>;
|
||||
requiredPermission?: string | null;
|
||||
}
|
||||
|
||||
interface ExplainReport {
|
||||
frameworkVersion: string;
|
||||
adapter: string;
|
||||
routes: ExplainRoute[];
|
||||
assets: Array<{ file: string; bytes: number }>;
|
||||
measurements: Record<string, number>;
|
||||
budgetViolations: Array<{ metric: string; budget: number; actual: number }>;
|
||||
}
|
||||
|
||||
export interface Explanation {
|
||||
target: string;
|
||||
subject: string;
|
||||
summary: string;
|
||||
reasons: string[];
|
||||
evidence: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function loadReport(root: string): ExplainReport {
|
||||
const path = join(resolve(root), "dist", "build-report.json");
|
||||
if (!existsSync(path)) {
|
||||
throw new Error(
|
||||
"WRN-EXPLAIN-NO-BUILD: run `wrnexus build` before requesting build explanations.",
|
||||
);
|
||||
}
|
||||
return JSON.parse(readFileSync(path, "utf8")) as ExplainReport;
|
||||
}
|
||||
|
||||
function routeMatch(routes: ExplainRoute[], subject: string): ExplainRoute | undefined {
|
||||
const normalized = subject.startsWith("/") ? subject : `/${subject}`;
|
||||
return routes.find((route) => route.path === normalized || route.source.includes(subject));
|
||||
}
|
||||
|
||||
export function explainBuildDecision(root: string, target: string, subject = ""): Explanation {
|
||||
const report = loadReport(root);
|
||||
if (
|
||||
target === "route" ||
|
||||
target === "hydration" ||
|
||||
target === "cache" ||
|
||||
target === "permission"
|
||||
) {
|
||||
const route =
|
||||
target === "permission"
|
||||
? report.routes.find((item) => item.requiredPermission === subject)
|
||||
: routeMatch(report.routes, subject);
|
||||
if (!route) throw new Error(`WRN-EXPLAIN-NOT-FOUND: no route or source matches '${subject}'.`);
|
||||
if (target === "cache") {
|
||||
const policy = route.cachePolicy ?? {};
|
||||
const entries = Object.entries(policy);
|
||||
return {
|
||||
target,
|
||||
subject: route.path,
|
||||
summary: entries.length
|
||||
? `Route cache uses '${policy.strategy ?? "framework-default"}' strategy.`
|
||||
: "Route has no explicit cache policy and uses safe framework defaults.",
|
||||
reasons: entries.length
|
||||
? entries.map(([name, value]) => `${name} = ${value}`)
|
||||
: ["responses remain private/revalidated unless an explicit safe policy enables reuse"],
|
||||
evidence: { source: route.source, cachePolicy: policy, execution: route.execution },
|
||||
};
|
||||
}
|
||||
if (target === "permission") {
|
||||
const requested = subject.startsWith("/") ? undefined : subject;
|
||||
const matches = report.routes.filter((item) =>
|
||||
requested ? item.requiredPermission === requested : item.path === route.path,
|
||||
);
|
||||
return {
|
||||
target,
|
||||
subject: requested ?? route.path,
|
||||
summary: matches.length
|
||||
? `${matches.length} route(s) require this permission.`
|
||||
: "No built route declares this permission.",
|
||||
reasons: matches.length
|
||||
? matches.map((item) => `${item.path} declares security.permission in ${item.source}`)
|
||||
: ["authorization may still be enforced programmatically; inspect authz policies"],
|
||||
evidence: { routes: matches },
|
||||
};
|
||||
}
|
||||
const reasons = route.reasons?.length ? route.reasons : ["no dynamic requirement was detected"];
|
||||
return {
|
||||
target,
|
||||
subject: route.path,
|
||||
summary:
|
||||
target === "hydration"
|
||||
? route.needsClientRuntime
|
||||
? `Hydration uses '${route.hydrationStrategy ?? "load"}' because client runtime is required.`
|
||||
: "Hydration is omitted because no client runtime is required."
|
||||
: `Route execution is '${route.execution}'${route.canPrerender ? " and can prerender" : " and cannot prerender"}.`,
|
||||
reasons,
|
||||
evidence: { ...route },
|
||||
};
|
||||
}
|
||||
if (target === "bundle") {
|
||||
const assets = [...report.assets].sort((a, b) => b.bytes - a.bytes);
|
||||
return {
|
||||
target,
|
||||
subject: subject || "production bundle",
|
||||
summary: `${assets.length} emitted assets; largest is ${assets[0]?.file ?? "none"}.`,
|
||||
reasons: assets.slice(0, 10).map((asset) => `${asset.file}: ${asset.bytes} bytes`),
|
||||
evidence: { measurements: report.measurements, largestAssets: assets.slice(0, 10) },
|
||||
};
|
||||
}
|
||||
if (target === "build") {
|
||||
return {
|
||||
target,
|
||||
subject: "production build",
|
||||
summary: `${report.routes.length} routes target the ${report.adapter} adapter.`,
|
||||
reasons: report.budgetViolations.length
|
||||
? report.budgetViolations.map(
|
||||
(item) => `${item.metric} exceeds ${item.budget} with ${item.actual}`,
|
||||
)
|
||||
: ["all configured performance budgets pass"],
|
||||
evidence: {
|
||||
frameworkVersion: report.frameworkVersion,
|
||||
adapter: report.adapter,
|
||||
measurements: report.measurements,
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`WRN-EXPLAIN-TARGET: unsupported target '${target}'.`);
|
||||
}
|
||||
|
||||
export function runExplain(root: string, target: string, subject: string, args: string[]): void {
|
||||
const explanation = explainBuildDecision(root, target, subject);
|
||||
if (args.includes("--json")) {
|
||||
console.log(JSON.stringify(explanation, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(explanation.summary);
|
||||
explanation.reasons.forEach((reason, index) => console.log(` ${index + 1}. ${reason}`));
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { extname, join, resolve } from "node:path";
|
||||
import {
|
||||
auditLocaleKeys,
|
||||
extractTranslationKeysFromFiles,
|
||||
flattenMessageKeys,
|
||||
loadLocales,
|
||||
} from "@wrnexus/i18n";
|
||||
|
||||
function sourceFiles(root: string): string[] {
|
||||
const output: string[] = [];
|
||||
const visit = (directory: string) => {
|
||||
if (!existsSync(directory)) return;
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === "dist")
|
||||
continue;
|
||||
const path = join(directory, entry.name);
|
||||
if (entry.isDirectory()) visit(path);
|
||||
else if ([".wrn", ".ts", ".tsx", ".js", ".jsx"].includes(extname(entry.name)))
|
||||
output.push(path);
|
||||
}
|
||||
};
|
||||
visit(join(root, "app"));
|
||||
return output.sort();
|
||||
}
|
||||
export function runI18nCommand(appRoot: string, command: string): boolean {
|
||||
const root = resolve(appRoot);
|
||||
const extracted = extractTranslationKeysFromFiles(sourceFiles(root));
|
||||
const keys = [...new Set(extracted.map((entry) => entry.key))].sort();
|
||||
const messages = loadLocales(join(root, "app", "locales"), { strict: true });
|
||||
const locales = Object.keys(messages).sort();
|
||||
const reference = locales[0] ?? "en";
|
||||
const audit = auditLocaleKeys(messages, reference);
|
||||
const referenceKeys = new Set(flattenMessageKeys(messages[reference] ?? {}));
|
||||
const unused = [...referenceKeys].filter((key) => !keys.includes(key)).sort();
|
||||
const missingFromReference = keys.filter((key) => !referenceKeys.has(key));
|
||||
const report = { reference, locales, extracted: keys, missingFromReference, unused, audit };
|
||||
if (command === "extract") {
|
||||
const directory = join(root, ".wrnexus");
|
||||
mkdirSync(directory, { recursive: true });
|
||||
const file = join(directory, "i18n-keys.json");
|
||||
writeFileSync(file, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
||||
console.log(`✓ Extracted ${keys.length} translation keys to ${file}`);
|
||||
return true;
|
||||
}
|
||||
if (command !== "validate")
|
||||
throw new Error("WRN-I18N-COMMAND: use i18n extract or i18n validate.");
|
||||
for (const locale of locales) {
|
||||
const missing = [
|
||||
...new Set([...(audit[locale]?.missing ?? []), ...missingFromReference]),
|
||||
].sort();
|
||||
if (missing.length) {
|
||||
console.log(`Missing in ${locale}:`);
|
||||
missing.forEach((key) => console.log(`- ${key}`));
|
||||
}
|
||||
}
|
||||
if (unused.length) {
|
||||
console.log("Unused keys:");
|
||||
unused.forEach((key) => console.log(`- ${key}`));
|
||||
}
|
||||
const healthy =
|
||||
missingFromReference.length === 0 &&
|
||||
Object.values(audit).every((value) => value.missing.length === 0);
|
||||
if (healthy) console.log(`✓ ${locales.length} locales contain every extracted key.`);
|
||||
return healthy;
|
||||
}
|
||||
+190
-7
@@ -40,7 +40,12 @@ function help(): void {
|
||||
Usage:
|
||||
wrnexus dev [app-dir] [--port=3000] [--host=::]
|
||||
Start the development server (live reload)
|
||||
wrnexus dev [app-dir] --services Start local production-service simulators with the app
|
||||
wrnexus dev [app-dir] --production-runtime
|
||||
wrnexus dev [app-dir] --services [--services-port=3099] [--services-http]
|
||||
Rebuild and reload the exact production artifact
|
||||
wrnexus build [app-dir] Build a production server bundle + assets
|
||||
wrnexus preview [app-dir] Serve the existing exact production output
|
||||
wrnexus create <app-name> Scaffold a new app
|
||||
wrnexus workspace <name> Scaffold a monorepo (apps/* + shared packages/*)
|
||||
wrnexus workspace add <name> [--domain=name.localhost]
|
||||
@@ -51,6 +56,9 @@ Usage:
|
||||
wrnexus generate <type> <name> Scaffold a page | component | api | schema
|
||||
wrnexus generate routes | docker | mobile
|
||||
Generate routes or scaffold deployment targets
|
||||
wrnexus generate types [app-dir] Generate application-wide route/component/key types
|
||||
wrnexus routes [app-dir] Generate typed named routes
|
||||
wrnexus typecheck [app-dir] Generate types and check TypeScript plus every .wrn file
|
||||
wrnexus mobile add <package...> Install Capacitor or Expo native packages
|
||||
wrnexus mobile compile Compile .wrn pages into native Expo routes
|
||||
wrnexus native list List cross-platform native capabilities
|
||||
@@ -58,12 +66,35 @@ Usage:
|
||||
wrnexus eject <name...> Copy a Wire UI component into app/components
|
||||
wrnexus update [dir] [--latest] Upgrade deps, migrate project files, and verify the app
|
||||
wrnexus db <cmd> Migrations: migrate | rollback | status | seed | generate | new
|
||||
wrnexus test [app-dir] [--watch] Run the app's tests (bun test, 'test' profile)
|
||||
wrnexus test [level] [app-dir] [--watch]
|
||||
Run unit | component | api | browser | visual | accessibility | performance
|
||||
wrnexus profiles [app-dir] List config profiles (dev/prod/uat/…) and their env files
|
||||
wrnexus doctor [app-dir] Check project structure, versions, syntax, routes, and config
|
||||
wrnexus doctor [app-dir] [--fix] Check project health; optionally apply safe repairs
|
||||
wrnexus compatibility <check|explain|upgrade> [app-dir]
|
||||
Inspect or explicitly upgrade behavior defaults
|
||||
wrnexus contracts <check|snapshot> [app-dir]
|
||||
Detect breaking boundary contract changes
|
||||
wrnexus security <audit|headers|test> [app-dir]
|
||||
Audit ASVS controls, inspect headers, or run abuse tests
|
||||
wrnexus api <generate|docs> [app-dir] Generate OpenAPI, docs, Postman, examples and SDKs
|
||||
wrnexus sdk generate <language> [app-dir]
|
||||
Generate TypeScript, JavaScript, Java, Go, or Python SDK
|
||||
wrnexus deploy <target> [app-dir] Generate docker | kubernetes | systemd | railway | render | fly
|
||||
wrnexus mcp [app-dir] Start the WRNexus MCP server over stdio
|
||||
wrnexus i18n <extract|validate> [app-dir]
|
||||
Extract and audit translation keys
|
||||
wrnexus report [app-dir] [--file=app/pages/page.wrn]
|
||||
Create a sanitized reproduction bundle
|
||||
wrnexus playground [--port=4173] Start the shareable WRN compiler playground
|
||||
wrnexus config [app-dir] --explain Print the fully resolved profile configuration
|
||||
wrnexus analyze [app-dir] Inspect dist/build-report.json and performance budgets
|
||||
wrnexus explain <route|build|hydration|bundle> [subject] [app-dir]
|
||||
Explain compiler and production build decisions
|
||||
wrnexus explain <cache|permission> <subject> [app-dir]
|
||||
Explain route caching or permission enforcement
|
||||
wrnexus inspect <target> [app-dir] Inspect packages | plugins | routes | assets | runtimes | styles | migrations | bundle
|
||||
wrnexus inspect component <name> [app-dir]
|
||||
Inspect a component's typed public contract
|
||||
wrnexus generate system <name> Scaffold a complete framework-native package
|
||||
|
||||
Profiles: pass --profile=<name> to dev/build/db (or set WRNEXUS_PROFILE) to load
|
||||
@@ -91,7 +122,31 @@ async function main(): Promise<void> {
|
||||
const port = portArg ? Number(portArg.split("=")[1]) : 3000;
|
||||
const host = hostArg?.split("=")[1] || "::";
|
||||
bootstrapProfile(appRoot, "development", rest);
|
||||
runDev(appRoot, port, host);
|
||||
let developmentCertificate:
|
||||
| { certFile: string; keyFile: string; cert: string; key: string; reused: boolean }
|
||||
| undefined;
|
||||
if (rest.includes("--services") && !rest.includes("--services-http")) {
|
||||
const { ensureLocalCertificate } = await import("./services.ts");
|
||||
developmentCertificate = await ensureLocalCertificate(appRoot);
|
||||
}
|
||||
if (rest.includes("--services")) {
|
||||
const { startLocalServices } = await import("./services.ts");
|
||||
await startLocalServices({
|
||||
appRoot,
|
||||
port: Number(
|
||||
rest.find((value) => value.startsWith("--services-port="))?.split("=")[1] ?? 3099,
|
||||
),
|
||||
https: !rest.includes("--services-http"),
|
||||
origin: `${rest.includes("--services-http") ? "http" : "https"}://localhost:${port}`,
|
||||
certificate: developmentCertificate,
|
||||
});
|
||||
}
|
||||
if (rest.includes("--production-runtime")) {
|
||||
const { runProductionDev } = await import("./dev.ts");
|
||||
await runProductionDev(appRoot, port, host);
|
||||
} else {
|
||||
runDev(appRoot, port, host, developmentCertificate);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "build": {
|
||||
@@ -101,6 +156,15 @@ async function main(): Promise<void> {
|
||||
await runBuild(appRoot);
|
||||
break;
|
||||
}
|
||||
case "preview": {
|
||||
const appRoot = rest.find((a) => !a.startsWith("--")) ?? ".";
|
||||
const port = Number(rest.find((a) => a.startsWith("--port="))?.split("=")[1] ?? 3000);
|
||||
const hostname = rest.find((a) => a.startsWith("--host="))?.split("=")[1] || "::";
|
||||
bootstrapProfile(appRoot, "production", rest);
|
||||
const { runPreview } = await import("./preview.ts");
|
||||
runPreview(appRoot, { port, hostname });
|
||||
break;
|
||||
}
|
||||
case "create":
|
||||
createApp(rest[0] ?? "");
|
||||
break;
|
||||
@@ -129,10 +193,18 @@ async function main(): Promise<void> {
|
||||
case "g": {
|
||||
if (rest[0] === "routes") {
|
||||
const { regenerateRoutes } = await import("./routes.ts");
|
||||
const n = regenerateRoutes(join(process.cwd(), "app"));
|
||||
const n = regenerateRoutes(join(resolve(rest[1] ?? "."), "app"));
|
||||
console.log(`✓ Generated app/routes.gen.ts (${n} routes)`);
|
||||
break;
|
||||
}
|
||||
if (rest[0] === "types") {
|
||||
const { generateApplicationTypesWithPlugins } = await import("./types.ts");
|
||||
const result = await generateApplicationTypesWithPlugins(rest[1] ?? ".");
|
||||
console.log(
|
||||
`✓ Generated ${result.file} (${result.routes} routes, ${result.components} components)`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (rest[0] === "docker") {
|
||||
const { generateDocker } = await import("./docker.ts");
|
||||
generateDocker(process.cwd());
|
||||
@@ -153,6 +225,20 @@ async function main(): Promise<void> {
|
||||
runGenerate(".", rest[0], rest[1]);
|
||||
break;
|
||||
}
|
||||
case "routes": {
|
||||
const appRoot = resolve(rest.find((arg) => !arg.startsWith("--")) ?? ".");
|
||||
const { regenerateRoutes } = await import("./routes.ts");
|
||||
const count = regenerateRoutes(join(appRoot, "app"));
|
||||
console.log(`✓ Generated app/routes.gen.ts (${count} routes)`);
|
||||
break;
|
||||
}
|
||||
case "typecheck": {
|
||||
const { runTypecheck } = await import("./types.ts");
|
||||
const healthy = await runTypecheck(rest.find((arg) => !arg.startsWith("--")) ?? ".");
|
||||
if (!healthy) process.exitCode = 1;
|
||||
else console.log("✓ Application types are valid");
|
||||
break;
|
||||
}
|
||||
case "eject": {
|
||||
const { runEject } = await import("./eject.ts");
|
||||
const args = rest.filter((a) => !a.startsWith("--"));
|
||||
@@ -191,10 +277,87 @@ async function main(): Promise<void> {
|
||||
}
|
||||
case "doctor": {
|
||||
const { runDoctor } = await import("./doctor.ts");
|
||||
const healthy = await runDoctor(rest.find((a) => !a.startsWith("--")) ?? ".");
|
||||
const healthy = await runDoctor(rest.find((a) => !a.startsWith("--")) ?? ".", {
|
||||
fix: rest.includes("--fix"),
|
||||
});
|
||||
if (!healthy) process.exitCode = 1;
|
||||
break;
|
||||
}
|
||||
case "compatibility": {
|
||||
const { runCompatibilityCommand } = await import("./compatibility-command.ts");
|
||||
const subcommand = rest.find((value) => !value.startsWith("--")) ?? "check";
|
||||
const appRoot = rest.filter((value) => !value.startsWith("--"))[1] ?? ".";
|
||||
if (!["check", "explain", "upgrade"].includes(subcommand))
|
||||
throw new Error(`WRN-COMPATIBILITY-COMMAND: unknown command '${subcommand}'.`);
|
||||
const current = await runCompatibilityCommand(appRoot, subcommand, rest);
|
||||
if (!current && subcommand !== "explain") process.exitCode = 1;
|
||||
break;
|
||||
}
|
||||
case "contracts": {
|
||||
const { runContractsCommand } = await import("./contracts-command.ts");
|
||||
const subcommand = rest.find((value) => !value.startsWith("--")) ?? "check";
|
||||
const appRoot = rest.filter((value) => !value.startsWith("--"))[1] ?? ".";
|
||||
const result = await runContractsCommand(appRoot, subcommand, rest);
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
break;
|
||||
}
|
||||
case "security": {
|
||||
const values = rest.filter((value) => !value.startsWith("--"));
|
||||
const { runSecurityCommand } = await import("./security-command.ts");
|
||||
const healthy = await runSecurityCommand(values[1] ?? ".", values[0] ?? "audit", rest);
|
||||
if (!healthy) process.exitCode = 1;
|
||||
break;
|
||||
}
|
||||
case "api": {
|
||||
const values = rest.filter((value) => !value.startsWith("--"));
|
||||
if (!["generate", "docs"].includes(values[0] ?? ""))
|
||||
throw new Error("WRN-API-COMMAND: use api generate or api docs.");
|
||||
const { runApiCommand } = await import("./api-command.ts");
|
||||
runApiCommand(values[1] ?? ".", "api", rest);
|
||||
break;
|
||||
}
|
||||
case "sdk": {
|
||||
const values = rest.filter((value) => !value.startsWith("--"));
|
||||
if (values[0] !== "generate")
|
||||
throw new Error("WRN-SDK-COMMAND: use sdk generate <language>.");
|
||||
const { runApiCommand } = await import("./api-command.ts");
|
||||
runApiCommand(values[2] ?? ".", "sdk", rest);
|
||||
break;
|
||||
}
|
||||
case "deploy": {
|
||||
const values = rest.filter((value) => !value.startsWith("--"));
|
||||
const { generateDeployment } = await import("./deploy.ts");
|
||||
const files = generateDeployment(values[1] ?? ".", values[0] ?? "");
|
||||
console.log(`✓ Deployment preset '${values[0]}' ready (${files.length} new files)`);
|
||||
break;
|
||||
}
|
||||
case "mcp": {
|
||||
const { runMcpStdio } = await import("@wrnexus/mcp/stdio");
|
||||
await runMcpStdio(resolve(rest.find((value) => !value.startsWith("--")) ?? "."));
|
||||
break;
|
||||
}
|
||||
case "i18n": {
|
||||
const values = rest.filter((value) => !value.startsWith("--"));
|
||||
const { runI18nCommand } = await import("./i18n-command.ts");
|
||||
if (!runI18nCommand(values[1] ?? ".", values[0] ?? "validate")) process.exitCode = 1;
|
||||
break;
|
||||
}
|
||||
case "report": {
|
||||
const { runReport } = await import("./report.ts");
|
||||
runReport(rest.find((value) => !value.startsWith("--")) ?? ".", rest);
|
||||
break;
|
||||
}
|
||||
case "playground": {
|
||||
const { createPlaygroundHandler } = await import("@wrnexus/playground");
|
||||
const port = Number(rest.find((value) => value.startsWith("--port="))?.split("=")[1] ?? 4173);
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
hostname: "127.0.0.1",
|
||||
fetch: createPlaygroundHandler(),
|
||||
});
|
||||
console.log(`▶ WRNexus playground: http://localhost:${server.port}`);
|
||||
break;
|
||||
}
|
||||
case "config": {
|
||||
const { runConfigCommand } = await import("./config-command.ts");
|
||||
await runConfigCommand(rest.find((a) => !a.startsWith("--")) ?? ".", rest);
|
||||
@@ -202,6 +365,12 @@ async function main(): Promise<void> {
|
||||
}
|
||||
case "inspect": {
|
||||
const target = rest.find((arg) => !arg.startsWith("--"));
|
||||
if (target === "component") {
|
||||
const values = rest.filter((arg) => !arg.startsWith("--"));
|
||||
const { runInspectComponent } = await import("./inspect.ts");
|
||||
runInspectComponent(values[2] ?? ".", values[1] ?? "", rest);
|
||||
break;
|
||||
}
|
||||
const appRoot = rest.filter((arg) => !arg.startsWith("--"))[1] ?? ".";
|
||||
const { runInspect } = await import("./inspect.ts");
|
||||
await runInspect(appRoot, target, rest);
|
||||
@@ -213,13 +382,25 @@ async function main(): Promise<void> {
|
||||
if (!healthy) process.exitCode = 1;
|
||||
break;
|
||||
}
|
||||
case "explain": {
|
||||
const values = rest.filter((value) => !value.startsWith("--"));
|
||||
const target = values[0] ?? "build";
|
||||
const subject = target === "build" || target === "bundle" ? "" : (values[1] ?? "");
|
||||
const appRoot =
|
||||
target === "build" || target === "bundle" ? (values[1] ?? ".") : (values[2] ?? ".");
|
||||
const { runExplain } = await import("./explain.ts");
|
||||
runExplain(appRoot, target, subject, rest);
|
||||
break;
|
||||
}
|
||||
case "test": {
|
||||
const appRoot = rest.find((a) => !a.startsWith("--")) ?? ".";
|
||||
const { TEST_LEVELS, runTests } = await import("./test.ts");
|
||||
const values = rest.filter((value) => !value.startsWith("--"));
|
||||
const hasLevel = TEST_LEVELS.includes(values[0] as (typeof TEST_LEVELS)[number]);
|
||||
const appRoot = (hasLevel ? values[1] : values[0]) ?? ".";
|
||||
const flag = rest.find((a) => a.startsWith("--profile="));
|
||||
// Tests default to the `test` profile (config + .env.test), unless overridden.
|
||||
process.env.WRNEXUS_PROFILE = resolveProfile({ explicit: flag?.split("=")[1] ?? "test" });
|
||||
loadEnv(resolve(appRoot), process.env.WRNEXUS_PROFILE);
|
||||
const { runTests } = await import("./test.ts");
|
||||
runTests(appRoot, rest);
|
||||
break;
|
||||
}
|
||||
@@ -259,6 +440,8 @@ async function main(): Promise<void> {
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message !== "unknown-environment-command") throw error;
|
||||
const { runPluginCliCommand } = await import("./plugin-command.ts");
|
||||
if (await runPluginCliCommand(workspaceRoot, command, rest)) break;
|
||||
console.error(`Unknown command or workspace environment: ${command}\n`);
|
||||
help();
|
||||
process.exit(1);
|
||||
|
||||
@@ -4,10 +4,41 @@ import { buildRouter, createRouteManifest, nameRoutes } from "@wrnexus/router";
|
||||
import { createPluginRunner, discoverPlugins } from "@wrnexus/plugin";
|
||||
import { loadAppConfig } from "@wrnexus/styles";
|
||||
import { uiComponentsDir } from "@wrnexus/ui";
|
||||
import { parse } from "@wrnexus/syntax";
|
||||
|
||||
export type InspectTarget =
|
||||
"packages" | "plugins" | "routes" | "assets" | "runtimes" | "styles" | "migrations" | "bundle";
|
||||
|
||||
export function inspectComponent(appRoot: string, requestedName: string): unknown {
|
||||
const root = resolve(appRoot);
|
||||
const router = buildRouter(join(root, "app"), { componentDirs: [uiComponentsDir()] });
|
||||
const component = router.components.find(
|
||||
(candidate) => candidate.name.toLowerCase() === requestedName.toLowerCase(),
|
||||
);
|
||||
if (!component) throw new Error(`Component not found: ${requestedName}`);
|
||||
const ast = parse(readFileSync(component.file, "utf8"));
|
||||
return {
|
||||
name: ast.name,
|
||||
file: relative(root, component.file).replace(/\\/g, "/"),
|
||||
props: ast.props.map(({ name, valueType, required, default: defaultValue }) => ({
|
||||
name,
|
||||
type: valueType ?? "unknown",
|
||||
required,
|
||||
...(defaultValue === undefined || defaultValue === "undefined"
|
||||
? {}
|
||||
: { default: defaultValue }),
|
||||
})),
|
||||
outputs: ast.outputs.map(({ name, payload }) => ({ name, payload: payload ?? null })),
|
||||
functions: ast.runtimeFunctions.map(({ name, runtime, async, parameters, returnType }) => ({
|
||||
name,
|
||||
runtime,
|
||||
async,
|
||||
parameters,
|
||||
returnType: returnType ?? "unknown",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function json(path: string): Record<string, any> | null {
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf8")) as Record<string, any>;
|
||||
@@ -158,3 +189,12 @@ export async function runInspect(
|
||||
);
|
||||
else console.log(JSON.stringify(value, null, 2));
|
||||
}
|
||||
|
||||
export function runInspectComponent(appRoot: string, name: string, args: string[] = []): void {
|
||||
const value = inspectComponent(appRoot, name);
|
||||
if (args.includes("--json")) console.log(JSON.stringify(value, null, 2));
|
||||
else {
|
||||
console.log(`WRNexus component ${name}\n`);
|
||||
console.log(JSON.stringify(value, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { resolve } from "node:path";
|
||||
import { createPluginRunner, discoverPlugins } from "@wrnexus/plugin";
|
||||
import { loadAppConfig } from "@wrnexus/styles";
|
||||
|
||||
export async function runPluginCliCommand(
|
||||
appRoot: string,
|
||||
commandName: string,
|
||||
args: string[],
|
||||
): Promise<boolean> {
|
||||
const root = resolve(appRoot);
|
||||
const config = await loadAppConfig(root);
|
||||
const discovered = await discoverPlugins(root, config.plugins, {
|
||||
includeDevDependencies: true,
|
||||
strict: true,
|
||||
});
|
||||
const runner = createPluginRunner(discovered, {
|
||||
root,
|
||||
mode: "development",
|
||||
command: "cli",
|
||||
metadata: new Map(),
|
||||
warn: (message) => console.warn(`[wrnexus:plugin] ${message}`),
|
||||
});
|
||||
await runner.configure(config as Record<string, unknown>);
|
||||
await runner.configResolved(config as Readonly<Record<string, unknown>>);
|
||||
const command = (await runner.contributions()).cliCommands.find(
|
||||
(candidate) => candidate.name === commandName,
|
||||
);
|
||||
if (!command) return false;
|
||||
await command.run(args, {
|
||||
root,
|
||||
mode: "development",
|
||||
command: "cli",
|
||||
metadata: new Map(),
|
||||
warn: (message) => console.warn(`[wrnexus:plugin] ${message}`),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
export interface PreviewOptions {
|
||||
port?: number;
|
||||
hostname?: string;
|
||||
stdio?: "inherit" | "pipe";
|
||||
/** Enable the production artifact's reconnecting DOM-morph client. */
|
||||
developmentRuntime?: boolean;
|
||||
}
|
||||
|
||||
export function productionEntry(appRoot: string): string {
|
||||
const entry = join(resolve(appRoot), "dist", "server.js");
|
||||
if (!existsSync(entry)) {
|
||||
throw new Error("WRN-PREVIEW-NO-BUILD: run `wrnexus build` before `wrnexus preview`.");
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function runPreview(appRoot: string, options: PreviewOptions = {}): ChildProcess {
|
||||
const entry = productionEntry(appRoot);
|
||||
const port = options.port ?? 3000;
|
||||
const hostname = options.hostname ?? "::";
|
||||
console.log(
|
||||
`\n ▶ WrNexus production preview — http://${hostname === "::" ? "localhost" : hostname}:${port}`,
|
||||
);
|
||||
return spawn(process.execPath, [entry], {
|
||||
cwd: resolve(appRoot),
|
||||
stdio: options.stdio ?? "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: "production",
|
||||
WRNEXUS_PROFILE: process.env.WRNEXUS_PROFILE ?? "production",
|
||||
PORT: String(port),
|
||||
HOST: hostname,
|
||||
...(options.developmentRuntime ? { WRNEXUS_PRODUCTION_DEV: "1" } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { basename, join, relative, resolve } from "node:path";
|
||||
import { diagnose } from "@wrnexus/compiler";
|
||||
import { currentCliVersion } from "./update-notifier.ts";
|
||||
|
||||
const SENSITIVE_KEY =
|
||||
/(?:secret|token|password|passwd|credential|api[-_]?key|private[-_]?key|cookie|authorization|session|dsn|database[-_]?url)/i;
|
||||
function sanitizeText(value: string): string {
|
||||
return value
|
||||
.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, "[REDACTED_EMAIL]")
|
||||
.replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, "[REDACTED_IP]")
|
||||
.replace(/https?:\/\/[^\s"'`/]+/gi, (origin) =>
|
||||
/localhost|127\.0\.0\.1|example\.(?:com|test|org)/i.test(origin)
|
||||
? origin
|
||||
: "https://[REDACTED_DOMAIN]",
|
||||
)
|
||||
.replace(/\b(?:sk|pk|wrn|ghp|xox[baprs])[_-][A-Za-z0-9_-]{12,}\b/g, "[REDACTED_TOKEN]")
|
||||
.replace(
|
||||
/((?:secret|token|password|apiKey|authorization|cookie)\s*[:=]\s*)(["'`])[^"'`]*\2/gi,
|
||||
"$1$2[REDACTED]$2",
|
||||
)
|
||||
.slice(0, 512 * 1024);
|
||||
}
|
||||
function sanitizeValue(value: unknown, key = ""): unknown {
|
||||
if (SENSITIVE_KEY.test(key)) return "[REDACTED]";
|
||||
if (typeof value === "string") return sanitizeText(value);
|
||||
if (Array.isArray(value)) return value.slice(0, 200).map((item) => sanitizeValue(item));
|
||||
if (value && typeof value === "object")
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.slice(0, 500)
|
||||
.map(([name, item]) => [name, sanitizeValue(item, name)]),
|
||||
);
|
||||
return value;
|
||||
}
|
||||
function packageVersions(root: string) {
|
||||
const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as {
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
};
|
||||
return Object.fromEntries(
|
||||
Object.entries({ ...manifest.dependencies, ...manifest.devDependencies }).sort(
|
||||
([left], [right]) => left.localeCompare(right),
|
||||
),
|
||||
);
|
||||
}
|
||||
export interface ReproductionReportOptions {
|
||||
file?: string;
|
||||
error?: string;
|
||||
output?: string;
|
||||
command?: string[];
|
||||
}
|
||||
export function generateReproductionReport(
|
||||
appRoot: string,
|
||||
options: ReproductionReportOptions = {},
|
||||
) {
|
||||
const root = resolve(appRoot);
|
||||
const selected = options.file ? resolve(root, options.file) : undefined;
|
||||
if (
|
||||
selected &&
|
||||
(!relative(root, selected) ||
|
||||
relative(root, selected).startsWith("..") ||
|
||||
!existsSync(selected))
|
||||
)
|
||||
throw new Error("WRN-REPORT-FILE: selected source must exist inside the application.");
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const output = resolve(root, options.output ?? join(".wrnexus", "reports", stamp));
|
||||
if (!relative(root, output) || relative(root, output).startsWith(".."))
|
||||
throw new Error("WRN-REPORT-OUTPUT: output must stay inside the application.");
|
||||
mkdirSync(output, { recursive: true });
|
||||
let source: string | undefined;
|
||||
let diagnostics: unknown[] = [];
|
||||
if (selected) {
|
||||
source = readFileSync(selected, "utf8");
|
||||
diagnostics = selected.endsWith(".wrn")
|
||||
? diagnose(source, { file: basename(selected), accessibility: true })
|
||||
: [];
|
||||
writeFileSync(
|
||||
join(output, `sanitized-${basename(selected)}`),
|
||||
`${sanitizeText(source)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
const configNames = ["wrnexus.config.ts", "wrnexus.config.js", "wrnexus.config.mjs"];
|
||||
const configFile = configNames.map((name) => join(root, name)).find(existsSync);
|
||||
const commands = options.command?.length
|
||||
? options.command
|
||||
: [
|
||||
"bun install --frozen-lockfile",
|
||||
selected ? "bunx wrnexus typecheck ." : "bunx wrnexus doctor .",
|
||||
"bunx wrnexus build .",
|
||||
];
|
||||
const report = sanitizeValue({
|
||||
schemaVersion: 1,
|
||||
frameworkVersion: currentCliVersion(),
|
||||
runtime: { bun: Bun.version, platform: process.platform, architecture: process.arch },
|
||||
source: selected ? relative(root, selected).replace(/\\/g, "/") : undefined,
|
||||
diagnostics,
|
||||
dependencies: packageVersions(root),
|
||||
config: configFile ? sanitizeText(readFileSync(configFile, "utf8")) : undefined,
|
||||
error: options.error,
|
||||
commands,
|
||||
});
|
||||
const reportFile = join(output, "report.json");
|
||||
writeFileSync(reportFile, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
||||
writeFileSync(
|
||||
join(output, "README.md"),
|
||||
`# Sanitized WRNexus reproduction\n\nGenerated by WRNexus ${currentCliVersion()}. Review the bundle before sharing. Values matching secrets, credentials, emails, IPs and non-public domains are redacted.\n\n## Reproduce\n\n${commands.map((command) => `- \`${command}\``).join("\n")}\n`,
|
||||
"utf8",
|
||||
);
|
||||
return {
|
||||
directory: output,
|
||||
reportFile,
|
||||
sourceFile: selected ? join(output, `sanitized-${basename(selected)}`) : undefined,
|
||||
};
|
||||
}
|
||||
export function runReport(appRoot: string, args: string[]) {
|
||||
const option = (name: string) =>
|
||||
args.find((value) => value.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
const result = generateReproductionReport(appRoot, {
|
||||
file: option("file"),
|
||||
error: option("error"),
|
||||
output: option("output"),
|
||||
});
|
||||
console.log(`✓ Sanitized reproduction bundle: ${result.directory}`);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { withSecurityHeaders } from "@wrnexus/core";
|
||||
import { isSafeUrl, secureCookieOptions } from "@wrnexus/security";
|
||||
import { loadAppConfig } from "@wrnexus/styles";
|
||||
|
||||
export interface SecurityAuditCheck {
|
||||
id: string;
|
||||
asvs: string[];
|
||||
passed: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface SecurityAuditReport {
|
||||
version: "ASVS 5.0.0";
|
||||
root: string;
|
||||
checks: SecurityAuditCheck[];
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
export async function securityHeaders(appRoot: string): Promise<Record<string, string>> {
|
||||
const root = resolve(appRoot);
|
||||
const config = await loadAppConfig(root);
|
||||
const response = withSecurityHeaders(
|
||||
new Request("https://security-audit.invalid/", {
|
||||
headers: { origin: "https://untrusted.invalid" },
|
||||
}),
|
||||
new Response("audit"),
|
||||
"production",
|
||||
config.security,
|
||||
"audit-nonce",
|
||||
);
|
||||
return Object.fromEntries(
|
||||
[...response.headers.entries()].sort(([left], [right]) => left.localeCompare(right)),
|
||||
);
|
||||
}
|
||||
|
||||
export async function securityAudit(appRoot: string): Promise<SecurityAuditReport> {
|
||||
const root = resolve(appRoot);
|
||||
const config = await loadAppConfig(root);
|
||||
const headers = await securityHeaders(root);
|
||||
const cors = typeof config.security?.cors === "object" ? config.security.cors : undefined;
|
||||
const checks: SecurityAuditCheck[] = [
|
||||
{
|
||||
id: "SEC-HEADERS",
|
||||
asvs: ["v5.0.0-3.4.1", "v5.0.0-3.4.6"],
|
||||
passed: config.security?.headers !== false && headers["x-content-type-options"] === "nosniff",
|
||||
message: "Browser security headers are enabled.",
|
||||
},
|
||||
{
|
||||
id: "SEC-CSP",
|
||||
asvs: ["v5.0.0-3.4.6"],
|
||||
passed:
|
||||
config.security?.contentSecurityPolicy !== false &&
|
||||
Boolean(headers["content-security-policy"]),
|
||||
message: "Nonce-capable Content Security Policy is enabled.",
|
||||
},
|
||||
{
|
||||
id: "SEC-CORS",
|
||||
asvs: ["v5.0.0-3.4.2"],
|
||||
passed:
|
||||
!(cors?.credentials && cors.origin === "*") &&
|
||||
!(cors?.credentials && Array.isArray(cors.origin) && cors.origin.includes("*")),
|
||||
message: "Credentialed CORS does not use a wildcard origin.",
|
||||
},
|
||||
{
|
||||
id: "SEC-CSRF",
|
||||
asvs: ["v5.0.0-3.5.1"],
|
||||
passed: true,
|
||||
message: "The shared runtime verifies double-submit CSRF tokens on unsafe requests.",
|
||||
},
|
||||
{
|
||||
id: "SEC-OUTPUT-ENCODING",
|
||||
asvs: ["v5.0.0-1.1.2", "v5.0.0-1.2.1", "v5.0.0-1.2.3"],
|
||||
passed: true,
|
||||
message: "Compiler HTML/attribute/JSON boundaries use contextual escaping.",
|
||||
},
|
||||
{
|
||||
id: "SEC-SSRF-REDIRECT",
|
||||
asvs: ["v5.0.0-1.3.6", "v5.0.0-3.7.2"],
|
||||
passed: !isSafeUrl("javascript:alert(1)"),
|
||||
message:
|
||||
"Unsafe URL protocols are rejected and outbound fetch uses allowlist/private-address controls.",
|
||||
},
|
||||
];
|
||||
return { version: "ASVS 5.0.0", root, checks, passed: checks.every((check) => check.passed) };
|
||||
}
|
||||
|
||||
function securityTests(root: string): string[] {
|
||||
const output: string[] = [];
|
||||
const walk = (dir: string): void => {
|
||||
if (!existsSync(dir)) return;
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (["node_modules", "dist", ".wrnexus"].includes(entry.name)) continue;
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(path);
|
||||
else if (/(?:security|abuse).*\.test\.[cm]?[jt]s$/i.test(entry.name)) output.push(path);
|
||||
}
|
||||
};
|
||||
walk(join(root, "app"));
|
||||
walk(join(root, "test"));
|
||||
return output;
|
||||
}
|
||||
|
||||
export async function runSecurityCommand(
|
||||
appRoot: string,
|
||||
subcommand = "audit",
|
||||
args: string[] = [],
|
||||
): Promise<boolean> {
|
||||
const root = resolve(appRoot);
|
||||
if (subcommand === "headers") {
|
||||
const headers = await securityHeaders(root);
|
||||
if (args.includes("--json")) console.log(JSON.stringify(headers, null, 2));
|
||||
else for (const [name, value] of Object.entries(headers)) console.log(`${name}: ${value}`);
|
||||
return true;
|
||||
}
|
||||
if (subcommand === "test") {
|
||||
const report = await securityAudit(root);
|
||||
secureCookieOptions({ url: new URL("https://security-audit.invalid/") });
|
||||
const tests = securityTests(root);
|
||||
if (!report.passed) return false;
|
||||
if (!tests.length) {
|
||||
console.log("✓ Built-in security probes passed; no application security test files found.");
|
||||
return true;
|
||||
}
|
||||
const result = Bun.spawnSync(["bun", "test", ...tests], {
|
||||
cwd: root,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
});
|
||||
return result.exitCode === 0;
|
||||
}
|
||||
if (subcommand !== "audit")
|
||||
throw new Error(`WRN-SECURITY-COMMAND: unknown command '${subcommand}'.`);
|
||||
const report = await securityAudit(root);
|
||||
if (args.includes("--json")) console.log(JSON.stringify(report, null, 2));
|
||||
else {
|
||||
console.log(`${report.version} application security audit\n`);
|
||||
for (const check of report.checks)
|
||||
console.log(
|
||||
`${check.passed ? "✓" : "✗"} ${check.id} [${check.asvs.join(", ")}] — ${check.message}`,
|
||||
);
|
||||
}
|
||||
return report.passed;
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
export interface LocalServiceRecord {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export interface LocalServicesState {
|
||||
database: Map<string, unknown>;
|
||||
cache: Map<string, { value: unknown; expiresAt?: number }>;
|
||||
mail: LocalServiceRecord[];
|
||||
sms: LocalServiceRecord[];
|
||||
webhooks: LocalServiceRecord[];
|
||||
storage: Map<string, Uint8Array>;
|
||||
queue: LocalServiceRecord[];
|
||||
cron: LocalServiceRecord[];
|
||||
auth: Map<string, LocalServiceRecord>;
|
||||
metrics: LocalServiceRecord[];
|
||||
}
|
||||
export function createLocalServicesState(): LocalServicesState {
|
||||
return {
|
||||
database: new Map(),
|
||||
cache: new Map(),
|
||||
mail: [],
|
||||
sms: [],
|
||||
webhooks: [],
|
||||
storage: new Map(),
|
||||
queue: [],
|
||||
cron: [],
|
||||
auth: new Map(),
|
||||
metrics: [],
|
||||
};
|
||||
}
|
||||
const json = (value: unknown, status = 200, origin = "https://localhost:3000") =>
|
||||
Response.json(value, {
|
||||
status,
|
||||
headers: {
|
||||
"cache-control": "no-store",
|
||||
"access-control-allow-origin": origin,
|
||||
"x-content-type-options": "nosniff",
|
||||
},
|
||||
});
|
||||
async function boundedJson(
|
||||
request: Request,
|
||||
maximum = 256 * 1024,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const text = await request.text();
|
||||
if (new TextEncoder().encode(text).byteLength > maximum)
|
||||
throw new RangeError("payload-too-large");
|
||||
const value = JSON.parse(text) as unknown;
|
||||
if (!value || typeof value !== "object" || Array.isArray(value))
|
||||
throw new TypeError("object-required");
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
function record(value: Record<string, unknown>): LocalServiceRecord {
|
||||
return { id: crypto.randomUUID(), createdAt: new Date().toISOString(), ...value };
|
||||
}
|
||||
export function createLocalServicesHandler(
|
||||
state = createLocalServicesState(),
|
||||
options: { origin?: string } = {},
|
||||
) {
|
||||
const origin = options.origin ?? "https://localhost:3000";
|
||||
return async (request: Request): Promise<Response> => {
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
if (request.method === "OPTIONS")
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
"access-control-allow-origin": origin,
|
||||
"access-control-allow-methods": "GET,POST,PUT,DELETE",
|
||||
"access-control-allow-headers": "content-type",
|
||||
},
|
||||
});
|
||||
if (path === "/healthz" || path === "/readyz")
|
||||
return json({ status: "up", service: "wrnexus-local-services" }, 200, origin);
|
||||
if (path === "/" || path === "/__services")
|
||||
return new Response(
|
||||
`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>WRNexus Local Services</title></head><body><h1>WRNexus Local Services</h1><nav>${["database", "cache", "mail", "sms", "webhooks", "storage", "queue", "cron", "auth", "metrics"].map((name) => `<a href="/${name}">${name}</a> `).join("")}</nav><p>Use the JSON endpoints to inspect or inject local development events.</p></body></html>`,
|
||||
{
|
||||
headers: {
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'",
|
||||
},
|
||||
},
|
||||
);
|
||||
try {
|
||||
if (path === "/database") {
|
||||
if (request.method === "GET") return json(Object.fromEntries(state.database), 200, origin);
|
||||
const body = await boundedJson(request);
|
||||
if (typeof body.key !== "string") return json({ error: "key is required" }, 400);
|
||||
state.database.set(body.key, body.value);
|
||||
return json({ key: body.key, value: body.value }, 201, origin);
|
||||
}
|
||||
if (path === "/cache") {
|
||||
if (request.method === "GET") {
|
||||
const now = Date.now();
|
||||
for (const [key, value] of state.cache)
|
||||
if (value.expiresAt && value.expiresAt <= now) state.cache.delete(key);
|
||||
return json(Object.fromEntries(state.cache), 200, origin);
|
||||
}
|
||||
const body = await boundedJson(request);
|
||||
if (typeof body.key !== "string") return json({ error: "key is required" }, 400);
|
||||
const ttlMs = typeof body.ttlMs === "number" ? Math.max(0, body.ttlMs) : undefined;
|
||||
state.cache.set(body.key, {
|
||||
value: body.value,
|
||||
...(ttlMs ? { expiresAt: Date.now() + ttlMs } : {}),
|
||||
});
|
||||
return json({ stored: true }, 201, origin);
|
||||
}
|
||||
for (const [name, values] of [
|
||||
["mail", state.mail],
|
||||
["sms", state.sms],
|
||||
["webhooks", state.webhooks],
|
||||
["queue", state.queue],
|
||||
["cron", state.cron],
|
||||
["metrics", state.metrics],
|
||||
] as const)
|
||||
if (path === `/${name}`) {
|
||||
if (request.method === "GET") return json(values, 200, origin);
|
||||
const value = record(await boundedJson(request));
|
||||
values.unshift(value);
|
||||
if (values.length > 500) values.length = 500;
|
||||
return json(value, 202, origin);
|
||||
}
|
||||
if (path === "/storage") {
|
||||
if (request.method === "GET")
|
||||
return json(
|
||||
[...state.storage.entries()].map(([key, value]) => ({ key, bytes: value.byteLength })),
|
||||
200,
|
||||
origin,
|
||||
);
|
||||
const key = url.searchParams.get("key");
|
||||
if (!key || key.includes("..") || key.length > 256)
|
||||
return json({ error: "safe key is required" }, 400);
|
||||
const bytes = new Uint8Array(await request.arrayBuffer());
|
||||
if (bytes.byteLength > 10 * 1024 * 1024) return json({ error: "object too large" }, 413);
|
||||
state.storage.set(key, bytes);
|
||||
return json({ key, bytes: bytes.byteLength }, 201, origin);
|
||||
}
|
||||
if (path.startsWith("/storage/") && request.method === "GET") {
|
||||
const key = decodeURIComponent(path.slice(9));
|
||||
const value = state.storage.get(key);
|
||||
return value
|
||||
? new Response(Uint8Array.from(value), {
|
||||
headers: {
|
||||
"content-type": "application/octet-stream",
|
||||
"content-disposition": "attachment",
|
||||
"x-content-type-options": "nosniff",
|
||||
},
|
||||
})
|
||||
: json({ error: "not found" }, 404);
|
||||
}
|
||||
if (path === "/auth") {
|
||||
if (request.method === "GET") return json([...state.auth.values()], 200, origin);
|
||||
const value = record(await boundedJson(request));
|
||||
if (typeof value.email !== "string") return json({ error: "email is required" }, 400);
|
||||
state.auth.set(value.id, value);
|
||||
return json({ user: value, accessToken: `local_${value.id}` }, 201, origin);
|
||||
}
|
||||
return json({ error: "not found" }, 404);
|
||||
} catch (error) {
|
||||
if (error instanceof RangeError) return json({ error: error.message }, 413);
|
||||
return json({ error: error instanceof Error ? error.message : "invalid request" }, 400);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export interface LocalCertificate {
|
||||
cert: string;
|
||||
key: string;
|
||||
certFile: string;
|
||||
keyFile: string;
|
||||
reused: boolean;
|
||||
}
|
||||
|
||||
/** Generate and cache a localhost-only development certificate without requiring OpenSSL. */
|
||||
export async function ensureLocalCertificate(appRoot: string): Promise<LocalCertificate> {
|
||||
const directory = join(resolve(appRoot), ".wrnexus", "certificates");
|
||||
const certFile = join(directory, "localhost.pem");
|
||||
const keyFile = join(directory, "localhost-key.pem");
|
||||
if (existsSync(certFile) && existsSync(keyFile)) {
|
||||
try {
|
||||
const cert = readFileSync(certFile, "utf8");
|
||||
const key = readFileSync(keyFile, "utf8");
|
||||
const certificate = new X509Certificate(cert);
|
||||
if (Date.parse(certificate.validTo) > Date.now() + 7 * 24 * 60 * 60 * 1000) {
|
||||
return { cert, key, certFile, keyFile, reused: true };
|
||||
}
|
||||
} catch {
|
||||
// Replace invalid or expired development material below.
|
||||
}
|
||||
}
|
||||
const now = new Date();
|
||||
const expires = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000);
|
||||
const generated = await generate([{ name: "commonName", value: "localhost" }], {
|
||||
algorithm: "sha256",
|
||||
keyType: "ec",
|
||||
curve: "P-256",
|
||||
notBeforeDate: new Date(now.getTime() - 60_000),
|
||||
notAfterDate: expires,
|
||||
extensions: [
|
||||
{ name: "basicConstraints", cA: false, critical: true },
|
||||
{ name: "keyUsage", digitalSignature: true, keyEncipherment: true, critical: true },
|
||||
{ name: "extKeyUsage", serverAuth: true },
|
||||
{
|
||||
name: "subjectAltName",
|
||||
altNames: [
|
||||
{ type: 2, value: "localhost" },
|
||||
{ type: 2, value: "*.localhost" },
|
||||
{ type: 7, ip: "127.0.0.1" },
|
||||
{ type: 7, ip: "::1" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
mkdirSync(directory, { recursive: true });
|
||||
writeFileSync(certFile, generated.cert, { encoding: "utf8", mode: 0o600 });
|
||||
writeFileSync(keyFile, generated.private, { encoding: "utf8", mode: 0o600 });
|
||||
try {
|
||||
chmodSync(certFile, 0o600);
|
||||
chmodSync(keyFile, 0o600);
|
||||
} catch {
|
||||
// Windows ACLs are inherited from the private workspace directory.
|
||||
}
|
||||
return {
|
||||
cert: generated.cert,
|
||||
key: generated.private,
|
||||
certFile,
|
||||
keyFile,
|
||||
reused: false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function startLocalServices(
|
||||
options: {
|
||||
appRoot?: string;
|
||||
port?: number;
|
||||
hostname?: string;
|
||||
https?: boolean;
|
||||
origin?: string;
|
||||
certificate?: LocalCertificate;
|
||||
} = {},
|
||||
) {
|
||||
const port = options.port ?? 3099;
|
||||
const hostname = options.hostname ?? "127.0.0.1";
|
||||
const secure = options.https !== false;
|
||||
const tls = secure
|
||||
? (options.certificate ?? (await ensureLocalCertificate(options.appRoot ?? ".")))
|
||||
: undefined;
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
hostname,
|
||||
fetch: createLocalServicesHandler(undefined, { origin: options.origin }),
|
||||
...(tls ? { tls: { cert: tls.cert, key: tls.key } } : {}),
|
||||
});
|
||||
console.log(
|
||||
` ▸ Local services: ${secure ? "https" : "http"}://${hostname}:${server.port}/__services`,
|
||||
);
|
||||
if (tls) console.log(` certificate: ${tls.certFile} (trust locally to remove warnings)`);
|
||||
console.log(" database cache mail sms webhooks storage queue cron auth metrics");
|
||||
return server;
|
||||
}
|
||||
import { X509Certificate } from "node:crypto";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { generate } from "selfsigned";
|
||||
+157
-24
@@ -1,26 +1,159 @@
|
||||
/**
|
||||
* `wrnexus test [app-dir] [--watch] [--profile=test]` — run the app's test files
|
||||
* with `bun test`. Defaults to the `test` profile (config + .env.test). Extra
|
||||
* args after `--` (or bun test flags) pass straight through.
|
||||
*/
|
||||
/** Level-aware `wrnexus test` runner with Bun and optional Playwright backends. */
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export function runTests(appRoot: string, args: string[]): void {
|
||||
const root = resolve(appRoot);
|
||||
const watch = args.includes("--watch");
|
||||
const passthrough = args.filter(
|
||||
(a) => !a.startsWith("--profile=") && a !== "--watch" && a !== appRoot,
|
||||
);
|
||||
|
||||
const child = spawn(
|
||||
process.execPath, // the Bun binary
|
||||
["test", ...(watch ? ["--watch"] : []), ...passthrough],
|
||||
{ stdio: "inherit", cwd: root },
|
||||
);
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) return;
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
export const TEST_LEVELS = [
|
||||
"unit",
|
||||
"component",
|
||||
"api",
|
||||
"browser",
|
||||
"visual",
|
||||
"accessibility",
|
||||
"performance",
|
||||
] as const;
|
||||
export type TestLevel = (typeof TEST_LEVELS)[number];
|
||||
export interface TestCommandPlan {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
level?: TestLevel;
|
||||
files: string[];
|
||||
setup?: { command: string; args: string[] };
|
||||
}
|
||||
|
||||
function shardFiles(files: string[], value?: string): string[] {
|
||||
if (!value) return files;
|
||||
const match = /^(\d+)\/(\d+)$/.exec(value);
|
||||
if (!match) throw new Error("WRN-TEST-SHARD: expected --shard=<index>/<total>");
|
||||
const index = Number(match[1]);
|
||||
const total = Number(match[2]);
|
||||
if (index < 1 || total < 1 || index > total)
|
||||
throw new Error("WRN-TEST-SHARD: index must be between 1 and total");
|
||||
return files.filter((_file, position) => position % total === index - 1);
|
||||
}
|
||||
|
||||
function testFiles(root: string): string[] {
|
||||
const files: string[] = [];
|
||||
const walk = (directory: string): void => {
|
||||
if (!existsSync(directory)) return;
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
if (["node_modules", "dist", ".wrnexus", "coverage"].includes(entry.name)) continue;
|
||||
const path = join(directory, entry.name);
|
||||
if (entry.isDirectory()) walk(path);
|
||||
else if (/\.(?:test|spec)\.[cm]?[jt]sx?$/i.test(entry.name)) files.push(path);
|
||||
}
|
||||
};
|
||||
for (const directory of ["app", "test", "tests"]) walk(join(root, directory));
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
function hasPlaywright(root: string): boolean {
|
||||
if (
|
||||
["playwright.config.ts", "playwright.config.js", "playwright.config.mjs"].some((file) =>
|
||||
existsSync(join(root, file)),
|
||||
)
|
||||
)
|
||||
return true;
|
||||
try {
|
||||
const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
||||
return Boolean(
|
||||
manifest.dependencies?.["@playwright/test"] || manifest.devDependencies?.["@playwright/test"],
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createTestPlan(appRoot: string, args: string[]): TestCommandPlan {
|
||||
const root = resolve(appRoot);
|
||||
const level = args.find((value): value is TestLevel => TEST_LEVELS.includes(value as TestLevel));
|
||||
const watch = args.includes("--watch");
|
||||
const shard = args.find((value) => value.startsWith("--shard="))?.slice(8);
|
||||
const browsers = (args.find((value) => value.startsWith("--browsers="))?.slice(11) ?? "chromium")
|
||||
.split(",")
|
||||
.filter(Boolean);
|
||||
const passthrough = args.filter(
|
||||
(value) =>
|
||||
!value.startsWith("--profile=") &&
|
||||
value !== "--watch" &&
|
||||
value !== appRoot &&
|
||||
value !== level &&
|
||||
!value.startsWith("--browsers=") &&
|
||||
!value.startsWith("--shard=") &&
|
||||
value !== "--install-browsers",
|
||||
);
|
||||
if ((level === "browser" || level === "visual") && hasPlaywright(root)) {
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: [
|
||||
"x",
|
||||
"playwright",
|
||||
"test",
|
||||
...(level === "visual" ? ["--grep", "@visual"] : []),
|
||||
...browsers.flatMap((browser) => ["--project", browser]),
|
||||
...(shard ? [`--shard=${shard}`] : []),
|
||||
"--reporter=line,html",
|
||||
...passthrough,
|
||||
],
|
||||
cwd: root,
|
||||
level,
|
||||
files: [],
|
||||
...(args.includes("--install-browsers")
|
||||
? {
|
||||
setup: { command: process.execPath, args: ["x", "playwright", "install", ...browsers] },
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
const pattern =
|
||||
level === "accessibility"
|
||||
? /(?:accessibility|a11y)/i
|
||||
: level === "performance"
|
||||
? /(?:performance|benchmark)/i
|
||||
: level
|
||||
? new RegExp(level, "i")
|
||||
: null;
|
||||
const files = shardFiles(
|
||||
pattern ? testFiles(root).filter((file) => pattern.test(relative(root, file))) : [],
|
||||
shard,
|
||||
);
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: ["test", ...(watch ? ["--watch"] : []), ...(pattern ? files : []), ...passthrough],
|
||||
cwd: root,
|
||||
level,
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
export function runTests(appRoot: string, args: string[]): ChildProcess | null {
|
||||
const plan = createTestPlan(appRoot, args);
|
||||
if (plan.level && !plan.files.length && !plan.args.includes("playwright")) {
|
||||
console.error(
|
||||
`WRN-TEST-NO-FILES: no ${plan.level} tests found. Name a file or directory with '${plan.level}' under app/, test/, or tests/.`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return null;
|
||||
}
|
||||
const launch = () => spawn(plan.command, plan.args, { stdio: "inherit", cwd: plan.cwd });
|
||||
if (plan.setup) {
|
||||
const setup = spawn(plan.setup.command, plan.setup.args, { stdio: "inherit", cwd: plan.cwd });
|
||||
setup.on("exit", (code, signal) => {
|
||||
if (signal || code !== 0) {
|
||||
process.exitCode = code ?? 1;
|
||||
return;
|
||||
}
|
||||
const test = launch();
|
||||
test.on("exit", (testCode, testSignal) => {
|
||||
if (!testSignal) process.exitCode = testCode ?? 0;
|
||||
});
|
||||
});
|
||||
return setup;
|
||||
}
|
||||
const child = launch();
|
||||
child.on("exit", (code, signal) => {
|
||||
if (!signal) process.exitCode = code ?? 0;
|
||||
});
|
||||
return child;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
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 { regenerateRoutes } from "./routes.ts";
|
||||
import { loadAppConfig } from "@wrnexus/styles";
|
||||
import { createPluginRunner, discoverPlugins, type PluginContributions } from "@wrnexus/plugin";
|
||||
|
||||
function files(root: string, predicate: (path: string) => boolean): string[] {
|
||||
if (!existsSync(root)) return [];
|
||||
const output: string[] = [];
|
||||
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
||||
const path = join(root, entry.name);
|
||||
if (entry.isDirectory()) output.push(...files(path, predicate));
|
||||
else if (predicate(path)) output.push(path);
|
||||
}
|
||||
return output.sort();
|
||||
}
|
||||
|
||||
function literalUnion(values: Iterable<string>): string {
|
||||
const unique = [...new Set(values)].sort();
|
||||
return unique.length ? unique.map((value) => JSON.stringify(value)).join(" | ") : "never";
|
||||
}
|
||||
|
||||
function typeImport(fromDirectory: string, file: string): string {
|
||||
const path = relative(fromDirectory, file).replace(/\\/g, "/");
|
||||
return JSON.stringify(path.startsWith(".") ? path : `./${path}`);
|
||||
}
|
||||
|
||||
function exportedHandlers(source: string): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
[
|
||||
...source.matchAll(
|
||||
/\bexport\s+(?:const|async\s+function|function)\s+(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\b/g,
|
||||
),
|
||||
].map((match) => match[1]!),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function flatten(value: unknown, prefix = ""): string[] {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return prefix ? [prefix] : [];
|
||||
return Object.entries(value).flatMap(([key, child]) =>
|
||||
flatten(child, prefix ? `${prefix}.${key}` : key),
|
||||
);
|
||||
}
|
||||
|
||||
function environmentKeys(root: string): string[] {
|
||||
return files(root, (path) => /^\.env(?:\.[\w-]+)?(?:\.example)?$/.test(basename(path))).flatMap(
|
||||
(path) =>
|
||||
readFileSync(path, "utf8")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/)?.[1])
|
||||
.filter((key): key is string => Boolean(key)),
|
||||
);
|
||||
}
|
||||
|
||||
export interface GeneratedApplicationTypes {
|
||||
file: string;
|
||||
routes: number;
|
||||
components: number;
|
||||
}
|
||||
|
||||
function writePluginArtifacts(root: string, contributions?: PluginContributions): void {
|
||||
if (!contributions) return;
|
||||
const typeDir = join(root, "app", "types");
|
||||
mkdirSync(typeDir, { recursive: true });
|
||||
const references = contributions.typeDefinitions.map((entry) => {
|
||||
const target = resolve(root, entry);
|
||||
const specifier = relative(typeDir, target).replace(/\\/g, "/");
|
||||
return `/// <reference path=${JSON.stringify(specifier.startsWith(".") ? specifier : `./${specifier}`)} />`;
|
||||
});
|
||||
writeFileSync(
|
||||
join(typeDir, "wrnexus.plugins.generated.d.ts"),
|
||||
`// AUTO-GENERATED plugin type aggregation - do not edit.\n${references.join("\n")}\n`,
|
||||
"utf8",
|
||||
);
|
||||
const docsDir = join(root, ".wrnexus", "documentation");
|
||||
mkdirSync(docsDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(docsDir, "plugins.md"),
|
||||
`# Plugin documentation\n\n${contributions.documentation
|
||||
.map(
|
||||
(entry) => `- [${entry}](${relative(docsDir, resolve(root, entry)).replace(/\\/g, "/")})`,
|
||||
)
|
||||
.join("\n")}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
export function generateApplicationTypes(
|
||||
appRoot: string,
|
||||
pluginContributions?: PluginContributions,
|
||||
): GeneratedApplicationTypes {
|
||||
const root = resolve(appRoot);
|
||||
const app = join(root, "app");
|
||||
const router = buildRouter(app);
|
||||
regenerateRoutes(app);
|
||||
const componentFiles = files(join(app, "components"), (path) => extname(path) === ".wrn");
|
||||
const components = componentFiles.map((path) => parse(readFileSync(path, "utf8")));
|
||||
const localeKeys = files(join(app, "locales"), (path) => extname(path) === ".json").flatMap(
|
||||
(path) => {
|
||||
try {
|
||||
return flatten(JSON.parse(readFileSync(path, "utf8")));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
);
|
||||
const source = files(app, (path) => /\.(?:ts|tsx|js|wrn)$/.test(path))
|
||||
.map((path) => readFileSync(path, "utf8"))
|
||||
.join("\n");
|
||||
const cacheKeys = [
|
||||
...source.matchAll(/\b(?:cache|invalidate(?:Tag)?)\s*\(\s*["'`]([^"'`]+)["'`]/g),
|
||||
].map((match) => match[1]);
|
||||
const queueNames = files(join(app, "queues"), (path) => /\.(?:ts|js)$/.test(path)).map((path) =>
|
||||
basename(path, extname(path)),
|
||||
);
|
||||
const manifest = createRouteManifest(nameRoutes(router.pages));
|
||||
const componentMap = components
|
||||
.map((component) => {
|
||||
const props = component.props
|
||||
.map(
|
||||
(prop) =>
|
||||
`${JSON.stringify(prop.name)}${prop.required ? "" : "?"}: ${prop.valueType ?? "unknown"}`,
|
||||
)
|
||||
.join("; ");
|
||||
const outputs = component.outputs
|
||||
.map(
|
||||
(output) =>
|
||||
`${JSON.stringify(output.name)}: (${output.payload ? `${output.payload.name}${output.payload.optional ? "?" : ""}: ${output.payload.valueType}` : ""}) => void`,
|
||||
)
|
||||
.join("; ");
|
||||
return ` ${JSON.stringify(component.name)}: { props: ${props ? `{ ${props} }` : "Record<string, never>"}; outputs: ${outputs ? `{ ${outputs} }` : "Record<string, never>"} };`;
|
||||
})
|
||||
.join("\n");
|
||||
const typeDir = join(app, "types");
|
||||
const apiContracts = router.api
|
||||
.map((route) => {
|
||||
const source = readFileSync(route.file, "utf8");
|
||||
const methods = exportedHandlers(source);
|
||||
const module = `typeof import(${typeImport(typeDir, route.file)})`;
|
||||
const entries = methods
|
||||
.map((method) => `${method}: ApiContract<${module}[${JSON.stringify(method)}]>`)
|
||||
.join("; ");
|
||||
return ` ${JSON.stringify(route.raw)}: { ${entries || `default: ApiContract<${module}["default"]>`} };`;
|
||||
})
|
||||
.join("\n");
|
||||
const middlewareContracts = router.middlewareFiles
|
||||
.map((file) => {
|
||||
const name = basename(file, extname(file));
|
||||
return ` ${JSON.stringify(name)}: MiddlewareContext<(typeof import(${typeImport(typeDir, file)}))["default"]>;`;
|
||||
})
|
||||
.join("\n");
|
||||
const databaseContracts = files(join(app, "db"), (path) => /queries\.gen\.ts$/.test(path))
|
||||
.flatMap((file) => {
|
||||
const module = `typeof import(${typeImport(typeDir, file)})`;
|
||||
return [
|
||||
...readFileSync(file, "utf8").matchAll(/\bexport\s+async\s+function\s+([A-Za-z_$][\w$]*)/g),
|
||||
].map(
|
||||
(match) =>
|
||||
` ${JSON.stringify(match[1]!)}: QueryContract<${module}[${JSON.stringify(match[1]!)}]>;`,
|
||||
);
|
||||
})
|
||||
.join("\n");
|
||||
const realtimeContracts = router.realtime
|
||||
.map(
|
||||
(route) =>
|
||||
` ${JSON.stringify(route.raw)}: RealtimeMessage<(typeof import(${typeImport(typeDir, route.file)}))["default"]>;`,
|
||||
)
|
||||
.join("\n");
|
||||
const queueContracts = files(join(app, "queues"), (path) => /\.(?:ts|js)$/.test(path))
|
||||
.map((file) => {
|
||||
const name = basename(file, extname(file));
|
||||
return ` ${JSON.stringify(name)}: QueuePayload<(typeof import(${typeImport(typeDir, file)}))["default"]>;`;
|
||||
})
|
||||
.join("\n");
|
||||
const configFile = ["wrnexus.config.ts", "wrnexus.config.js", "wrnexus.config.mjs"]
|
||||
.map((name) => join(root, name))
|
||||
.find(existsSync);
|
||||
const code = `// AUTO-GENERATED by \`wrnexus generate types\` - do not edit.
|
||||
declare namespace WRNexusGenerated {
|
||||
type ApiContract<T> = T extends import("@wrnexus/core").DefinedEndpoint<infer I, infer O>
|
||||
? { input: I; output: O }
|
||||
: T extends (...args: infer A) => infer R
|
||||
? { input: A extends [any, infer I, ...any[]] ? I : unknown; output: Awaited<R> }
|
||||
: { input: unknown; output: unknown };
|
||||
type MiddlewareContext<T> = T extends (ctx: infer C, ...args: any[]) => any ? C : never;
|
||||
type QueryContract<T> = T extends (db: any, args: infer A, ...rest: any[]) => infer R
|
||||
? { args: A; result: Awaited<R> }
|
||||
: T extends (db: any, ...rest: any[]) => infer R
|
||||
? { args: Record<string, never>; result: Awaited<R> }
|
||||
: never;
|
||||
type RealtimeMessage<T> = T extends import("@wrnexus/core").RoomDefinition<any, infer M> ? M : unknown;
|
||||
type QueuePayload<T> = T extends import("@wrnexus/queue").JobDefinition<infer I> ? I : unknown;
|
||||
type RouteName = ${literalUnion(manifest.map((route) => route.name))};
|
||||
type ApiRoute = ${literalUnion(router.api.map((route) => route.raw))};
|
||||
type RealtimeRoute = ${literalUnion(router.realtime.map((route) => route.raw))};
|
||||
type EnvironmentKey = ${literalUnion(environmentKeys(root))};
|
||||
type TranslationKey = ${literalUnion(localeKeys)};
|
||||
type QueueName = ${literalUnion(queueNames)};
|
||||
type CacheKey = ${literalUnion(cacheKeys)};
|
||||
interface Components {
|
||||
${componentMap}
|
||||
}
|
||||
interface ApiContracts {
|
||||
${apiContracts}
|
||||
}
|
||||
interface MiddlewareContexts {
|
||||
${middlewareContracts}
|
||||
}
|
||||
interface DatabaseQueries {
|
||||
${databaseContracts}
|
||||
}
|
||||
interface RealtimeMessages {
|
||||
${realtimeContracts}
|
||||
}
|
||||
interface QueuePayloads {
|
||||
${queueContracts}
|
||||
}
|
||||
type ApplicationConfig = ${configFile ? `(typeof import(${typeImport(typeDir, configFile)}))["default"]` : "Record<string, never>"};
|
||||
}
|
||||
`;
|
||||
mkdirSync(typeDir, { recursive: true });
|
||||
const output = join(typeDir, "wrnexus.generated.d.ts");
|
||||
writeFileSync(output, code, "utf8");
|
||||
writePluginArtifacts(root, pluginContributions);
|
||||
return {
|
||||
file: relative(root, output).replace(/\\/g, "/"),
|
||||
routes: manifest.length,
|
||||
components: components.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Discover configured plugins and include their type/docs contributions in generated artifacts. */
|
||||
export async function generateApplicationTypesWithPlugins(
|
||||
appRoot: string,
|
||||
): Promise<GeneratedApplicationTypes> {
|
||||
const root = resolve(appRoot);
|
||||
const config = await loadAppConfig(root);
|
||||
const input = await discoverPlugins(root, config.plugins, {
|
||||
includeDevDependencies: true,
|
||||
strict: true,
|
||||
});
|
||||
const runner = createPluginRunner(input, {
|
||||
root,
|
||||
mode: "development",
|
||||
command: "cli",
|
||||
profile: process.env.WRNEXUS_PROFILE,
|
||||
metadata: new Map(),
|
||||
warn: (message) => console.warn(`[wrnexus:plugin] ${message}`),
|
||||
});
|
||||
await runner.configure(config as Record<string, unknown>);
|
||||
await runner.configResolved(config as Readonly<Record<string, unknown>>);
|
||||
return generateApplicationTypes(root, await runner.contributions());
|
||||
}
|
||||
|
||||
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 }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function runTypecheck(appRoot: string): Promise<boolean> {
|
||||
const root = resolve(appRoot);
|
||||
await generateApplicationTypesWithPlugins(root);
|
||||
const diagnostics = checkApplication(root);
|
||||
for (const item of diagnostics)
|
||||
console.error(`${item.file}:${item.line}:${item.column} ${item.code} ${item.message}`);
|
||||
let tsOk = true;
|
||||
if (existsSync(join(root, "tsconfig.json"))) {
|
||||
const process = Bun.spawnSync(["bunx", "tsc", "--noEmit"], {
|
||||
cwd: root,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
});
|
||||
tsOk = process.exitCode === 0;
|
||||
}
|
||||
return diagnostics.every((item) => item.category !== "error") && tsOk;
|
||||
}
|
||||
+115
-1
@@ -30,6 +30,7 @@ import {
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { basename, dirname, join, relative, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { formatWrn, parse } from "@wrnexus/syntax";
|
||||
import { AI_GUIDE, CLAUDE_MD } from "./ai-guide.ts";
|
||||
import { inspectProject, type DoctorCheck } from "./doctor.ts";
|
||||
|
||||
@@ -189,7 +190,20 @@ function quoteLegacyDynamicAttributes(source: string): string {
|
||||
}
|
||||
|
||||
export function migrateWrnSource(source: string): string {
|
||||
return formatInlineProps(quoteLegacyDynamicAttributes(source));
|
||||
return formatInlineProps(quoteLegacyDynamicAttributes(source))
|
||||
.replace(/[ \t]+$/gm, "")
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.replace(/\n{3,}$/g, "\n\n")
|
||||
.replace(/\s*$/, "\n");
|
||||
}
|
||||
|
||||
export function formatCurrentWrnSource(source: string): string {
|
||||
return formatWrn(migrateWrnSource(source), {
|
||||
insertSpaces: true,
|
||||
tabSize: 2,
|
||||
printWidth: 100,
|
||||
multilineAttributes: true,
|
||||
});
|
||||
}
|
||||
|
||||
function findMatching(source: string, open: number, openChar = "{", closeChar = "}"): number {
|
||||
@@ -1857,6 +1871,106 @@ const MIGRATIONS: Migration[] = [
|
||||
if (!ctx.report.needsReview.includes(review)) ctx.report.needsReview.push(review);
|
||||
},
|
||||
},
|
||||
{
|
||||
version: "0.8.0",
|
||||
id: "0.8.0-01-package-kits",
|
||||
description:
|
||||
"Adds package-owned helper kits, reusable UI blocks, standalone realtime rooms, repaired i18n, image helpers, JWT utilities, and encrypted HTTP envelopes.",
|
||||
apply(ctx) {
|
||||
const file = join(ctx.appRoot, "package.json");
|
||||
if (!existsSync(file)) return;
|
||||
const pkg = JSON.parse(readFileSync(file, "utf8")) as Record<string, any>;
|
||||
const dependencies = (pkg.dependencies ??= {});
|
||||
const additions = ["@wrnexus/realtime", "@wrnexus/csr"];
|
||||
const added: string[] = [];
|
||||
for (const name of additions) {
|
||||
if (dependencies[name] === `^${ctx.to}`) continue;
|
||||
dependencies[name] = `^${ctx.to}`;
|
||||
added.push(name);
|
||||
}
|
||||
if (added.length) {
|
||||
ctx.log(`+ package-kit dependencies: ${added.join(", ")}`);
|
||||
if (!ctx.dryRun) writeFileSync(file, JSON.stringify(pkg, null, 2) + "\n", "utf8");
|
||||
}
|
||||
const review =
|
||||
"Review package-owned components and helpers, i18n locale layout, encrypted HTTP trust boundaries, and realtime room authorization before enabling them in production.";
|
||||
if (!ctx.report.needsReview.includes(review)) ctx.report.needsReview.push(review);
|
||||
},
|
||||
},
|
||||
{
|
||||
version: "0.8.0",
|
||||
id: "0.8.0-02-current-wrn-source",
|
||||
description:
|
||||
"Upgrades every application WRN source to current syntax, adds resolvable imports, normalizes formatting, and records unresolved work.",
|
||||
apply(ctx) {
|
||||
const appDirectory = join(ctx.appRoot, "app");
|
||||
if (!existsSync(appDirectory)) return;
|
||||
const index = buildV060SymbolIndex(ctx.appRoot);
|
||||
const changedFiles: string[] = [];
|
||||
|
||||
for (const file of walkProjectFiles(appDirectory, ".wrn")) {
|
||||
const relativeFile = relative(ctx.appRoot, file).replace(/\\/g, "/");
|
||||
const before = readFileSync(file, "utf8");
|
||||
let after = migrateV060WrnSource(before, ctx.report, relativeFile);
|
||||
after = migrateImportedLayout(after, relativeFile, ctx.appRoot, index, ctx.report);
|
||||
after = addExplicitImportsToSource(after, relativeFile, ctx.appRoot, index, ctx.report);
|
||||
after = formatCurrentWrnSource(after);
|
||||
if (after === before) continue;
|
||||
|
||||
try {
|
||||
parse(after);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message.split("\n", 1)[0] : String(error);
|
||||
ctx.report.parseFailures.push(`${relativeFile}: ${message}`);
|
||||
ctx.report.needsReview.push(
|
||||
`${relativeFile}: automatic modernization was skipped because the migrated source did not parse`,
|
||||
);
|
||||
ctx.log(`! ${relativeFile}: left unchanged because migrated source did not parse`);
|
||||
continue;
|
||||
}
|
||||
|
||||
changedFiles.push(relativeFile);
|
||||
if (!ctx.report.changedAutomatically.includes(relativeFile)) {
|
||||
ctx.report.changedAutomatically.push(relativeFile);
|
||||
}
|
||||
ctx.log(`~ ${relativeFile}: current WRN syntax, imports, and formatting`);
|
||||
if (!ctx.dryRun) writeFileSync(file, after, "utf8");
|
||||
}
|
||||
|
||||
const reportFile = join(
|
||||
ctx.appRoot,
|
||||
".wrnexus",
|
||||
"migrations",
|
||||
"0.8.0-source-modernization.json",
|
||||
);
|
||||
ctx.log(
|
||||
`+ .wrnexus/migrations/0.8.0-source-modernization.json (${changedFiles.length} WRN files updated)`,
|
||||
);
|
||||
if (!ctx.dryRun) {
|
||||
mkdirSync(dirname(reportFile), { recursive: true });
|
||||
writeFileSync(
|
||||
reportFile,
|
||||
JSON.stringify(
|
||||
{
|
||||
version: "0.8.0",
|
||||
from: ctx.from,
|
||||
to: ctx.to,
|
||||
appliedAt: new Date().toISOString(),
|
||||
changedFiles,
|
||||
unresolvedImports: ctx.report.unresolvedImports,
|
||||
ambiguousFunctions: ctx.report.ambiguousFunctions,
|
||||
legacyOutputPayloads: ctx.report.legacyOutputPayloads,
|
||||
parseFailures: ctx.report.parseFailures,
|
||||
needsReview: ctx.report.needsReview,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Release tooling uses this to require an explicit migration entry per version. */
|
||||
|
||||
@@ -75,10 +75,25 @@ export const workspaceFiles = (name: string): Record<string, string> => ({
|
||||
"scripts": {
|
||||
"dev": "wrnexus gateway",
|
||||
"gateway": "wrnexus gateway",
|
||||
"production": "wrnexus production"
|
||||
"staging": "wrnexus staging",
|
||||
"production": "wrnexus production",
|
||||
"typecheck": "tsc --noEmit && bun run --filter './apps/*' typecheck",
|
||||
"test": "bun run --filter './apps/*' test",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier . --write",
|
||||
"format:check": "prettier . --check",
|
||||
"doctor": "bun run --filter './apps/*' doctor",
|
||||
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@wrnexus/cli": "${frameworkVersion}"
|
||||
"@wrnexus/cli": "${frameworkVersion}",
|
||||
"@eslint/js": "^9.0.0",
|
||||
"@types/bun": "latest",
|
||||
"eslint": "^9.0.0",
|
||||
"prettier": "latest",
|
||||
"typescript": "^5.5.0",
|
||||
"typescript-eslint": "latest"
|
||||
}
|
||||
}
|
||||
`,
|
||||
@@ -119,8 +134,109 @@ export default config;
|
||||
".gitignore": `node_modules/
|
||||
dist/
|
||||
.wrnexus/
|
||||
**/.wrnexus/
|
||||
coverage/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.*.example
|
||||
*.log
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
uploads/
|
||||
mobile/android/
|
||||
mobile/ios/
|
||||
mobile/.expo/
|
||||
.idea/
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/extensions.json
|
||||
*.tsbuildinfo
|
||||
.eslintcache
|
||||
`,
|
||||
".env.example": `REDIS_URL=redis://localhost:6379
|
||||
AUTH_SECRET=replace-with-at-least-32-random-characters
|
||||
`,
|
||||
".prettierrc.json": `{
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
`,
|
||||
".prettierignore": `node_modules/
|
||||
dist/
|
||||
.wrnexus/
|
||||
**/.wrnexus/
|
||||
*.log
|
||||
**/CLAUDE.md
|
||||
`,
|
||||
".editorconfig": `root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
`,
|
||||
"eslint.config.js": `import { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import js from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
const tsconfigRootDir = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["node_modules/**", "dist/**", "**/dist/**", ".wrnexus/**", "**/.wrnexus/**"] },
|
||||
{ languageOptions: { parserOptions: { tsconfigRootDir } } },
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
rules: {
|
||||
"no-undef": "off",
|
||||
"no-console": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
`,
|
||||
"tsconfig.json": `{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"types": ["bun"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": ["wrnexus.workspace.ts", "packages/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "apps"]
|
||||
}
|
||||
`,
|
||||
".vscode/settings.json": `{
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" },
|
||||
"prettier.requireConfig": true,
|
||||
"[wrn]": { "editor.defaultFormatter": "wrnexus.wrnexus", "editor.formatOnSave": true }
|
||||
}
|
||||
`,
|
||||
".vscode/extensions.json": `{
|
||||
"recommendations": ["wrnexus.wrnexus", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
|
||||
}
|
||||
`,
|
||||
"packages/shared/package.json": `{
|
||||
"name": "@app/shared",
|
||||
@@ -129,6 +245,10 @@ dist/
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": { ".": "./src/index.ts" },
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/pubsub": "${frameworkVersion}"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { createOpenApi, generateApiArtifacts, inspectApi } from "../src/api-command.ts";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () =>
|
||||
Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))),
|
||||
);
|
||||
async function fixture() {
|
||||
const root = join(tmpdir(), `wrnexus-api-${crypto.randomUUID()}`);
|
||||
roots.push(root);
|
||||
await mkdir(join(root, "app", "api", "users"), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, "app", "api", "users", "[id].ts"),
|
||||
"export async function GET(){}\nexport const PATCH = () => {};\n",
|
||||
);
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("API and SDK generation", () => {
|
||||
test("derives methods, paths and OpenAPI operations from file routes", async () => {
|
||||
const operations = inspectApi(await fixture());
|
||||
expect(operations.map((operation) => `${operation.method} ${operation.path}`)).toEqual([
|
||||
"GET /api/users/{id}",
|
||||
"PATCH /api/users/{id}",
|
||||
]);
|
||||
expect(createOpenApi(operations).openapi).toBe("3.1.0");
|
||||
});
|
||||
test("emits docs, Postman, examples and all requested SDK languages", async () => {
|
||||
const root = await fixture();
|
||||
const result = generateApiArtifacts(root, ["typescript", "javascript", "java", "go", "python"]);
|
||||
expect(result.files).toHaveLength(9);
|
||||
expect(
|
||||
JSON.parse(await readFile(join(root, "generated/api/openapi.json"), "utf8")).paths[
|
||||
"/api/users/{id}"
|
||||
].get.operationId,
|
||||
).toBe("getUsersId");
|
||||
expect(await readFile(join(root, "generated/api/sdk/python/wrnexus-api.py"), "utf8")).toContain(
|
||||
"class WrnexusApi",
|
||||
);
|
||||
});
|
||||
test("extracts webhook prose and schemas into OpenAPI 3.1 webhooks", async () => {
|
||||
const root = await fixture();
|
||||
await mkdir(join(root, "app", "api", "webhooks"), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, "app", "api", "webhooks", "payment.ts"),
|
||||
`
|
||||
export const webhook = defineWebhook({
|
||||
event: "payment.completed",
|
||||
summary: "Payment completed",
|
||||
description: "Sent after settlement.",
|
||||
payloadSchema: "#/components/schemas/Payment",
|
||||
signatureHeader: "x-payment-signature"
|
||||
});
|
||||
export const POST = () => new Response("ok");
|
||||
`,
|
||||
);
|
||||
const spec = createOpenApi(inspectApi(root)) as any;
|
||||
expect(spec.webhooks["payment.completed"].post.description).toBe("Sent after settlement.");
|
||||
expect(
|
||||
spec.webhooks["payment.completed"].post.requestBody.content["application/json"].schema.$ref,
|
||||
).toBe("#/components/schemas/Payment");
|
||||
expect(spec.webhooks["payment.completed"].post.parameters[0].name).toBe("x-payment-signature");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { compatibilityReport, upgradeCompatibility } from "../src/compatibility-command.ts";
|
||||
|
||||
test("compatibility upgrade is backed up, current, and idempotent", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-compatibility-"));
|
||||
const file = join(root, "wrnexus.config.ts");
|
||||
writeFileSync(file, `export default { port: 3000 };\n`);
|
||||
const first = upgradeCompatibility(root);
|
||||
expect(first.changed).toBe(true);
|
||||
expect(readFileSync(first.backup, "utf8")).toContain("port: 3000");
|
||||
expect(readFileSync(file, "utf8")).toContain('compatibilityDate: "2026-08-02"');
|
||||
expect(upgradeCompatibility(root).changed).toBe(false);
|
||||
expect((await compatibilityReport(root)).needsUpgrade).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { runContractsCommand } from "../src/contracts-command.ts";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () =>
|
||||
Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))),
|
||||
);
|
||||
|
||||
async function fixture(): Promise<string> {
|
||||
const root = join(tmpdir(), `wrnexus-contracts-${crypto.randomUUID()}`);
|
||||
roots.push(root);
|
||||
await mkdir(root, { recursive: true });
|
||||
await writeFile(
|
||||
join(root, "wrnexus.contracts.json"),
|
||||
JSON.stringify({
|
||||
format: 1,
|
||||
contracts: [
|
||||
{
|
||||
kind: "queue",
|
||||
name: "mail",
|
||||
version: 1,
|
||||
consumers: ["worker"],
|
||||
payload: {
|
||||
type: "object",
|
||||
fields: { to: { type: "string", rules: [] } },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("contracts command", () => {
|
||||
test("snapshots and checks compatible contracts", async () => {
|
||||
const root = await fixture();
|
||||
expect((await runContractsCommand(root, "snapshot")).ok).toBe(true);
|
||||
expect((await runContractsCommand(root, "check")).ok).toBe(true);
|
||||
});
|
||||
|
||||
test("returns a failed result for breaking changes", async () => {
|
||||
const root = await fixture();
|
||||
await runContractsCommand(root, "snapshot");
|
||||
await writeFile(
|
||||
join(root, "wrnexus.contracts.json"),
|
||||
JSON.stringify({ format: 1, contracts: [] }),
|
||||
);
|
||||
const result = await runContractsCommand(root, "check");
|
||||
expect(result).toMatchObject({ ok: false, issueCount: 1 });
|
||||
});
|
||||
|
||||
test("requires an explicit baseline", async () => {
|
||||
const root = await fixture();
|
||||
await expect(runContractsCommand(root, "check")).rejects.toThrow("WRN-CONTRACT-BASELINE");
|
||||
});
|
||||
});
|
||||
@@ -22,8 +22,10 @@ test("scaffoldApp creates a comprehensive .gitignore", () => {
|
||||
".wrnexus/",
|
||||
".env.*",
|
||||
"!.env.example",
|
||||
"!.env.*.example",
|
||||
"*.log",
|
||||
"*.db",
|
||||
"uploads/",
|
||||
"coverage/",
|
||||
"mobile/android/",
|
||||
".vscode/",
|
||||
@@ -47,6 +49,79 @@ test("scaffoldApp includes production build and start scripts", () => {
|
||||
expect(pkg.scripts.build).toBe("wrnexus build .");
|
||||
expect(pkg.scripts.start).toBe("bun dist/server.js");
|
||||
expect(pkg.scripts.production).toBe("bun run build && bun run start");
|
||||
expect(pkg.scripts.typecheck).toBe("tsc --noEmit");
|
||||
expect(pkg.scripts.test).toBe("wrnexus test .");
|
||||
expect(pkg.scripts.check).toBe(
|
||||
"bun run typecheck && bun run lint && bun run test && bun run format:check",
|
||||
);
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("scaffoldApp includes the complete v0.8 configuration and starter structure", () => {
|
||||
const parent = mkdtempSync(join(tmpdir(), "wrnexus-create-"));
|
||||
const root = join(parent, "complete-app");
|
||||
|
||||
try {
|
||||
scaffoldApp(root, "complete-app");
|
||||
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
||||
const config = readFileSync(join(root, "wrnexus.config.ts"), "utf8");
|
||||
|
||||
for (const packageName of [
|
||||
"@wrnexus/auth",
|
||||
"@wrnexus/captcha",
|
||||
"@wrnexus/db",
|
||||
"@wrnexus/encryption",
|
||||
"@wrnexus/i18n",
|
||||
"@wrnexus/image",
|
||||
"@wrnexus/jwt",
|
||||
"@wrnexus/observability",
|
||||
"@wrnexus/realtime",
|
||||
"@wrnexus/security",
|
||||
"@wrnexus/store",
|
||||
"@wrnexus/ui",
|
||||
"@wrnexus/uploader",
|
||||
"@wrnexus/validation",
|
||||
]) {
|
||||
expect(pkg.dependencies[packageName]).toBe(currentCliVersion());
|
||||
}
|
||||
|
||||
for (const block of [
|
||||
"plugins:",
|
||||
"imports:",
|
||||
"types:",
|
||||
"stores:",
|
||||
"compatibility:",
|
||||
"performance:",
|
||||
"observability:",
|
||||
"tenancy:",
|
||||
"build:",
|
||||
"navigation:",
|
||||
"devToolbar:",
|
||||
"theme:",
|
||||
"i18n:",
|
||||
"db:",
|
||||
"databases:",
|
||||
"storage:",
|
||||
"realtime:",
|
||||
"profiles:",
|
||||
]) {
|
||||
expect(config).toContain(block);
|
||||
}
|
||||
|
||||
for (const relative of [
|
||||
".env.example",
|
||||
".env.test.example",
|
||||
"app/locales/en.json",
|
||||
"app/db/migrations/0001_init.sql",
|
||||
"app/db/seed.ts",
|
||||
"app/schemas/contact.ts",
|
||||
"app/realtime/chat.ts",
|
||||
"test/smoke.test.ts",
|
||||
]) {
|
||||
expect(existsSync(join(root, relative))).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { DEPLOY_TARGETS, generateDeployment } from "../src/deploy.ts";
|
||||
|
||||
describe("deployment presets", () => {
|
||||
for (const target of DEPLOY_TARGETS) {
|
||||
test(`generates ${target}`, () => {
|
||||
const root = mkdtempSync(join(tmpdir(), `wrnexus-${target}-`));
|
||||
const files = generateDeployment(root, target);
|
||||
expect(files).toContain(".env.production.example");
|
||||
expect(readFileSync(join(root, "deploy/README.md"), "utf8")).toContain("/readyz");
|
||||
expect(generateDeployment(root, target)).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test("Kubernetes includes probes, limits and release migration", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-k8s-"));
|
||||
generateDeployment(root, "kubernetes");
|
||||
const manifest = readFileSync(join(root, "deploy/kubernetes.yaml"), "utf8");
|
||||
expect(manifest).toContain("readinessProbe");
|
||||
expect(manifest).toContain("kind: Job");
|
||||
expect(manifest).toContain("resources:");
|
||||
});
|
||||
|
||||
test("rejects unknown targets", () => {
|
||||
expect(() => generateDeployment(".", "unknown")).toThrow("WRN-DEPLOY-TARGET");
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { inspectProject } from "../src/doctor.ts";
|
||||
import { inspectProject, repairProject } from "../src/doctor.ts";
|
||||
|
||||
test("doctor reports a healthy minimal project", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-doctor-"));
|
||||
@@ -19,3 +19,29 @@ test("doctor returns actionable missing-project checks", () => {
|
||||
expect(checks.find((check) => check.name === "package.json")?.ok).toBe(false);
|
||||
expect(checks.find((check) => check.name === "app/pages")?.detail).toBe("Create app/pages");
|
||||
});
|
||||
|
||||
test("doctor --fix applies safe repairs and is idempotent", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-doctor-fix-"));
|
||||
writeFileSync(
|
||||
join(root, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "app",
|
||||
dependencies: { "@wrnexus/core": "^0.8.0", "@wrnexus/router": "^0.7.0" },
|
||||
wrnexus: { version: "0.7.0" },
|
||||
}),
|
||||
);
|
||||
mkdirSync(join(root, "app", "components"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "app", "components", "Greeting.wrn"),
|
||||
'component Greeting { props { name:string="World" } view { <p>{name}</p> } }',
|
||||
);
|
||||
|
||||
const repairs = repairProject(root);
|
||||
expect(repairs.map(({ name }) => name)).toContain("app/pages");
|
||||
expect(repairs.map(({ name }) => name)).toContain("configuration");
|
||||
expect(existsSync(join(root, "wrnexus.config.ts"))).toBe(true);
|
||||
const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
||||
expect(manifest.dependencies["@wrnexus/router"]).toBe("^0.8.0");
|
||||
expect(manifest.wrnexus.version).toBe("0.8.0");
|
||||
expect(repairProject(root)).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { explainBuildDecision } from "../src/explain.ts";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () =>
|
||||
Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))),
|
||||
);
|
||||
|
||||
async function fixture(): Promise<string> {
|
||||
const root = join(tmpdir(), `wrnexus-explain-${crypto.randomUUID()}`);
|
||||
roots.push(root);
|
||||
await mkdir(join(root, "dist"), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, "dist", "build-report.json"),
|
||||
JSON.stringify({
|
||||
frameworkVersion: "0.8.0",
|
||||
adapter: "edge",
|
||||
measurements: { routeJsBytes: 12 },
|
||||
budgetViolations: [],
|
||||
assets: [{ file: "server.js", bytes: 12 }],
|
||||
routes: [
|
||||
{
|
||||
kind: "page",
|
||||
path: "/users/[id]",
|
||||
source: "app/pages/users/[id].wrn",
|
||||
execution: "authenticated-ssr",
|
||||
canPrerender: false,
|
||||
needsClientRuntime: true,
|
||||
needsServerRuntime: true,
|
||||
hydrationStrategy: "visible",
|
||||
reasons: ["client interactivity", "authentication required"],
|
||||
cachePolicy: { strategy: "stale-while-revalidate", ttl: "30s" },
|
||||
requiredPermission: "users.read",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("causal build explanations", () => {
|
||||
test("explains route execution and hydration from persisted compiler evidence", async () => {
|
||||
const root = await fixture();
|
||||
const route = explainBuildDecision(root, "route", "/users/[id]");
|
||||
expect(route.summary).toContain("authenticated-ssr");
|
||||
expect(route.reasons).toContain("authentication required");
|
||||
expect(explainBuildDecision(root, "hydration", "users/[id]").summary).toContain("visible");
|
||||
});
|
||||
|
||||
test("explains build and bundle measurements", async () => {
|
||||
const root = await fixture();
|
||||
expect(explainBuildDecision(root, "build").reasons).toContain(
|
||||
"all configured performance budgets pass",
|
||||
);
|
||||
expect(explainBuildDecision(root, "bundle").reasons[0]).toBe("server.js: 12 bytes");
|
||||
});
|
||||
|
||||
test("explains cache and permission decisions", async () => {
|
||||
const root = await fixture();
|
||||
expect(explainBuildDecision(root, "cache", "/users/[id]").summary).toContain(
|
||||
"stale-while-revalidate",
|
||||
);
|
||||
expect(explainBuildDecision(root, "permission", "users.read").reasons[0]).toContain(
|
||||
"security.permission",
|
||||
);
|
||||
});
|
||||
|
||||
test("uses stable diagnostics for missing evidence", () => {
|
||||
expect(() => explainBuildDecision("missing", "build")).toThrow("WRN-EXPLAIN-NO-BUILD");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { runI18nCommand } from "../src/i18n-command.ts";
|
||||
|
||||
test("i18n extract and validate audit native WRN translation keys", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-i18n-cli-"));
|
||||
mkdirSync(join(root, "app/pages"), { recursive: true });
|
||||
mkdirSync(join(root, "app/locales"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "app/pages/index.wrn"),
|
||||
`page Home { view { <h1>{t:home.title}</h1> } }`,
|
||||
);
|
||||
writeFileSync(join(root, "app/locales/en.json"), JSON.stringify({ home: { title: "Home" } }));
|
||||
writeFileSync(
|
||||
join(root, "app/locales/mr.json"),
|
||||
JSON.stringify({ home: { title: "मुख्यपृष्ठ" } }),
|
||||
);
|
||||
expect(runI18nCommand(root, "extract")).toBe(true);
|
||||
expect(existsSync(join(root, ".wrnexus/i18n-keys.json"))).toBe(true);
|
||||
expect(runI18nCommand(root, "validate")).toBe(true);
|
||||
});
|
||||
test("i18n validate fails missing locale keys", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-i18n-cli-"));
|
||||
mkdirSync(join(root, "app/pages"), { recursive: true });
|
||||
mkdirSync(join(root, "app/locales"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "app/pages/index.wrn"),
|
||||
`page Home { view { <h1>{t:home.title}</h1> } }`,
|
||||
);
|
||||
writeFileSync(join(root, "app/locales/en.json"), JSON.stringify({ home: { title: "Home" } }));
|
||||
writeFileSync(join(root, "app/locales/es.json"), JSON.stringify({}));
|
||||
expect(runI18nCommand(root, "validate")).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { runPluginCliCommand } from "../src/plugin-command.ts";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
delete (globalThis as Record<string, unknown>).__pluginCommandArgs;
|
||||
});
|
||||
|
||||
test("application plugins can register executable CLI commands", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-plugin-command-"));
|
||||
roots.push(root);
|
||||
writeFileSync(
|
||||
join(root, "wrnexus.config.ts"),
|
||||
`export default {
|
||||
plugins: [{
|
||||
name: "command-test",
|
||||
cliCommands: [{
|
||||
name: "greet",
|
||||
run(args) { globalThis.__pluginCommandArgs = args }
|
||||
}]
|
||||
}]
|
||||
};
|
||||
`,
|
||||
);
|
||||
expect(await runPluginCliCommand(root, "greet", ["Ada"])).toBe(true);
|
||||
expect((globalThis as Record<string, unknown>).__pluginCommandArgs).toEqual(["Ada"]);
|
||||
expect(await runPluginCliCommand(root, "missing", [])).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { productionEntry, runPreview } from "../src/preview.ts";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () =>
|
||||
Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))),
|
||||
);
|
||||
|
||||
describe("production preview", () => {
|
||||
test("refuses to approximate a missing production build", () => {
|
||||
expect(() => productionEntry("missing-preview-root")).toThrow("WRN-PREVIEW-NO-BUILD");
|
||||
});
|
||||
|
||||
test("executes the exact dist server with production environment", async () => {
|
||||
const root = join(tmpdir(), `wrnexus-preview-${crypto.randomUUID()}`);
|
||||
roots.push(root);
|
||||
await mkdir(join(root, "dist"), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, "dist", "server.js"),
|
||||
"console.log(process.env.NODE_ENV + ':' + process.env.PORT)",
|
||||
);
|
||||
expect(productionEntry(root)).toBe(join(root, "dist", "server.js"));
|
||||
const child = runPreview(root, { port: 4100, stdio: "pipe" });
|
||||
const output = await new Response(child.stdout as never).text();
|
||||
expect(await new Promise<number | null>((resolve) => child.on("exit", resolve))).toBe(0);
|
||||
expect(output.trim()).toBe("production:4100");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { generateReproductionReport } from "../src/report.ts";
|
||||
|
||||
test("report bundles actionable diagnostics while redacting secrets and user/internal data", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-report-"));
|
||||
mkdirSync(join(root, "app/pages"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "package.json"),
|
||||
JSON.stringify({ dependencies: { "@wrnexus/core": "0.8.0" } }),
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, "wrnexus.config.ts"),
|
||||
`export default { apiKey: "sk_secretsecretsecret", endpoint: "https://internal.police.local/api" }`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, "app/pages/index.wrn"),
|
||||
`page Home { view { <p>officer@example.com</p> } }`,
|
||||
);
|
||||
const result = generateReproductionReport(root, {
|
||||
file: "app/pages/index.wrn",
|
||||
error: "token=wrn_supersecrettoken at 10.0.0.1",
|
||||
output: ".wrnexus/report-test",
|
||||
});
|
||||
const all = readFileSync(result.reportFile, "utf8") + readFileSync(result.sourceFile!, "utf8");
|
||||
expect(all).toContain("frameworkVersion");
|
||||
expect(all).toContain("diagnostics");
|
||||
expect(all).not.toContain("sk_secretsecretsecret");
|
||||
expect(all).not.toContain("officer@example.com");
|
||||
expect(all).not.toContain("internal.police.local");
|
||||
expect(all).not.toContain("10.0.0.1");
|
||||
});
|
||||
test("report rejects traversal inputs and outputs", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-report-"));
|
||||
writeFileSync(join(root, "package.json"), "{}");
|
||||
expect(() => generateReproductionReport(root, { file: "../secret" })).toThrow("WRN-REPORT-FILE");
|
||||
expect(() => generateReproductionReport(root, { output: "../outside" })).toThrow(
|
||||
"WRN-REPORT-OUTPUT",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { validateRuntimeCapabilities } from "../src/build.ts";
|
||||
|
||||
test("production targets fail before bundling incompatible application imports", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-edge-build-"));
|
||||
mkdirSync(join(root, "app", "api"), { recursive: true });
|
||||
writeFileSync(join(root, "app", "api", "files.ts"), `import fs from "node:fs";`);
|
||||
expect(() => validateRuntimeCapabilities(root, "edge")).toThrow(/WRN-RUNTIME-CAPABILITY/);
|
||||
expect(() => validateRuntimeCapabilities(root, "worker")).toThrow(/filesystem/);
|
||||
expect(() => validateRuntimeCapabilities(root, "bun")).not.toThrow();
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { runSecurityCommand, securityAudit, securityHeaders } from "../src/security-command.ts";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () =>
|
||||
Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))),
|
||||
);
|
||||
|
||||
async function fixture(config = "export default {};"): Promise<string> {
|
||||
const root = join(tmpdir(), `wrnexus-security-${crypto.randomUUID()}`);
|
||||
roots.push(root);
|
||||
await mkdir(root, { recursive: true });
|
||||
await writeFile(join(root, "wrnexus.config.ts"), config);
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("security command", () => {
|
||||
test("audits secure framework defaults against mapped ASVS controls", async () => {
|
||||
const report = await securityAudit(await fixture());
|
||||
expect(report.passed).toBe(true);
|
||||
expect(report.version).toBe("ASVS 5.0.0");
|
||||
expect(report.checks.every((check) => check.asvs.length > 0)).toBe(true);
|
||||
});
|
||||
|
||||
test("reports deliberately disabled headers", async () => {
|
||||
const report = await securityAudit(
|
||||
await fixture("export default { security: { headers: false } };"),
|
||||
);
|
||||
expect(report.passed).toBe(false);
|
||||
expect(report.checks.find((check) => check.id === "SEC-HEADERS")?.passed).toBe(false);
|
||||
});
|
||||
|
||||
test("prints the effective production headers", async () => {
|
||||
const headers = await securityHeaders(await fixture());
|
||||
expect(headers["content-security-policy"]).toContain("nonce-audit-nonce");
|
||||
expect(headers["strict-transport-security"]).toContain("max-age=");
|
||||
expect(headers["x-content-type-options"]).toBe("nosniff");
|
||||
});
|
||||
|
||||
test("rejects credentialed wildcard CORS and unknown commands", async () => {
|
||||
const root = await fixture(
|
||||
'export default { security: { cors: { origin: "*", credentials: true } } };',
|
||||
);
|
||||
expect((await securityAudit(root)).passed).toBe(false);
|
||||
await expect(runSecurityCommand(root, "unknown")).rejects.toThrow("WRN-SECURITY-COMMAND");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync, statSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { X509Certificate } from "node:crypto";
|
||||
import {
|
||||
createLocalServicesHandler,
|
||||
ensureLocalCertificate,
|
||||
startLocalServices,
|
||||
} from "../src/services.ts";
|
||||
|
||||
describe("local production service simulator", () => {
|
||||
test("simulates bounded mail, cache, storage, auth and health APIs", async () => {
|
||||
const handler = createLocalServicesHandler();
|
||||
expect((await handler(new Request("http://local/healthz"))).status).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await handler(
|
||||
new Request("http://local/mail", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ to: "u@test", subject: "Welcome" }),
|
||||
}),
|
||||
)
|
||||
).status,
|
||||
).toBe(202);
|
||||
expect(await (await handler(new Request("http://local/mail"))).json()).toHaveLength(1);
|
||||
await handler(
|
||||
new Request("http://local/cache", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ key: "user", value: 1 }),
|
||||
}),
|
||||
);
|
||||
expect(await (await handler(new Request("http://local/cache"))).json()).toHaveProperty(
|
||||
"user.value",
|
||||
1,
|
||||
);
|
||||
expect(
|
||||
(
|
||||
await handler(
|
||||
new Request("http://local/storage?key=file.txt", { method: "POST", body: "hello" }),
|
||||
)
|
||||
).status,
|
||||
).toBe(201);
|
||||
expect(await (await handler(new Request("http://local/storage/file.txt"))).text()).toBe(
|
||||
"hello",
|
||||
);
|
||||
expect(
|
||||
(
|
||||
await handler(
|
||||
new Request("http://local/auth", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: "u@test" }),
|
||||
}),
|
||||
)
|
||||
).status,
|
||||
).toBe(201);
|
||||
});
|
||||
test("rejects unsafe storage keys and oversized declared JSON", async () => {
|
||||
const handler = createLocalServicesHandler();
|
||||
expect(
|
||||
(
|
||||
await handler(
|
||||
new Request("http://local/storage?key=../secret", { method: "POST", body: "bad" }),
|
||||
)
|
||||
).status,
|
||||
).toBe(400);
|
||||
const response = await handler(
|
||||
new Request("http://local/mail", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ value: "x".repeat(300_000) }),
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(413);
|
||||
});
|
||||
test("generates and safely reuses a localhost HTTPS certificate", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-services-cert-"));
|
||||
const first = await ensureLocalCertificate(root);
|
||||
const certificate = new X509Certificate(first.cert);
|
||||
expect(certificate.subjectAltName).toContain("DNS:localhost");
|
||||
expect(certificate.subjectAltName).toContain("IP Address:127.0.0.1");
|
||||
expect(statSync(first.keyFile).size).toBeGreaterThan(100);
|
||||
const second = await ensureLocalCertificate(root);
|
||||
expect(second.reused).toBe(true);
|
||||
expect(second.cert).toBe(first.cert);
|
||||
});
|
||||
test("serves the simulator over generated HTTPS", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-services-tls-"));
|
||||
const server = await startLocalServices({ appRoot: root, port: 0, hostname: "127.0.0.1" });
|
||||
try {
|
||||
const response = await fetch(`https://127.0.0.1:${server.port}/healthz`, {
|
||||
tls: { rejectUnauthorized: false },
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toHaveProperty("service", "wrnexus-local-services");
|
||||
} finally {
|
||||
server.stop(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { createTestPlan } from "../src/test.ts";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () =>
|
||||
Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))),
|
||||
);
|
||||
async function fixture(): Promise<string> {
|
||||
const root = join(tmpdir(), `wrnexus-test-command-${crypto.randomUUID()}`);
|
||||
roots.push(root);
|
||||
await mkdir(join(root, "test", "component"), { recursive: true });
|
||||
await writeFile(join(root, "test", "math.unit.test.ts"), "export {};\n");
|
||||
await writeFile(join(root, "test", "component", "card.test.ts"), "export {};\n");
|
||||
await writeFile(join(root, "test", "home.a11y.test.ts"), "export {};\n");
|
||||
await writeFile(join(root, "package.json"), "{}");
|
||||
return root;
|
||||
}
|
||||
describe("test command planning", () => {
|
||||
test("discovers the requested Bun test level", async () => {
|
||||
const root = await fixture();
|
||||
expect(createTestPlan(root, ["unit"]).files).toHaveLength(1);
|
||||
expect(createTestPlan(root, ["component"]).files[0]).toContain("card.test.ts");
|
||||
expect(createTestPlan(root, ["accessibility"]).files[0]).toContain("a11y.test.ts");
|
||||
});
|
||||
test("delegates browser and visual suites to Playwright", async () => {
|
||||
const root = await fixture();
|
||||
await writeFile(join(root, "playwright.config.ts"), "export default {};\n");
|
||||
expect(createTestPlan(root, ["browser"]).args).toContain("playwright");
|
||||
expect(createTestPlan(root, ["visual"]).args).toContain("@visual");
|
||||
const matrix = createTestPlan(root, [
|
||||
"browser",
|
||||
"--browsers=chromium,firefox",
|
||||
"--shard=2/3",
|
||||
"--install-browsers",
|
||||
]);
|
||||
expect(matrix.args).toContain("firefox");
|
||||
expect(matrix.args).toContain("--shard=2/3");
|
||||
expect(matrix.args).toContain("--reporter=line,html");
|
||||
expect(matrix.setup?.args).toEqual(["x", "playwright", "install", "chromium", "firefox"]);
|
||||
});
|
||||
test("deterministically shards convention-based suites", async () => {
|
||||
const root = await fixture();
|
||||
await writeFile(join(root, "test", "second.unit.test.ts"), "export {};\n");
|
||||
expect(createTestPlan(root, ["unit", "--shard=1/2"]).files).toHaveLength(1);
|
||||
expect(() => createTestPlan(root, ["unit", "--shard=3/2"])).toThrow("WRN-TEST-SHARD");
|
||||
});
|
||||
test("keeps the unfiltered legacy command", async () => {
|
||||
const plan = createTestPlan(await fixture(), ["--watch"]);
|
||||
expect(plan.level).toBeUndefined();
|
||||
expect(plan.args).toEqual(["test", "--watch"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { checkApplication, generateApplicationTypes } from "../src/types.ts";
|
||||
import { inspectComponent } from "../src/inspect.ts";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function fixture(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-types-"));
|
||||
roots.push(root);
|
||||
for (const dir of ["pages/users", "components", "api", "realtime", "queues", "locales"])
|
||||
mkdirSync(join(root, "app", dir), { recursive: true });
|
||||
writeFileSync(join(root, ".env.example"), "PUBLIC_API_URL=https://example.test\n");
|
||||
writeFileSync(join(root, "app/pages/index.wrn"), "page Home { view { <h1>Home</h1> } }\n");
|
||||
writeFileSync(
|
||||
join(root, "app/pages/users/[id].wrn"),
|
||||
"page User { props { id: string } view { <p>{id}</p> } }\n",
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, "app/components/Button.wrn"),
|
||||
"component Button { props { label: string } outputs { press(event: MouseEvent) } view { <button>{label}</button> } }\n",
|
||||
);
|
||||
writeFileSync(join(root, "app/api/users.ts"), "export default () => new Response('ok');\n");
|
||||
writeFileSync(join(root, "app/realtime/chat.ts"), "export default {};\n");
|
||||
writeFileSync(join(root, "app/queues/email.ts"), "export default {};\n");
|
||||
writeFileSync(join(root, "app/locales/en.json"), JSON.stringify({ common: { save: "Save" } }));
|
||||
return root;
|
||||
}
|
||||
|
||||
test("generate types emits application-wide deterministic contracts", () => {
|
||||
const root = fixture();
|
||||
const result = generateApplicationTypes(root);
|
||||
const output = readFileSync(join(root, result.file), "utf8");
|
||||
expect(existsSync(join(root, "app/routes.gen.ts"))).toBe(true);
|
||||
expect(output).toContain('type RouteName = "index" | "users.id"');
|
||||
expect(output).toContain('type EnvironmentKey = "PUBLIC_API_URL"');
|
||||
expect(output).toContain('type TranslationKey = "common.save"');
|
||||
expect(output).toContain('type QueueName = "email"');
|
||||
expect(output).toContain('"Button": { props: { "label": string }');
|
||||
expect(output).toContain("interface ApiContracts");
|
||||
expect(output).toContain('"/api/users": { default: ApiContract<');
|
||||
expect(output).toContain("interface RealtimeMessages");
|
||||
expect(output).toContain('"/realtime/chat": RealtimeMessage<');
|
||||
expect(output).toContain("interface QueuePayloads");
|
||||
expect(output).toContain('"email": QueuePayload<');
|
||||
});
|
||||
|
||||
test("application checker validates every wrn source", () => {
|
||||
expect(checkApplication(fixture()).filter((item) => item.category === "error")).toEqual([]);
|
||||
}, 15_000);
|
||||
|
||||
test("component inspection exposes its typed public contract", () => {
|
||||
const value = inspectComponent(fixture(), "button") as { name: string; props: unknown[] };
|
||||
expect(value.name).toBe("Button");
|
||||
expect(value.props).toEqual([{ name: "label", type: "string", required: true }]);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { migrateV060WrnSource } from "../src/update.ts";
|
||||
import { formatCurrentWrnSource, migrateV060WrnSource } from "../src/update.ts";
|
||||
|
||||
const report = () => ({
|
||||
changedAutomatically: [],
|
||||
@@ -30,4 +30,16 @@ describe("v0.6 source migration", () => {
|
||||
expect(first).toContain("output.confirm({ ok: true })");
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
test("uses the canonical framework formatter idempotently", () => {
|
||||
const source = `page Home {
|
||||
view {
|
||||
<button type="button" class="one two three four five six seven eight nine ten eleven twelve" @click='save()'>Save</button>
|
||||
}
|
||||
}`;
|
||||
const formatted = formatCurrentWrnSource(source);
|
||||
|
||||
expect(formatted).toContain("<button\n");
|
||||
expect(formatCurrentWrnSource(formatted)).toBe(formatted);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -286,3 +286,56 @@ test("0.4 migration removes manual CAPTCHA runtime wiring and archives copied as
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("0.8 migration modernizes every WRN source with imports and a review report", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-update-current-source-"));
|
||||
mkdirSync(join(root, "app", "components"), { recursive: true });
|
||||
mkdirSync(join(root, "app", "layouts"), { recursive: true });
|
||||
mkdirSync(join(root, "app", "pages"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "source-app",
|
||||
dependencies: { "@wrnexus/core": "^0.7.0" },
|
||||
wrnexus: { version: "0.7.0" },
|
||||
}),
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, "app", "components", "Notice.wrn"),
|
||||
'component Notice {\r\n props { label = "Ready" count = 1 } \r\n view { <p>{label}</p> }\r\n}',
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, "app", "layouts", "shell.wrn"),
|
||||
"layout Shell {\n view { <main><slot /></main> }\n}\n",
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, "app", "pages", "index.wrn"),
|
||||
'page Home {\n layout = "shell"\n view { <Notice label={"Updated"} /> <Missing /> }\n}\n',
|
||||
);
|
||||
|
||||
try {
|
||||
updateApp(root, "0.8.0", false);
|
||||
const first = readFileSync(join(root, "app", "pages", "index.wrn"), "utf8");
|
||||
expect(first).toContain('import Notice from "@/components/Notice.wrn"');
|
||||
expect(first).toContain('import Shell from "@/layouts/shell.wrn"');
|
||||
expect(first).toContain("layout = Shell");
|
||||
expect(first).toContain("label='{\"Updated\"}'");
|
||||
expect(first.endsWith("\n")).toBe(true);
|
||||
|
||||
const component = readFileSync(join(root, "app", "components", "Notice.wrn"), "utf8");
|
||||
expect(component).toContain('props {\n label = "Ready"\n count = 1\n }');
|
||||
expect(component).not.toContain("\r");
|
||||
|
||||
const reportPath = join(root, ".wrnexus", "migrations", "0.8.0-source-modernization.json");
|
||||
const report = JSON.parse(readFileSync(reportPath, "utf8"));
|
||||
expect(report.changedFiles).toContain("app/pages/index.wrn");
|
||||
expect(report.unresolvedImports).toContain(
|
||||
"app/pages/index.wrn: component 'Missing' could not be resolved",
|
||||
);
|
||||
|
||||
updateApp(root, "0.8.0", false);
|
||||
expect(readFileSync(join(root, "app", "pages", "index.wrn"), "utf8")).toBe(first);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -47,8 +47,14 @@ test("workspace templates pin the running framework release", () => {
|
||||
expect(files["README.md"]).toContain("http://127.0.0.1:3000");
|
||||
expect(files["README.md"]).toContain("internal gateway targets");
|
||||
expect(JSON.parse(files["package.json"]!).scripts.production).toBe("wrnexus production");
|
||||
expect(JSON.parse(files["package.json"]!).scripts.check).toContain("typecheck");
|
||||
expect(JSON.parse(files["package.json"]!).scripts.check).toContain("format:check");
|
||||
expect(files["wrnexus.workspace.ts"]).toContain('runtime: "development"');
|
||||
expect(files["wrnexus.workspace.ts"]).toContain("hmr: false");
|
||||
expect(files[".env.example"]).toContain("REDIS_URL");
|
||||
expect(files["eslint.config.js"]).toContain("typescript-eslint");
|
||||
expect(files["tsconfig.json"]).toContain('"strict": true');
|
||||
expect(files[".vscode/extensions.json"]).toContain("wrnexus.wrnexus");
|
||||
});
|
||||
|
||||
test("production workspace detects default and named SQL migrations", () => {
|
||||
|
||||
@@ -1,9 +1,44 @@
|
||||
# @wrnexus/compiler
|
||||
|
||||
## Partial-static rendering
|
||||
|
||||
Pages can select `render = "partial-static"` and divide their view with `<Static>` and
|
||||
`<Dynamic>` boundaries. The compiler emits a build-only shell renderer that never evaluates
|
||||
dynamic-boundary children. `wrnexus build` expands static component mounts into
|
||||
`dist/partial-shells.json`, records byte/region evidence in `build-report.json`, and embeds
|
||||
the shell in the production route manifest. At request time the production runtime retains
|
||||
request-aware layouts, locale/theme metadata and security nonces while streaming dynamic
|
||||
regions into stable placeholders.
|
||||
|
||||
> Compiler for the `.wrn` language — tokenizes, parses, and lowers `.wrn` page and component files to TypeScript.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
|
||||
Production adapters use `analyzeRuntimeImports` before bundling. Edge, worker,
|
||||
service-worker, and browser targets reject Node filesystem, TCP, and process
|
||||
modules with `WRN-RUNTIME-CAPABILITY`. Package manifests can declare supported
|
||||
`wrnexus.runtimes` and required `wrnexus.requires` capabilities; discovery fails
|
||||
when the selected deployment cannot satisfy them.
|
||||
|
||||
## Server actions
|
||||
|
||||
```wrn
|
||||
action createUser using CreateUserSchema {
|
||||
const user = await users.create(input)
|
||||
invalidate("users")
|
||||
return user
|
||||
}
|
||||
|
||||
view {
|
||||
<form @submit="createUser">...</form>
|
||||
}
|
||||
```
|
||||
|
||||
The compiler produces a schema-aware server registry, a fully inferred action
|
||||
client, and progressively enhanced form metadata. The shared runtime performs
|
||||
validation, authentication/permission checks, CSRF verification, serialization,
|
||||
invalidation reporting, and browser lifecycle events.
|
||||
|
||||
## Overview
|
||||
|
||||
`@wrnexus/compiler` turns `.wrn` source into TypeScript that targets the framework's runtime primitives. A `.wrn` file declares either a `page` (a route) or a `component` (a reusable, prop-driven fragment) with blocks for `state`, `view` (plain HTML), `seo`, `style`, `functions`, `api`, `ssr`/`client` data bindings, and `realtime` websocket handlers. The pipeline is `source → Lexer → parse() → PageAst → generate() → TypeScript`. It is a build/server-side library — the WrNexus dev loader calls it to compile `.wrn` files on the fly, surfacing `ParseError` as a readable error page.
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
{
|
||||
"name": "@wrnexus/compiler",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/csr": "workspace:*",
|
||||
"@wrnexus/syntax": "workspace:*",
|
||||
"@wrnexus/store": "workspace:*"
|
||||
"@wrnexus/store": "workspace:*",
|
||||
"@wrnexus/validation": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,185 @@ export interface RuntimeRequirements {
|
||||
needsServerRuntime: boolean;
|
||||
hydrationStrategy: string | null;
|
||||
reasons: string[];
|
||||
optimization: OptimizationReport;
|
||||
cachePolicy: Record<string, string>;
|
||||
requiredPermission: string | null;
|
||||
}
|
||||
|
||||
export interface OptimizationReport {
|
||||
staticNodes: number;
|
||||
reactiveRegions: number;
|
||||
eliminatedBranches: number;
|
||||
unusedState: string[];
|
||||
unusedHandlers: string[];
|
||||
constantProps: string[];
|
||||
unusedLocalCssClasses: string[];
|
||||
batchableStateUpdates: number;
|
||||
memoizableComponents: string[];
|
||||
preloadDependencies: string[];
|
||||
serverOnlyModules: string[];
|
||||
}
|
||||
|
||||
function identifiers(value: string): Set<string> {
|
||||
return new Set(value.match(/[A-Za-z_$][\w$]*/g) ?? []);
|
||||
}
|
||||
|
||||
function literalBoolean(expression: string | null): boolean | undefined {
|
||||
if (expression === null) return true;
|
||||
const value = expression.trim();
|
||||
if (value === "true") return true;
|
||||
if (
|
||||
value === "false" ||
|
||||
value === "null" ||
|
||||
value === "undefined" ||
|
||||
value === "0" ||
|
||||
value === "''" ||
|
||||
value === '""'
|
||||
)
|
||||
return false;
|
||||
if (/^-?(?:[1-9]\d*|0?\.\d+)$/.test(value) || /^(['"]).+\1$/.test(value)) return true;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function optimizeNodes(nodes: ViewNode[], report: { eliminated: number }): ViewNode[] {
|
||||
const output: ViewNode[] = [];
|
||||
for (const node of nodes) {
|
||||
if (node.type === "element")
|
||||
output.push({
|
||||
...node,
|
||||
attrs: node.attrs.map((attribute) => ({ ...attribute })),
|
||||
children: optimizeNodes(node.children, report),
|
||||
});
|
||||
else if (node.type === "each")
|
||||
output.push({
|
||||
...node,
|
||||
body: optimizeNodes(node.body, report),
|
||||
empty: optimizeNodes(node.empty, report),
|
||||
});
|
||||
else if (node.type === "if") {
|
||||
let selected: ViewNode[] | undefined;
|
||||
let dynamic = false;
|
||||
for (const branch of node.branches) {
|
||||
const value = literalBoolean(branch.cond);
|
||||
if (value === undefined) {
|
||||
dynamic = true;
|
||||
break;
|
||||
}
|
||||
report.eliminated++;
|
||||
if (value) {
|
||||
selected = branch.body;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (dynamic)
|
||||
output.push({
|
||||
...node,
|
||||
branches: node.branches.map((branch) => ({
|
||||
...branch,
|
||||
body: optimizeNodes(branch.body, report),
|
||||
})),
|
||||
});
|
||||
else if (selected) output.push(...optimizeNodes(selected, report));
|
||||
} else output.push({ ...node });
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/** Safe compile-time folding for literal conditional branches. */
|
||||
export function optimizeAst(ast: PageAst): { ast: PageAst; eliminatedBranches: number } {
|
||||
const report = { eliminated: 0 };
|
||||
return {
|
||||
ast: { ...ast, view: optimizeNodes(ast.view, report) },
|
||||
eliminatedBranches: report.eliminated,
|
||||
};
|
||||
}
|
||||
|
||||
export function analyzeOptimizations(ast: PageAst): OptimizationReport {
|
||||
const used = new Set<string>();
|
||||
let staticNodes = 0;
|
||||
let reactiveRegions = 0;
|
||||
const componentNames = new Set<string>();
|
||||
const staticClasses = new Set<string>();
|
||||
const visit = (nodes: ViewNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if (node.type === "text") {
|
||||
const refs = identifiers(node.value);
|
||||
refs.forEach((name) => used.add(name));
|
||||
if (node.value.includes("{")) reactiveRegions++;
|
||||
else staticNodes++;
|
||||
} else if (node.type === "element") {
|
||||
if (/^[A-Z]/.test(node.tag)) componentNames.add(node.tag);
|
||||
let reactive = false;
|
||||
for (const attribute of node.attrs) {
|
||||
identifiers(attribute.value).forEach((name) => used.add(name));
|
||||
reactive ||= attribute.event || attribute.value.includes("{");
|
||||
if (attribute.name === "class" && !attribute.value.includes("{"))
|
||||
for (const name of attribute.value.split(/\s+/)) if (name) staticClasses.add(name);
|
||||
}
|
||||
if (reactive) reactiveRegions++;
|
||||
else staticNodes++;
|
||||
visit(node.children);
|
||||
} else if (node.type === "each") {
|
||||
identifiers(`${node.list} ${node.key ?? ""}`).forEach((name) => used.add(name));
|
||||
reactiveRegions++;
|
||||
visit(node.body);
|
||||
visit(node.empty);
|
||||
} else {
|
||||
for (const branch of node.branches) {
|
||||
identifiers(branch.cond ?? "").forEach((name) => used.add(name));
|
||||
visit(branch.body);
|
||||
}
|
||||
reactiveRegions++;
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(ast.view);
|
||||
const handlerReferences = new Set(used);
|
||||
const executable = [
|
||||
...ast.runtimeFunctions.map((fn) => fn.body),
|
||||
...ast.functions,
|
||||
...ast.effects.map((effect) => effect.body),
|
||||
...ast.watches.map((watch) => watch.body),
|
||||
...ast.actions.map((action) => action.body),
|
||||
].join("\n");
|
||||
identifiers(executable).forEach((name) => used.add(name));
|
||||
const localCss = new Set(
|
||||
ast.styles.flatMap((style) =>
|
||||
[...style.matchAll(/\.([_a-zA-Z][\w-]*)/g)].map((match) => match[1]!),
|
||||
),
|
||||
);
|
||||
const optimized = optimizeAst(ast);
|
||||
const assignmentCounts = ast.runtimeFunctions.map(
|
||||
(fn) =>
|
||||
ast.states.filter((state) =>
|
||||
new RegExp(`\\b${state.name}\\s*(?:[+*/-]?=|\\+\\+|--)`).test(fn.body),
|
||||
).length,
|
||||
);
|
||||
return {
|
||||
staticNodes,
|
||||
reactiveRegions,
|
||||
eliminatedBranches: optimized.eliminatedBranches,
|
||||
unusedState: ast.states.filter((state) => !used.has(state.name)).map((state) => state.name),
|
||||
unusedHandlers: ast.runtimeFunctions
|
||||
.filter((fn) => fn.runtime !== "server" && !handlerReferences.has(fn.name))
|
||||
.map((fn) => fn.name),
|
||||
constantProps: ast.props
|
||||
.filter((prop) =>
|
||||
/^(?:-?\d+(?:\.\d+)?|true|false|null|(['"]).*\1)$/.test(prop.default.trim()),
|
||||
)
|
||||
.map((prop) => prop.name),
|
||||
unusedLocalCssClasses: [...localCss].filter((name) => !staticClasses.has(name)).sort(),
|
||||
batchableStateUpdates: assignmentCounts
|
||||
.filter((count) => count > 1)
|
||||
.reduce((sum, count) => sum + count - 1, 0),
|
||||
memoizableComponents: [...componentNames].sort(),
|
||||
preloadDependencies: ast.structuredImports
|
||||
.filter((entry) => !entry.typeOnly && !entry.source.startsWith("node:"))
|
||||
.map((entry) => entry.source),
|
||||
serverOnlyModules: ast.structuredImports
|
||||
.filter((entry) => entry.source.startsWith("node:") || ast.runtime === "server")
|
||||
.map((entry) => entry.source),
|
||||
};
|
||||
}
|
||||
|
||||
function hasEvent(nodes: ViewNode[]): boolean {
|
||||
@@ -67,12 +246,42 @@ export function analyzeRuntimeRequirements(ast: PageAst): RuntimeRequirements {
|
||||
else if (interactive) kind = "static-interactive";
|
||||
else kind = "static";
|
||||
|
||||
if (ast.renderMode === "static") {
|
||||
kind = "static";
|
||||
reasons.push("explicit static rendering");
|
||||
} else if (ast.renderMode === "server") {
|
||||
kind = requestData ? "request-ssr" : "static";
|
||||
reasons.push("explicit server rendering");
|
||||
} else if (ast.renderMode === "client") {
|
||||
kind = "static-interactive";
|
||||
reasons.push("explicit client rendering");
|
||||
} else if (ast.renderMode === "partial-static") {
|
||||
kind = "streaming-ssr";
|
||||
reasons.push("partial-static shell with streamed dynamic regions");
|
||||
}
|
||||
|
||||
const clientDisabled = ast.renderMode === "static" || ast.renderMode === "server";
|
||||
const serverDisabled = ast.renderMode === "client";
|
||||
|
||||
return {
|
||||
kind,
|
||||
canPrerender: kind === "static" || kind === "static-interactive",
|
||||
needsClientRuntime: interactive && ast.hydrate !== "none" && ast.runtime !== "server",
|
||||
needsServerRuntime: requestData || authenticated || streaming || ast.runtime === "server",
|
||||
hydrationStrategy: interactive ? (ast.hydrate ?? "load") : null,
|
||||
needsClientRuntime:
|
||||
!clientDisabled &&
|
||||
(interactive || ast.renderMode === "client") &&
|
||||
ast.hydrate !== "none" &&
|
||||
ast.runtime !== "server",
|
||||
needsServerRuntime:
|
||||
!serverDisabled &&
|
||||
(requestData ||
|
||||
authenticated ||
|
||||
streaming ||
|
||||
ast.renderMode === "server" ||
|
||||
["server", "edge", "worker", "service-worker"].includes(ast.runtime ?? "")),
|
||||
hydrationStrategy: clientDisabled ? null : interactive ? (ast.hydrate ?? "load") : null,
|
||||
reasons,
|
||||
optimization: analyzeOptimizations(ast),
|
||||
cachePolicy: { ...(ast.cache ?? {}) },
|
||||
requiredPermission: ast.security.permission ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { VOID_ELEMENTS, type Attr, type DataMode, type PageAst, type ViewNode }
|
||||
import { eraseFunctionTypes, runtimeTypeOf } from "./types.ts";
|
||||
import { stripRuntimeFunctionModifiers } from "@wrnexus/syntax";
|
||||
import { generateStoreModule } from "./store-codegen.ts";
|
||||
import { optimizeAst } from "./analysis.ts";
|
||||
|
||||
interface RenderBinding {
|
||||
method: string;
|
||||
@@ -352,6 +353,55 @@ function renderLoopBody(node: ViewNode): string {
|
||||
|
||||
const inner = node.children.map(renderLoopBody).join("");
|
||||
|
||||
if (node.tag === "Static") return inner;
|
||||
if (node.tag === "Dynamic")
|
||||
return (
|
||||
escLit('<wrn-dynamic-region data-wrn-dynamic="true">') +
|
||||
inner +
|
||||
escLit("</wrn-dynamic-region>")
|
||||
);
|
||||
if (node.tag === "KeepAlive") {
|
||||
const key = node.attrs.find((attribute) => attribute.name === "key")?.value ?? "default";
|
||||
return (
|
||||
escLit('<div data-wrn-keepalive="') +
|
||||
bakeLoopAttr(key) +
|
||||
escLit(`">`) +
|
||||
inner +
|
||||
escLit("</div>")
|
||||
);
|
||||
}
|
||||
if (node.tag === "Portal") {
|
||||
const target = node.attrs.find((attribute) => attribute.name === "to")?.value ?? "body";
|
||||
return (
|
||||
escLit('<div data-wrn-portal="') +
|
||||
bakeLoopAttr(target) +
|
||||
escLit('">') +
|
||||
inner +
|
||||
escLit("</div>")
|
||||
);
|
||||
}
|
||||
if (node.tag === "Transition") {
|
||||
const name =
|
||||
node.attrs.find((attribute) => attribute.name === "name")?.value ?? "wrn-transition";
|
||||
return (
|
||||
escLit('<div data-wrn-transition="') +
|
||||
bakeLoopAttr(name) +
|
||||
escLit('">') +
|
||||
inner +
|
||||
escLit("</div>")
|
||||
);
|
||||
}
|
||||
if (node.tag === "Component") {
|
||||
const selected = node.attrs.find((attribute) => attribute.name === "is")?.value ?? "";
|
||||
return (
|
||||
escLit('<div data-wrn-dynamic-component="') +
|
||||
bakeLoopAttr(selected) +
|
||||
escLit('">') +
|
||||
inner +
|
||||
escLit("</div>")
|
||||
);
|
||||
}
|
||||
|
||||
if (componentTag) {
|
||||
return (
|
||||
escLit(`<div data-component="${attrEscape(node.tag)}"`) +
|
||||
@@ -417,6 +467,7 @@ function compileIfExpr(node: IfNode): string {
|
||||
*/
|
||||
function collectControlExprs(nodes: ViewNode[], out: string[] = []): string[] {
|
||||
for (const node of nodes) {
|
||||
if (node.type === "text") continue;
|
||||
if (node.type === "each") {
|
||||
out.push(node.list);
|
||||
collectControlExprs(node.body, out);
|
||||
@@ -450,6 +501,73 @@ function renderNode(
|
||||
return `\x00WRNEACH${loops.length - 1}\x00`;
|
||||
}
|
||||
|
||||
if (node.tag === "Static" || node.tag === "Dynamic") {
|
||||
const inner = node.children
|
||||
.map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive))
|
||||
.join("");
|
||||
return node.tag === "Static"
|
||||
? inner
|
||||
: `<wrn-dynamic-region data-wrn-dynamic="true">${inner}</wrn-dynamic-region>`;
|
||||
}
|
||||
if (node.tag === "Portal" || node.tag === "Transition" || node.tag === "Component") {
|
||||
const inner = node.children
|
||||
.map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive))
|
||||
.join("");
|
||||
const attribute =
|
||||
node.tag === "Portal"
|
||||
? "data-wrn-portal"
|
||||
: node.tag === "Transition"
|
||||
? "data-wrn-transition"
|
||||
: "data-wrn-dynamic-component";
|
||||
const source = node.tag === "Portal" ? "to" : node.tag === "Transition" ? "name" : "is";
|
||||
const fallback =
|
||||
node.tag === "Portal" ? "body" : node.tag === "Transition" ? "wrn-transition" : "";
|
||||
const original = node.attrs.find((item) => item.name === source);
|
||||
const rendered = original
|
||||
? renderAttrs([{ ...original, name: attribute }], undefined, reactive, loops)
|
||||
: ` ${attribute}="${attrEscape(fallback)}"`;
|
||||
return `<div${rendered}>${inner}</div>`;
|
||||
}
|
||||
|
||||
if (node.tag === "Async") {
|
||||
const source = attrValue(node.attrs, "source") ?? "data";
|
||||
const retries = attrValue(node.attrs, "retries") ?? "2";
|
||||
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(
|
||||
(child): child is Extract<ViewNode, { type: "element" }> =>
|
||||
child.type === "element" && child.tag === name,
|
||||
);
|
||||
return (element?.children ?? [])
|
||||
.map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive))
|
||||
.join("");
|
||||
};
|
||||
const loading = branch("Loading");
|
||||
const success = branch("Success");
|
||||
const error = branch("Error");
|
||||
let initial = loading;
|
||||
if (serverResolved) {
|
||||
const nested = (value: string) => value.replace(/\\/g, "\\\\").replace(/`/g, "\\`");
|
||||
const sourcePattern = source.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`;
|
||||
}
|
||||
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>`;
|
||||
}
|
||||
|
||||
if (node.tag === "KeepAlive") {
|
||||
const key = attrValue(node.attrs, "key") ?? "default";
|
||||
const inner = node.children
|
||||
.map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive))
|
||||
.join("");
|
||||
return `<div data-wrn-keepalive="${attrEscape(key)}">${inner}</div>`;
|
||||
}
|
||||
if (isComponentTag(node.tag)) {
|
||||
return renderPageComponentInvocation(
|
||||
node,
|
||||
@@ -848,7 +966,9 @@ function generateSsrStateAliases(stateNames: string[]): string {
|
||||
}
|
||||
|
||||
function hydrationAttribute(ast: PageAst): string {
|
||||
const strategy = ast.hydrate ?? "load";
|
||||
const strategy = ["static", "server"].includes(ast.renderMode ?? "")
|
||||
? "none"
|
||||
: (ast.hydrate ?? "load");
|
||||
const hasBrowserModule = ast.runtimeFunctions.some((fn) =>
|
||||
["legacy", "client", "shared"].includes(fn.runtime),
|
||||
);
|
||||
@@ -879,13 +999,88 @@ function publicOutputNames(ast: PageAst): string[] {
|
||||
];
|
||||
}
|
||||
|
||||
function prepareActionForms(nodes: ViewNode[], actions: ReadonlySet<string>): void {
|
||||
for (const node of nodes) {
|
||||
if (node.type === "text") continue;
|
||||
if (node.type === "each") {
|
||||
prepareActionForms(node.body, actions);
|
||||
prepareActionForms(node.empty, actions);
|
||||
continue;
|
||||
}
|
||||
if (node.type === "if") {
|
||||
node.branches.forEach((branch) => prepareActionForms(branch.body, actions));
|
||||
continue;
|
||||
}
|
||||
prepareActionForms(node.children, actions);
|
||||
if (node.tag.toLowerCase() !== "form") continue;
|
||||
const submit = node.attrs.find((attr) => attr.event && attr.name === "submit");
|
||||
if (!submit || !actions.has(submit.value.trim())) continue;
|
||||
const name = submit.value.trim();
|
||||
node.attrs = node.attrs.filter((attr) => attr !== submit);
|
||||
if (!node.attrs.some((attr) => !attr.event && attr.name === "method")) {
|
||||
node.attrs.push({ name: "method", value: "post", event: false });
|
||||
}
|
||||
node.attrs.push({ name: "data-wrn-action", value: name, event: false });
|
||||
node.children.unshift({
|
||||
type: "element",
|
||||
tag: "input",
|
||||
attrs: [
|
||||
{ name: "type", value: "hidden", event: false },
|
||||
{ name: "name", value: "_wrnexus_action", event: false },
|
||||
{ name: "value", value: name, event: false },
|
||||
],
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function markServerAsyncBoundaries(nodes: ViewNode[], serverLoads: ReadonlySet<string>): void {
|
||||
for (const node of nodes) {
|
||||
if (node.type === "text") continue;
|
||||
if (node.type === "each") {
|
||||
markServerAsyncBoundaries(node.body, serverLoads);
|
||||
markServerAsyncBoundaries(node.empty, serverLoads);
|
||||
continue;
|
||||
}
|
||||
if (node.type === "if") {
|
||||
node.branches.forEach((branch) => markServerAsyncBoundaries(branch.body, serverLoads));
|
||||
continue;
|
||||
}
|
||||
if (node.tag === "Async") {
|
||||
const source = attrValue(node.attrs, "source") ?? "data";
|
||||
if (
|
||||
serverLoads.has(source) &&
|
||||
!node.attrs.some((attribute) => attribute.name === "data-wrn-async-server")
|
||||
) {
|
||||
node.attrs.push({ name: "data-wrn-async-server", value: "true", event: false });
|
||||
}
|
||||
}
|
||||
markServerAsyncBoundaries(node.children, serverLoads);
|
||||
}
|
||||
}
|
||||
|
||||
export function generate(ast: PageAst): string {
|
||||
ast = optimizeAst(ast).ast;
|
||||
if (ast.kind === "global-store" || ast.kind === "page-store") return generateStoreModule(ast);
|
||||
if (ast.kind === "component" || ast.kind === "layout") {
|
||||
return generateComponent(ast);
|
||||
}
|
||||
|
||||
const out: string[] = [];
|
||||
prepareActionForms(ast.view, new Set(ast.actions.map((action) => action.name)));
|
||||
markServerAsyncBoundaries(
|
||||
ast.view,
|
||||
new Set(
|
||||
ast.loads
|
||||
.filter((load) => load.mode === "server" && !load.deferred && load.name)
|
||||
.map((load) => load.name!),
|
||||
),
|
||||
);
|
||||
if (ast.actions.length > 0) {
|
||||
out.push(
|
||||
`import { createActionClient } from "@wrnexus/csr";\nimport type { InferSchema } from "@wrnexus/validation";`,
|
||||
);
|
||||
}
|
||||
if (ast.imports.length > 0) out.push(generatedImports(ast).join("\n"));
|
||||
const ssrBindings: SsrBinding[] = [];
|
||||
const csrBindings: CsrBinding[] = [];
|
||||
@@ -909,11 +1104,19 @@ export function generate(ast: PageAst): string {
|
||||
`export const layout = ${ast.layoutIsSymbol ? ast.layout : JSON.stringify(ast.layout)};`,
|
||||
);
|
||||
out.push(`export const __wrnexusRuntime = ${JSON.stringify(ast.runtime ?? "universal")};`);
|
||||
out.push(`export const __wrnexusHydrate = ${JSON.stringify(ast.hydrate ?? "load")};`);
|
||||
out.push(`export const __wrnexusRender = ${JSON.stringify(ast.renderMode ?? "hybrid")};`);
|
||||
out.push(
|
||||
`export const __wrnexusHydrate = ${JSON.stringify(["static", "server"].includes(ast.renderMode ?? "") ? "none" : (ast.hydrate ?? "load"))};`,
|
||||
);
|
||||
out.push(`export const __wrnexusHydrationId = ${JSON.stringify(hydrationId(ast))};`);
|
||||
if (Object.keys(ast.cache ?? {}).length > 0)
|
||||
out.push(`export const __wrnexusCache = ${JSON.stringify(ast.cache, null, 2)};`);
|
||||
if (Object.keys(ast.security).length > 0) {
|
||||
out.push(`export const __wrnexusSecurity = ${JSON.stringify(ast.security, null, 2)};`);
|
||||
}
|
||||
if (Object.keys(ast.navigation).length > 0) {
|
||||
out.push(`export const __wrnexusNavigation = ${JSON.stringify(ast.navigation, null, 2)};`);
|
||||
}
|
||||
|
||||
// --- View -> default page component ---
|
||||
const browserStates = ast.states.filter((state) => state.runtime !== "server");
|
||||
@@ -960,6 +1163,10 @@ export function generate(ast: PageAst): string {
|
||||
if (pageStyleTag) {
|
||||
html = `${pageStyleTag}${html}`;
|
||||
}
|
||||
if (ast.renderMode === "client") {
|
||||
const clientRoot = hydrationId(ast);
|
||||
html = `<div data-wrn-client-root="${clientRoot}" aria-busy="true"></div><template data-wrn-client-template="${clientRoot}">${html}</template>`;
|
||||
}
|
||||
const pageStyleExport = localStyleExport(ast, styles);
|
||||
if (pageStyleExport) out.push(pageStyleExport);
|
||||
if (csrBindings.length > 0) {
|
||||
@@ -972,6 +1179,14 @@ export function generate(ast: PageAst): string {
|
||||
// Escape the static HTML for the template literal, then swap loop sentinels for
|
||||
// their real `${…}` code (which must NOT be escaped).
|
||||
let body = templateEscape(html);
|
||||
let staticShellBody: string | undefined;
|
||||
if (ast.renderMode === "partial-static") {
|
||||
const shellHtml = html.replace(
|
||||
/<wrn-dynamic-region\b[^>]*>[\s\S]*?<\/wrn-dynamic-region>/gi,
|
||||
'<wrn-dynamic-region data-wrn-dynamic="true"></wrn-dynamic-region>',
|
||||
);
|
||||
staticShellBody = templateEscape(shellHtml);
|
||||
}
|
||||
const dynamicStateScope = ast.states
|
||||
.map(
|
||||
(state) =>
|
||||
@@ -994,9 +1209,16 @@ export function generate(ast: PageAst): string {
|
||||
(entry) => ` const ${entry.local} = await ctx.__wrnexusUseStore(${entry.internal});`,
|
||||
)
|
||||
.join("\n");
|
||||
const serverLoadAliases = ast.loads
|
||||
.filter((load) => load.mode === "server" && !load.deferred && load.name)
|
||||
.map((load) => ` const ${load.name} = ctx[${JSON.stringify(load.name)}];`)
|
||||
.join("\n");
|
||||
|
||||
loops.forEach((code, idx) => {
|
||||
body = body.replace(`\x00WRNEACH${idx}\x00`, () => code);
|
||||
if (staticShellBody?.includes(`\x00WRNEACH${idx}\x00`)) {
|
||||
staticShellBody = staticShellBody.replace(`\x00WRNEACH${idx}\x00`, () => code);
|
||||
}
|
||||
});
|
||||
|
||||
// Server loops iterate raw SSR data. Declare a named const for every `ssr` data
|
||||
@@ -1024,6 +1246,7 @@ export function generate(ast: PageAst): string {
|
||||
out.push(
|
||||
`export default async function ${ast.name}(ctx: any) {
|
||||
${storeDeclarations}
|
||||
${serverLoadAliases}
|
||||
${decls}
|
||||
const __state: ${stateType} = { ${dynamicStateScope} };
|
||||
${ssrStateAliases}
|
||||
@@ -1055,6 +1278,7 @@ export function generate(ast: PageAst): string {
|
||||
out.push(
|
||||
`export default ${storeBindings.length > 0 ? "async " : ""}function ${ast.name}(ctx: any) {
|
||||
${storeDeclarations}
|
||||
${serverLoadAliases}
|
||||
const __state: ${stateType} = { ${dynamicStateScope} };
|
||||
${ssrStateAliases}
|
||||
const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, (__state as any)[key]]));
|
||||
@@ -1081,30 +1305,122 @@ export function generate(ast: PageAst): string {
|
||||
);
|
||||
}
|
||||
|
||||
if (staticShellBody !== undefined) {
|
||||
out.push(
|
||||
`export async function __wrnexusBuildStaticShell(ctx: any = {}) {
|
||||
${storeDeclarations}
|
||||
${serverLoadAliases}
|
||||
${loopConsts.length > 0 ? loopConsts.join("\n") : ""}
|
||||
const __state: ${stateType} = { ${dynamicStateScope} };
|
||||
${ssrStateAliases}
|
||||
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));
|
||||
return key + ": " + encoded;
|
||||
})
|
||||
.join(", ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
return \`${staticShellBody}\`.replace("__WRNEXUS_DYNAMIC_SCOPE__", __scopeValue);
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (ast.loads.length > 0) {
|
||||
const serverLoads = ast.loads.filter((entry) => entry.mode === "server");
|
||||
const clientLoads = ast.loads.filter((entry) => entry.mode === "client");
|
||||
if (serverLoads.length > 0) {
|
||||
out.push(
|
||||
`export async function __wrnexusLoad(ctx: any) {\n${serverLoads.map((entry) => entry.body).join("\n")}\n}`,
|
||||
);
|
||||
}
|
||||
if (clientLoads.length > 0) {
|
||||
out.push(
|
||||
`export async function __wrnexusClientLoad(ctx: any) {
|
||||
${clientLoads.map((entry) => entry.body).join("\n")}
|
||||
}`,
|
||||
);
|
||||
}
|
||||
const serverLoads = ast.loads.filter((entry) => entry.mode === "server" && !entry.deferred);
|
||||
const publicClientLoads = ast.loads.filter(
|
||||
(entry) => entry.mode === "client" || entry.deferred,
|
||||
);
|
||||
const namedByName = new Map(
|
||||
ast.loads.filter((entry) => entry.name).map((entry) => [entry.name!, entry]),
|
||||
);
|
||||
const clientNames = new Set(
|
||||
publicClientLoads.flatMap((entry) => (entry.name ? [entry.name] : [])),
|
||||
);
|
||||
const includeDependencies = (name: string): void => {
|
||||
for (const dependency of namedByName.get(name)?.dependsOn ?? []) {
|
||||
if (clientNames.has(dependency)) continue;
|
||||
clientNames.add(dependency);
|
||||
includeDependencies(dependency);
|
||||
}
|
||||
};
|
||||
for (const name of [...clientNames]) includeDependencies(name);
|
||||
const clientLoads = ast.loads.filter((entry) => !entry.name || clientNames.has(entry.name));
|
||||
const renderLoads = (
|
||||
exportName: string,
|
||||
execution: typeof ast.loads,
|
||||
exposed: typeof ast.loads,
|
||||
): string => {
|
||||
const declarations = execution
|
||||
.filter((entry) => entry.name)
|
||||
.map((entry) => {
|
||||
const dependencies = (entry.dependsOn ?? [])
|
||||
.map((dependency) => `const ${dependency} = await __load_${dependency}();`)
|
||||
.join("\n");
|
||||
return ` let __promise_${entry.name}: Promise<unknown> | undefined;
|
||||
const __load_${entry.name} = () => (__promise_${entry.name} ??= (async () => {
|
||||
${dependencies}
|
||||
${entry.body}
|
||||
})());`;
|
||||
})
|
||||
.join("\n");
|
||||
const visible = exposed.filter((entry) => entry.name);
|
||||
return `export async function ${exportName}(ctx: any) {
|
||||
${exposed
|
||||
.filter((entry) => !entry.name)
|
||||
.map((entry) => entry.body)
|
||||
.join("\n")}
|
||||
${declarations}
|
||||
${
|
||||
visible.length
|
||||
? ` const __values = await Promise.all([${visible.map((entry) => `__load_${entry.name}()`).join(", ")}]);
|
||||
return { ${visible.map((entry, index) => `${JSON.stringify(entry.name)}: __values[${index}]`).join(", ")} };`
|
||||
: ""
|
||||
}
|
||||
}`;
|
||||
};
|
||||
if (serverLoads.length > 0) out.push(renderLoads("__wrnexusLoad", serverLoads, serverLoads));
|
||||
if (publicClientLoads.length > 0)
|
||||
out.push(renderLoads("__wrnexusClientLoad", clientLoads, publicClientLoads));
|
||||
}
|
||||
|
||||
if (ast.actions.length > 0) {
|
||||
for (const action of ast.actions) {
|
||||
out.push(`export async function ${action.name}(${action.args.join(", ")}) {${action.body}}`);
|
||||
if (!action.schema) {
|
||||
out.push(
|
||||
`export async function ${action.name}(${action.args.join(", ")}) {${action.body}}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
out.push(`export async function ${action.name}(input: any, ctx: any) {
|
||||
const invalidate = (...tags: string[]) => {
|
||||
const bucket = (ctx.locals.__wrnexusInvalidatedTags ??= []);
|
||||
bucket.push(...tags.flat());
|
||||
};
|
||||
${action.body}
|
||||
}`);
|
||||
}
|
||||
out.push(
|
||||
`export const __wrnexusActions = { ${ast.actions.map((action) => action.name).join(", ")} };`,
|
||||
`export const __wrnexusActions = { ${ast.actions
|
||||
.map(
|
||||
(action) =>
|
||||
`${action.name}: { run: ${action.name}, schema: ${action.schema ?? "undefined"} }`,
|
||||
)
|
||||
.join(", ")} };`,
|
||||
);
|
||||
out.push(`export const __wrnexusActionClients = {
|
||||
${ast.actions
|
||||
.map(
|
||||
(action) =>
|
||||
` ${action.name}: createActionClient<${action.schema ? `InferSchema<typeof ${action.schema}>` : "Record<string, unknown>"}, Awaited<ReturnType<typeof ${action.name}>>>("", ${JSON.stringify(action.name)}),`,
|
||||
)
|
||||
.join("\n")}
|
||||
};`);
|
||||
}
|
||||
|
||||
// --- API blocks -> method handlers ---
|
||||
@@ -1545,6 +1861,34 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string {
|
||||
return renderComponentIfNode(node, ctx);
|
||||
}
|
||||
|
||||
if (node.tag === "Static" || node.tag === "Dynamic") {
|
||||
const inner = node.children.map((child) => renderComponentNode(child, ctx)).join("");
|
||||
return node.tag === "Static"
|
||||
? inner
|
||||
: `<wrn-dynamic-region data-wrn-dynamic="true">${inner}</wrn-dynamic-region>`;
|
||||
}
|
||||
|
||||
if (node.tag === "KeepAlive") {
|
||||
const key = node.attrs.find((attribute) => attribute.name === "key")?.value ?? "default";
|
||||
const inner = node.children.map((child) => renderComponentNode(child, ctx)).join("");
|
||||
return `<div data-wrn-keepalive="${compileAttrValue(key, ctx)}">${inner}</div>`;
|
||||
}
|
||||
|
||||
if (node.tag === "Portal" || node.tag === "Transition" || node.tag === "Component") {
|
||||
const inner = node.children.map((child) => renderComponentNode(child, ctx)).join("");
|
||||
const attribute =
|
||||
node.tag === "Portal"
|
||||
? "data-wrn-portal"
|
||||
: node.tag === "Transition"
|
||||
? "data-wrn-transition"
|
||||
: "data-wrn-dynamic-component";
|
||||
const source = node.tag === "Portal" ? "to" : node.tag === "Transition" ? "name" : "is";
|
||||
const fallback =
|
||||
node.tag === "Portal" ? "body" : node.tag === "Transition" ? "wrn-transition" : "";
|
||||
const raw = node.attrs.find((item) => item.name === source)?.value ?? fallback;
|
||||
return `<div ${attribute}="${compileAttrValue(raw, ctx)}">${inner}</div>`;
|
||||
}
|
||||
|
||||
if (isComponentTag(node.tag)) {
|
||||
return renderNestedComponentInvocation(node, ctx);
|
||||
}
|
||||
@@ -1865,11 +2209,19 @@ function generateComponent(ast: PageAst): string {
|
||||
out.push(`export const __wrnexusComponent = ${JSON.stringify(ast.name)};`);
|
||||
}
|
||||
out.push(`export const __wrnexusRuntime = ${JSON.stringify(ast.runtime ?? "universal")};`);
|
||||
out.push(`export const __wrnexusHydrate = ${JSON.stringify(ast.hydrate ?? "load")};`);
|
||||
out.push(`export const __wrnexusRender = ${JSON.stringify(ast.renderMode ?? "hybrid")};`);
|
||||
out.push(
|
||||
`export const __wrnexusHydrate = ${JSON.stringify(["static", "server"].includes(ast.renderMode ?? "") ? "none" : (ast.hydrate ?? "load"))};`,
|
||||
);
|
||||
out.push(`export const __wrnexusHydrationId = ${JSON.stringify(hydrationId(ast))};`);
|
||||
if (Object.keys(ast.cache ?? {}).length > 0)
|
||||
out.push(`export const __wrnexusCache = ${JSON.stringify(ast.cache, null, 2)};`);
|
||||
if (Object.keys(ast.security).length > 0) {
|
||||
out.push(`export const __wrnexusSecurity = ${JSON.stringify(ast.security, null, 2)};`);
|
||||
}
|
||||
if (Object.keys(ast.navigation).length > 0) {
|
||||
out.push(`export const __wrnexusNavigation = ${JSON.stringify(ast.navigation, null, 2)};`);
|
||||
}
|
||||
const componentStyleExport = localStyleExport(ast, styles);
|
||||
if (componentStyleExport) out.push(componentStyleExport);
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
type PageAst,
|
||||
type WrnDiagnostic,
|
||||
} from "@wrnexus/syntax";
|
||||
export { formatWrn } from "@wrnexus/syntax";
|
||||
export type { FormatWrnOptions } from "@wrnexus/syntax";
|
||||
import { generate } from "./codegen.ts";
|
||||
import { generateNative } from "./native-codegen.ts";
|
||||
|
||||
@@ -35,8 +37,14 @@ export { generateStoreBrowserModule, generateStoreModule } from "./store-codegen
|
||||
export { createComponentContract } from "./component-contract.ts";
|
||||
export { resolveWrnImport, resolveWrnImports } from "./import-resolver.ts";
|
||||
export { createWrnSourceMap } from "./source-map.ts";
|
||||
export { analyzeRuntimeRequirements } from "./analysis.ts";
|
||||
export type { RouteExecutionKind, RuntimeRequirements } from "./analysis.ts";
|
||||
export { analyzeOptimizations, analyzeRuntimeRequirements, optimizeAst } from "./analysis.ts";
|
||||
export type { OptimizationReport, RouteExecutionKind, RuntimeRequirements } from "./analysis.ts";
|
||||
export { analyzeRuntimeImports, runtimeCapabilities } from "./runtime-capabilities.ts";
|
||||
export type {
|
||||
DeploymentRuntime,
|
||||
RuntimeCapability,
|
||||
RuntimeCapabilityDiagnostic,
|
||||
} from "./runtime-capabilities.ts";
|
||||
export { generateNative, NativeCompileError } from "./native-codegen.ts";
|
||||
export { Lexer, LexError } from "@wrnexus/syntax";
|
||||
export { eraseFunctionTypes, inferredRuntimeType, runtimeTypeOf } from "@wrnexus/syntax";
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
export type DeploymentRuntime = "bun" | "node" | "edge" | "worker" | "service-worker" | "browser";
|
||||
export type RuntimeCapability =
|
||||
"filesystem" | "tcp" | "process" | "websocket" | "crypto" | "streams" | "background-tasks";
|
||||
|
||||
const CAPABILITIES: Record<DeploymentRuntime, ReadonlySet<RuntimeCapability>> = {
|
||||
bun: new Set([
|
||||
"filesystem",
|
||||
"tcp",
|
||||
"process",
|
||||
"websocket",
|
||||
"crypto",
|
||||
"streams",
|
||||
"background-tasks",
|
||||
]),
|
||||
node: new Set([
|
||||
"filesystem",
|
||||
"tcp",
|
||||
"process",
|
||||
"websocket",
|
||||
"crypto",
|
||||
"streams",
|
||||
"background-tasks",
|
||||
]),
|
||||
edge: new Set(["websocket", "crypto", "streams", "background-tasks"]),
|
||||
worker: new Set(["websocket", "crypto", "streams", "background-tasks"]),
|
||||
"service-worker": new Set(["crypto", "streams", "background-tasks"]),
|
||||
browser: new Set(["websocket", "crypto", "streams"]),
|
||||
};
|
||||
|
||||
const MODULE_CAPABILITIES: Array<[RegExp, RuntimeCapability]> = [
|
||||
[/^(?:node:)?(?:fs|path|os)(?:\/|$)/, "filesystem"],
|
||||
[/^(?:node:)?(?:net|tls|dgram|http2)(?:\/|$)/, "tcp"],
|
||||
[/^(?:node:)?(?:child_process|cluster|worker_threads)(?:\/|$)/, "process"],
|
||||
];
|
||||
|
||||
export interface RuntimeCapabilityDiagnostic {
|
||||
code: "WRN-RUNTIME-CAPABILITY";
|
||||
runtime: DeploymentRuntime;
|
||||
module: string;
|
||||
capability: RuntimeCapability;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function runtimeCapabilities(runtime: DeploymentRuntime): ReadonlySet<RuntimeCapability> {
|
||||
return CAPABILITIES[runtime];
|
||||
}
|
||||
|
||||
export function analyzeRuntimeImports(
|
||||
source: string,
|
||||
runtime: DeploymentRuntime,
|
||||
): RuntimeCapabilityDiagnostic[] {
|
||||
const modules = [
|
||||
...source.matchAll(/\b(?:import\s+(?:[\s\S]*?\s+from\s+)?|require\s*\()\s*["']([^"']+)["']/g),
|
||||
].map((match) => match[1]!);
|
||||
const available = runtimeCapabilities(runtime);
|
||||
return modules.flatMap((module) => {
|
||||
const requirement = MODULE_CAPABILITIES.find(([pattern]) => pattern.test(module));
|
||||
if (!requirement || available.has(requirement[1])) return [];
|
||||
return [
|
||||
{
|
||||
code: "WRN-RUNTIME-CAPABILITY" as const,
|
||||
runtime,
|
||||
module,
|
||||
capability: requirement[1],
|
||||
message: `Module '${module}' requires ${requirement[1]}, which is unavailable in the ${runtime} runtime.`,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
|
||||
|
||||
exports[`compiler output remains snapshot-compatible for the canonical component contract 1`] = `
|
||||
{
|
||||
"code":
|
||||
"// compiled from .wrn
|
||||
import Button from "@wrnexus/ui/components/Button.wrn";
|
||||
|
||||
import { Buffer as __WrnexusBuffer } from "node:buffer";
|
||||
|
||||
export const __wrnexusComponent = "Counter";
|
||||
|
||||
export const __wrnexusRuntime = "universal";
|
||||
|
||||
export const __wrnexusRender = "hybrid";
|
||||
|
||||
export const __wrnexusHydrate = "load";
|
||||
|
||||
export const __wrnexusHydrationId = "Counter:1skggk6";
|
||||
|
||||
export const __wrnexusBehavior = {
|
||||
"functions": "function increment(){\\n count = count + 1\\n output.change(count)\\n }",
|
||||
"outputs": [
|
||||
{
|
||||
"name": "change",
|
||||
"payload": {
|
||||
"name": "value",
|
||||
"valueType": "number",
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"computed": [],
|
||||
"effects": [],
|
||||
"lifecycle": {},
|
||||
"watches": []
|
||||
};
|
||||
|
||||
export interface CounterProps {
|
||||
[attribute: string]: unknown;
|
||||
"label"?: string;
|
||||
}
|
||||
|
||||
export interface CounterOutputs {
|
||||
"change"(value: number): void;
|
||||
}
|
||||
|
||||
function __coerce(v: any, def: any, declared: string = "unknown"): any {
|
||||
if (v === undefined || v === null) {
|
||||
return def;
|
||||
}
|
||||
|
||||
if (declared === "number" || typeof def === "number") {
|
||||
const parsed = Number(v);
|
||||
if (!Number.isFinite(parsed)) throw new TypeError("Expected a finite number prop");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
if (declared === "boolean" || typeof def === "boolean") {
|
||||
if (v === true || v === "" || v === "true" || v === 1 || v === "1") return true;
|
||||
if (v === false || v === "false" || v === 0 || v === "0") return false;
|
||||
throw new TypeError("Expected a boolean prop");
|
||||
}
|
||||
|
||||
if (declared === "array" || Array.isArray(def)) {
|
||||
if (Array.isArray(v)) {
|
||||
return v;
|
||||
}
|
||||
|
||||
if (typeof v === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(v);
|
||||
return Array.isArray(parsed) ? parsed : def;
|
||||
} catch {
|
||||
if (declared === "array") throw new TypeError("Expected an array prop");
|
||||
return def;
|
||||
}
|
||||
}
|
||||
|
||||
return def;
|
||||
}
|
||||
|
||||
if (declared === "object" || (def !== null && typeof def === "object")) {
|
||||
if (
|
||||
v !== null &&
|
||||
typeof v === "object" &&
|
||||
!Array.isArray(v)
|
||||
) {
|
||||
return v;
|
||||
}
|
||||
|
||||
if (typeof v === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(v);
|
||||
|
||||
return (
|
||||
parsed !== null &&
|
||||
typeof parsed === "object" &&
|
||||
!Array.isArray(parsed)
|
||||
)
|
||||
? parsed
|
||||
: def;
|
||||
} catch {
|
||||
if (declared === "object") throw new TypeError("Expected an object prop");
|
||||
return def;
|
||||
}
|
||||
}
|
||||
|
||||
return def;
|
||||
}
|
||||
|
||||
if (declared === "bigint") return BigInt(v);
|
||||
if (declared === "function" && typeof v !== "function") {
|
||||
throw new TypeError("Expected a function prop");
|
||||
}
|
||||
return declared === "unknown" && def === undefined ? v : String(v);
|
||||
}
|
||||
|
||||
function __restProps(
|
||||
props: Record<string, any>,
|
||||
declared: Set<string>,
|
||||
): Record<string, any> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(props).filter(([name]) => !declared.has(name)),
|
||||
);
|
||||
}
|
||||
|
||||
function __wireHtml(v: any): string {
|
||||
return String(v == null ? "" : v).replace(
|
||||
/[&<>]/g,
|
||||
(c) =>
|
||||
c === "&"
|
||||
? "&"
|
||||
: c === "<"
|
||||
? "<"
|
||||
: ">",
|
||||
);
|
||||
}
|
||||
|
||||
function __wireAttr(v: any): string {
|
||||
return String(v == null ? "" : v).replace(
|
||||
/[&<>"]/g,
|
||||
(c) =>
|
||||
c === "&"
|
||||
? "&"
|
||||
: c === "<"
|
||||
? "<"
|
||||
: c === ">"
|
||||
? ">"
|
||||
: """,
|
||||
);
|
||||
}
|
||||
|
||||
function __wireBooleanAttr(name: string, value: any): string {
|
||||
return value === true ||
|
||||
value === "true" ||
|
||||
value === "" ||
|
||||
value === 1 ||
|
||||
value === "1" ||
|
||||
value === name
|
||||
? " " + name
|
||||
: "";
|
||||
}
|
||||
|
||||
function __wireSpreadAttrs(value: any): string {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) return "";
|
||||
|
||||
const booleanAttributes = new Set(["allowfullscreen","async","autofocus","autoplay","checked","controls","default","defer","disabled","formnovalidate","hidden","inert","ismap","itemscope","loop","multiple","muted","nomodule","novalidate","open","playsinline","readonly","required","reversed","selected"]);
|
||||
const attributes: string[] = [];
|
||||
|
||||
for (const [name, raw] of Object.entries(value)) {
|
||||
const lowerName = name.toLowerCase();
|
||||
if (
|
||||
!/^[A-Za-z_:][A-Za-z0-9_.:-]*$/.test(name) ||
|
||||
lowerName.startsWith("on") ||
|
||||
lowerName === "style" ||
|
||||
lowerName === "slot" ||
|
||||
lowerName === "data-component" ||
|
||||
lowerName.startsWith("data-wrn")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (booleanAttributes.has(lowerName)) {
|
||||
attributes.push(__wireBooleanAttr(name, raw));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (raw === false || raw === null || raw === undefined) continue;
|
||||
attributes.push(" " + name + '="' + __wireAttr(raw) + '"');
|
||||
}
|
||||
|
||||
return attributes.join("");
|
||||
}
|
||||
|
||||
function __wireProp(v: any): string {
|
||||
const value =
|
||||
v !== null && typeof v === "object"
|
||||
? JSON.stringify(v)
|
||||
: String(v == null ? "" : v);
|
||||
|
||||
return __wireAttr(value);
|
||||
}
|
||||
|
||||
function __wireRaw(v: any): string {
|
||||
return String(v == null ? "" : v);
|
||||
}
|
||||
|
||||
function __wrnexusSerializeScopeValue(value: any): string {
|
||||
if (value === undefined) {
|
||||
return "undefined";
|
||||
}
|
||||
|
||||
if (value === null) {
|
||||
return "null";
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value)
|
||||
? String(value)
|
||||
: "null";
|
||||
}
|
||||
|
||||
if (typeof value === "boolean") {
|
||||
return value ? "true" : "false";
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
|
||||
return serialized === undefined
|
||||
? "undefined"
|
||||
: serialized;
|
||||
} catch {
|
||||
return "null";
|
||||
}
|
||||
}
|
||||
|
||||
function __wrnexusScopeDecl(obj: Record<string, any>): string {
|
||||
return Object.keys(obj)
|
||||
.map(
|
||||
(key) =>
|
||||
key +
|
||||
": " +
|
||||
__wrnexusSerializeScopeValue(
|
||||
obj[key],
|
||||
),
|
||||
)
|
||||
.join(", ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
export function render(props: CounterProps = {} as CounterProps): string {
|
||||
const __p = props || {};
|
||||
const label: string = __coerce(__p["label"], ("Count"), "string");
|
||||
const __attrs = __restProps(__p, new Set(["label"]));
|
||||
let count = (0);
|
||||
const __scopeState = { "label": label, "count": count };
|
||||
const __scope = __wrnexusScopeDecl(__scopeState);
|
||||
const __scopePayload = __WrnexusBuffer.from(JSON.stringify(__scopeState), "utf8").toString("base64");
|
||||
return \`<div data-scope="\${__scope}" data-wrn-scope="\${__scopePayload}" data-wrn-behavior="eyJmdW5jdGlvbnMiOiJmdW5jdGlvbiBpbmNyZW1lbnQoKXtcbiAgICAgIGNvdW50ID0gY291bnQgKyAxXG4gICAgICBvdXRwdXQuY2hhbmdlKGNvdW50KVxuICAgIH0iLCJvdXRwdXRzIjpbeyJuYW1lIjoiY2hhbmdlIiwicGF5bG9hZCI6eyJuYW1lIjoidmFsdWUiLCJ2YWx1ZVR5cGUiOiJudW1iZXIiLCJvcHRpb25hbCI6ZmFsc2V9fV0sImNvbXB1dGVkIjpbXSwiZWZmZWN0cyI6W10sImxpZmVjeWNsZSI6e30sIndhdGNoZXMiOltdfQ==" data-wrn-hydration="Counter:1skggk6" data-wrn-hydrate="load" data-wrn-runtime="universal" data-wrn-client-module="__WRNEXUS_CLIENT_MODULE__">
|
||||
<div data-component="Button"\${__wireSpreadAttrs(__attrs)} on:click="\${__wireProp(increment)}">\${__wireHtml(label)}: <span data-text="count">\${__wireHtml(count)}</span></div>
|
||||
</div>\`;
|
||||
}
|
||||
|
||||
export default { name: "Counter", kind: "component", render };
|
||||
"
|
||||
,
|
||||
"diagnostics": [],
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { compile } from "../src/index.ts";
|
||||
|
||||
test("compiles schema-backed actions and progressively enhanced forms", () => {
|
||||
const output = compile(
|
||||
`import { CreateUserSchema } from "./schema";
|
||||
page Users {
|
||||
action createUser using CreateUserSchema { invalidate("users"); return { id: input.name } }
|
||||
view { <form @submit='createUser'><input name='name' /></form> }
|
||||
}`,
|
||||
"Users.wrn",
|
||||
).code;
|
||||
expect(output).toContain('data-wrn-action="createUser"');
|
||||
expect(output).toContain('name="_wrnexus_action" value="createUser"');
|
||||
expect(output).toContain("schema: CreateUserSchema");
|
||||
expect(output).toContain("__wrnexusInvalidatedTags");
|
||||
expect(output).toContain(
|
||||
"createActionClient<InferSchema<typeof CreateUserSchema>, Awaited<ReturnType<typeof createUser>>>",
|
||||
);
|
||||
expect(output).not.toContain("data-on-submit");
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { generate, parse } from "../src/index.ts";
|
||||
|
||||
test("Async syntax compiles loading, success and error branches into inert templates", () => {
|
||||
const code = generate(
|
||||
parse(`page Users {
|
||||
load client users { return [{ name: "Ada" }] }
|
||||
view {
|
||||
<Async source="users" retries="3">
|
||||
<Loading><p>Loading users</p></Loading>
|
||||
<Success data="users"><p>{users.name}</p></Success>
|
||||
<Error error="error"><p>{error.message}</p></Error>
|
||||
</Async>
|
||||
}
|
||||
}`),
|
||||
);
|
||||
expect(code).toContain('data-wrn-async="users"');
|
||||
expect(code).toContain('data-wrn-async-retries="3"');
|
||||
expect(code).toContain("data-wrn-async-loading");
|
||||
expect(code).toContain("data-wrn-async-success");
|
||||
expect(code).toContain("data-wrn-async-error");
|
||||
expect(code).toContain("__wrnexusClientLoad");
|
||||
});
|
||||
|
||||
test("server named loads render Async success content during SSR", () => {
|
||||
const code = generate(
|
||||
parse(`page Users {
|
||||
load server users { return { name: "Ada" } }
|
||||
view {
|
||||
<Async source="users">
|
||||
<Loading><p>Loading</p></Loading>
|
||||
<Success><p>{users.name}</p></Success>
|
||||
<Error><p>Failed</p></Error>
|
||||
</Async>
|
||||
}
|
||||
}`),
|
||||
);
|
||||
expect(code).toContain('const users = ctx["users"]');
|
||||
expect(code).toContain('data-wrn-async-resolved="true"');
|
||||
expect(code).toContain('ctx["users"] !== undefined');
|
||||
});
|
||||
|
||||
test("named loads support memoized dependencies and deferred execution", () => {
|
||||
const code = generate(
|
||||
parse(`page Data {
|
||||
load server account { return { id: 7 } }
|
||||
load server projects after account { return [account.id] }
|
||||
load server audit after projects defer { return { project: projects[0] } }
|
||||
view { <Async source="audit"><Loading>Wait</Loading><Success>Ready</Success></Async> }
|
||||
}`),
|
||||
);
|
||||
expect(code).toContain("const account = await __load_account()");
|
||||
expect(code).toContain("const projects = await __load_projects()");
|
||||
expect(code).toContain("__promise_projects ??=");
|
||||
expect(code).toContain("export async function __wrnexusClientLoad");
|
||||
expect(code).toContain('return { "audit": __values[0] }');
|
||||
});
|
||||
|
||||
test("load dependency cycles and cross-phase server dependencies fail compilation", () => {
|
||||
expect(() =>
|
||||
parse(
|
||||
`page Cycle { load server first after second { return 1 } load server second after first { return 2 } view { <p>x</p> } }`,
|
||||
),
|
||||
).toThrow("cycle");
|
||||
expect(() =>
|
||||
parse(
|
||||
`page Phase { load client browser { return 1 } load server invalid after browser { return 2 } view { <p>x</p> } }`,
|
||||
),
|
||||
).toThrow("cannot depend");
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { generate, parse } from "../src/index.ts";
|
||||
|
||||
test("client-rendered pages emit an inert template and browser mount anchor", () => {
|
||||
const code = generate(
|
||||
parse('page ClientOnly { render = "client" view { <main><h1>Browser only</h1></main> } }'),
|
||||
);
|
||||
expect(code).toContain('data-wrn-client-root="');
|
||||
expect(code).toContain('data-wrn-client-template="');
|
||||
expect(code.indexOf("Browser only")).toBeGreaterThan(code.indexOf("<template"));
|
||||
});
|
||||
@@ -7,6 +7,27 @@ import { generate, parse } from "../src/index.ts";
|
||||
import { compileWireFile } from "../src/index.ts";
|
||||
import { mountHtml } from "@wrnexus/test";
|
||||
|
||||
test("explicit static rendering disables hydration metadata", () => {
|
||||
const output = compileWireFile(`page StaticPage {
|
||||
render = "static"
|
||||
state count = 0
|
||||
view { <button @click="count++">{count}</button> }
|
||||
}`);
|
||||
expect(output).toContain('export const __wrnexusRender = "static"');
|
||||
expect(output).toContain('data-wrn-hydrate="none"');
|
||||
expect(output).toContain('export const __wrnexusHydrate = "none"');
|
||||
});
|
||||
|
||||
test("named data loads compile as parallel typed data entries", () => {
|
||||
const output = compileWireFile(`page Users {
|
||||
load users { return ["Ada"] }
|
||||
load server teams { return ["Core"] }
|
||||
view { <p>Users</p> }
|
||||
}`);
|
||||
expect(output).toContain("await Promise.all");
|
||||
expect(output).toContain('return { "users": __values[0], "teams": __values[1] }');
|
||||
});
|
||||
|
||||
let seq = 0;
|
||||
/** Compile a `.wrn` source and import the resulting module. */
|
||||
async function compileAndImport(src: string): Promise<Record<string, unknown>> {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { compile } from "../src/index.ts";
|
||||
|
||||
test("compiles declarative portals, transitions and dynamic component cases", () => {
|
||||
const result = compile(`page Ui {
|
||||
view {
|
||||
<Portal to="#modal"><p>Modal</p></Portal>
|
||||
<Transition name="fade"><p>Animated</p></Transition>
|
||||
<Component is="Admin"><section data-component-case="Admin">Admin</section><section data-component-case="Guest">Guest</section></Component>
|
||||
}
|
||||
}`);
|
||||
expect(result.code).toContain('data-wrn-portal="#modal"');
|
||||
expect(result.code).toContain('data-wrn-transition="fade"');
|
||||
expect(result.code).toContain('data-wrn-dynamic-component="Admin"');
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { generate, parse } from "../src/index.ts";
|
||||
|
||||
test("KeepAlive compiles to a keyed live-instance preservation boundary", () => {
|
||||
const output = generate(
|
||||
parse(
|
||||
`page Dashboard { navigation { preserve = ["component"] } view { <KeepAlive key="filters"><DashboardFilters /></KeepAlive> } }`,
|
||||
),
|
||||
);
|
||||
expect(output).toContain('data-wrn-keepalive="filters"');
|
||||
expect(output).not.toContain('data-component="KeepAlive"');
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { analyzeOptimizations, generate, optimizeAst, parse } from "../src/index.ts";
|
||||
|
||||
test("compiler folds literal branches and reports optimization opportunities", () => {
|
||||
const ast = parse(`component Optimized {
|
||||
props { title: string = "Hello" }
|
||||
state count = 0
|
||||
state unused = 1
|
||||
functions {
|
||||
client function increment(): void { count++ }
|
||||
client function orphan(): void { unused++ }
|
||||
}
|
||||
style { .used { color: red } .unused-css { color: blue } }
|
||||
view { <section class="used"><h1>{title}</h1>{#if false}<p>dead</p>{:else}<button @click="increment">{count}</button>{/if}</section> }
|
||||
}`);
|
||||
const report = analyzeOptimizations(ast);
|
||||
expect(report.eliminatedBranches).toBeGreaterThan(0);
|
||||
expect(report.unusedHandlers).toContain("orphan");
|
||||
expect(report.unusedLocalCssClasses).toContain("unused-css");
|
||||
expect(report.constantProps).toContain("title");
|
||||
expect(optimizeAst(ast).ast.view).not.toEqual(ast.view);
|
||||
expect(generate(ast)).not.toContain("dead");
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { analyzeRuntimeRequirements, generate, parse } from "../src/index.ts";
|
||||
|
||||
test("partial-static pages compile transparent static and streamed dynamic boundaries", () => {
|
||||
const ast = parse(
|
||||
`page Dashboard { render = "partial-static" view { <Static><header>Docs</header></Static><Dynamic><p>User</p></Dynamic> } }`,
|
||||
);
|
||||
expect(ast.renderMode).toBe("partial-static");
|
||||
expect(analyzeRuntimeRequirements(ast).kind).toBe("streaming-ssr");
|
||||
const output = generate(ast);
|
||||
expect(output).toContain("wrn-dynamic-region");
|
||||
expect(output).toContain("__wrnexusBuildStaticShell");
|
||||
expect(output).toContain('<wrn-dynamic-region data-wrn-dynamic="true"></wrn-dynamic-region>');
|
||||
expect(output).not.toContain('data-component="Static"');
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { compile, diagnose } from "../src/index.ts";
|
||||
|
||||
test("compiler output remains snapshot-compatible for the canonical component contract", () => {
|
||||
const source = `import Button from "@wrnexus/ui/components/Button.wrn";
|
||||
|
||||
component Counter {
|
||||
props {
|
||||
label: string = "Count"
|
||||
}
|
||||
state {
|
||||
count = 0
|
||||
}
|
||||
outputs {
|
||||
change(value: number)
|
||||
}
|
||||
functions {
|
||||
client function increment(): void {
|
||||
count = count + 1
|
||||
output.change(count)
|
||||
}
|
||||
}
|
||||
view {
|
||||
<Button on:click={increment}>{label}: {count}</Button>
|
||||
}
|
||||
}
|
||||
`;
|
||||
const result = compile(source, "Counter.wrn");
|
||||
expect({ code: result.code, diagnostics: result.richDiagnostics }).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("diagnostics tolerate deterministic malformed-source fuzz cases", () => {
|
||||
let state = 0x8f3a21;
|
||||
const alphabet = "{}[]()<>=:/@#$'\"` abcdefghijklmnopqrstuvwxyz0123456789\n\t";
|
||||
for (let sample = 0; sample < 500; sample++) {
|
||||
let source = "";
|
||||
const length = 1 + (state % 180);
|
||||
for (let index = 0; index < length; index++) {
|
||||
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
|
||||
source += alphabet[state % alphabet.length];
|
||||
}
|
||||
expect(() => diagnose(source, { file: `fuzz-${sample}.wrn` })).not.toThrow();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { analyzeRuntimeImports, runtimeCapabilities } from "../src/index.ts";
|
||||
|
||||
test("edge and workers reject Node capabilities with stable diagnostics", () => {
|
||||
const source = `import fs from "node:fs";\nimport { connect } from "node:net";`;
|
||||
expect(analyzeRuntimeImports(source, "edge").map((item) => item.capability)).toEqual([
|
||||
"filesystem",
|
||||
"tcp",
|
||||
]);
|
||||
expect(analyzeRuntimeImports(source, "bun")).toEqual([]);
|
||||
expect(runtimeCapabilities("service-worker").has("filesystem")).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
# @wrnexus/content
|
||||
|
||||
Typed content collections for Markdown/MDX-like documents and remote CMS records. Collections
|
||||
validate frontmatter through any `{ parse(input) }` schema, render escaped HTML, and expose draft
|
||||
preview, versions, references, headings, search indexes, pagination, RSS and sitemaps.
|
||||
|
||||
```ts
|
||||
const posts = defineCollection({
|
||||
name: "posts",
|
||||
schema: PostSchema,
|
||||
loader: localContentLoader("content/posts"),
|
||||
previewToken: process.env.CONTENT_PREVIEW_TOKEN,
|
||||
});
|
||||
|
||||
const published = await posts.load();
|
||||
const preview = await posts.load({ previewToken: request.headers.get("x-preview-token") ?? "" });
|
||||
```
|
||||
|
||||
Remote systems implement `CmsAdapter`, use `cmsContentLoader`, or return JSON records through
|
||||
`remoteContentLoader`. Markdown HTML is escaped by default; raw executable HTML is never trusted.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user