first commit
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
# @wrnexus/ai
|
||||
|
||||
> 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.
|
||||
|
||||
## Overview
|
||||
|
||||
`@wrnexus/ai` is a thin, dependency-free wrapper over the Anthropic **Messages API**,
|
||||
built on `fetch` (Bun-native, no SDK). Use it in API routes, jobs, or middleware to
|
||||
call Claude. It defaults to the most capable model, **`claude-opus-4-8`**, reads your
|
||||
key from `ANTHROPIC_API_KEY`, and supports both one-shot generation and streaming.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @wrnexus/ai
|
||||
```
|
||||
|
||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
|
||||
|
||||
Set your key in the environment (e.g. `.env`):
|
||||
|
||||
```
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### `createAI(config?)`
|
||||
|
||||
Creates a client. The key is read at call time, so it's safe to create at import.
|
||||
|
||||
```ts
|
||||
import { createAI } from "@wrnexus/ai";
|
||||
const ai = createAI(); // or createAI({ apiKey, model, maxTokens, baseURL, version })
|
||||
```
|
||||
|
||||
`AIConfig` fields (all optional):
|
||||
|
||||
| Field | Default | Description |
|
||||
| ----------- | --------------------------- | -------------------------- |
|
||||
| `apiKey` | `ANTHROPIC_API_KEY` | Anthropic API key |
|
||||
| `model` | `"claude-opus-4-8"` | Model id |
|
||||
| `maxTokens` | `4096` | Default max output tokens |
|
||||
| `baseURL` | `https://api.anthropic.com` | API base URL |
|
||||
| `version` | `"2023-06-01"` | `anthropic-version` header |
|
||||
|
||||
### `ai.generate(prompt, opts?): Promise<string>`
|
||||
|
||||
One-shot text generation. `prompt` is a string or a `Message[]` history.
|
||||
|
||||
```ts
|
||||
const text = await ai.generate("Write a haiku about Bun.");
|
||||
|
||||
const reply = await ai.generate(
|
||||
[
|
||||
{ role: "user", content: "My name is Ada." },
|
||||
{ role: "assistant", content: "Hi Ada!" },
|
||||
{ role: "user", content: "What's my name?" },
|
||||
],
|
||||
{ system: "You are concise." },
|
||||
);
|
||||
```
|
||||
|
||||
### `ai.stream(prompt, opts?): AsyncGenerator<string>`
|
||||
|
||||
Yields text deltas as they arrive.
|
||||
|
||||
```ts
|
||||
for await (const chunk of ai.stream("Tell me a story.")) {
|
||||
process.stdout.write(chunk);
|
||||
}
|
||||
```
|
||||
|
||||
### `ai.streamResponse(prompt, opts?): Response`
|
||||
|
||||
Returns a streaming `text/plain` `Response` — drop it straight into an API route.
|
||||
|
||||
```ts
|
||||
// app/api/chat.ts
|
||||
import { createAI } from "@wrnexus/ai";
|
||||
const ai = createAI();
|
||||
|
||||
export const POST = async (ctx) => {
|
||||
const { prompt } = await ctx.req.json();
|
||||
return ai.streamResponse(prompt);
|
||||
};
|
||||
```
|
||||
|
||||
### `GenerateOptions`
|
||||
|
||||
| Option | Type | Description |
|
||||
| ----------- | ------------------------------------------------- | ---------------------------------------------------- |
|
||||
| `system` | `string` | System prompt |
|
||||
| `model` | `string` | Override the model for this call |
|
||||
| `maxTokens` | `number` | Override max output tokens |
|
||||
| `thinking` | `boolean` | Enable adaptive extended thinking (deeper reasoning) |
|
||||
| `effort` | `"low" \| "medium" \| "high" \| "xhigh" \| "max"` | Reasoning effort / token spend |
|
||||
| `messages` | `Message[]` | Full history — supersedes `prompt` |
|
||||
| `signal` | `AbortSignal` | Cancel the request |
|
||||
|
||||
> `temperature` / `top_p` are intentionally **not** exposed — the current Claude
|
||||
> models reject them (400). Steer output with prompting instead.
|
||||
|
||||
### `AIError`
|
||||
|
||||
Thrown on non-2xx responses or a model refusal. Carries `.status` and `.type`
|
||||
(e.g. `"authentication_error"`, `"rate_limit_error"`, `"refusal"`).
|
||||
|
||||
```ts
|
||||
import { AIError } from "@wrnexus/ai";
|
||||
try {
|
||||
await ai.generate("...");
|
||||
} catch (e) {
|
||||
if (e instanceof AIError && e.type === "rate_limit_error") {
|
||||
/* back off */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
// app/api/summarize.ts — summarize posted text
|
||||
import { createAI } from "@wrnexus/ai";
|
||||
const ai = createAI();
|
||||
|
||||
export const POST = async (ctx) => {
|
||||
const { text } = await ctx.req.json().catch(() => ({}));
|
||||
if (!text) return Response.json({ error: "Provide 'text'." }, { status: 400 });
|
||||
const summary = await ai.generate(`Summarize in one sentence:\n\n${text}`, {
|
||||
system: "You are a precise summarizer.",
|
||||
});
|
||||
return Response.json({ summary });
|
||||
};
|
||||
```
|
||||
|
||||
## Requirements / Notes
|
||||
|
||||
- **Bun-only.** Uses `fetch`, `ReadableStream`, `TextDecoder`/`TextEncoder`, and
|
||||
reads `ANTHROPIC_API_KEY` from `Bun.env` (falls back to `process.env`).
|
||||
- **Zero dependencies** — no `@anthropic-ai/sdk`; talks to the Messages API directly.
|
||||
- Defaults to `claude-opus-4-8`. Pass `{ model }` for a different model (e.g.
|
||||
`"claude-sonnet-5"` for speed/cost, `"claude-haiku-4-5"` for the fastest).
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@wrnexus/ai",
|
||||
"version": "0.2.12",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* @wrnexus/ai — a tiny, zero-dependency Claude (Anthropic) client for WrNexus apps.
|
||||
*
|
||||
* Use it in API routes, jobs, or anywhere server-side to generate text with Claude.
|
||||
* It talks to the Anthropic Messages API over `fetch` (no SDK dependency, Bun-native),
|
||||
* and defaults to the most capable model, `claude-opus-4-8`.
|
||||
*
|
||||
* import { createAI } from "@wrnexus/ai";
|
||||
* const ai = createAI(); // reads ANTHROPIC_API_KEY
|
||||
* const text = await ai.generate("Write a haiku about Bun.");
|
||||
*
|
||||
* Streaming (great for API routes):
|
||||
* export const POST = async (ctx) => ai.streamResponse(await ctx.req.text());
|
||||
*/
|
||||
|
||||
export type Role = "user" | "assistant";
|
||||
|
||||
export interface Message {
|
||||
role: Role;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** Reasoning effort — higher means deeper thinking + more tokens. */
|
||||
export type Effort = "low" | "medium" | "high" | "xhigh" | "max";
|
||||
|
||||
export interface AIConfig {
|
||||
/** Anthropic API key. Default: `ANTHROPIC_API_KEY` from the environment. */
|
||||
apiKey?: string;
|
||||
/** Model id. Default: `claude-opus-4-8` (the most capable Claude model). */
|
||||
model?: string;
|
||||
/** Default max output tokens. Default: 4096. */
|
||||
maxTokens?: number;
|
||||
/** API base URL. Default: `https://api.anthropic.com`. */
|
||||
baseURL?: string;
|
||||
/** `anthropic-version` header. Default: `2023-06-01`. */
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface GenerateOptions {
|
||||
/** System prompt — sets the assistant's role/behavior. */
|
||||
system?: string;
|
||||
/** Override the model for this call. */
|
||||
model?: string;
|
||||
/** Override max output tokens for this call. */
|
||||
maxTokens?: number;
|
||||
/** Enable adaptive extended thinking (slower, deeper reasoning). */
|
||||
thinking?: boolean;
|
||||
/** Reasoning effort / token spend (`output_config.effort`). */
|
||||
effort?: Effort;
|
||||
/** Full message history — supersedes the `prompt` argument when provided. */
|
||||
messages?: Message[];
|
||||
/** Abort the request. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Thrown when the API returns a non-2xx response or refuses the request. */
|
||||
export class AIError extends Error {
|
||||
readonly status: number;
|
||||
readonly type: string;
|
||||
constructor(message: string, status = 0, type = "api_error") {
|
||||
super(message);
|
||||
this.name = "AIError";
|
||||
this.status = status;
|
||||
this.type = type;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AI {
|
||||
/** Generate a full text response (non-streaming). */
|
||||
generate(prompt: string | Message[], opts?: GenerateOptions): Promise<string>;
|
||||
/** Stream the response as text deltas, as they arrive. */
|
||||
stream(prompt: string | Message[], opts?: GenerateOptions): AsyncGenerator<string, void, unknown>;
|
||||
/** Stream straight to a `Response` (text/plain) — drop-in for an API route return. */
|
||||
streamResponse(prompt: string | Message[], opts?: GenerateOptions): Response;
|
||||
}
|
||||
|
||||
const DEFAULT_MODEL = "claude-opus-4-8";
|
||||
const DEFAULT_MAX_TOKENS = 4096;
|
||||
const DEFAULT_BASE_URL = "https://api.anthropic.com";
|
||||
const DEFAULT_VERSION = "2023-06-01";
|
||||
|
||||
function envKey(): string | undefined {
|
||||
// Prefer Bun.env; fall back to process.env (works under Node-compatible runtimes too).
|
||||
const g = globalThis as {
|
||||
Bun?: { env: Record<string, string | undefined> };
|
||||
process?: { env: Record<string, string | undefined> };
|
||||
};
|
||||
return g.Bun?.env?.ANTHROPIC_API_KEY ?? g.process?.env?.ANTHROPIC_API_KEY;
|
||||
}
|
||||
|
||||
function toMessages(prompt: string | Message[], opts?: GenerateOptions): Message[] {
|
||||
if (opts?.messages?.length) return opts.messages;
|
||||
if (typeof prompt === "string") return [{ role: "user", content: prompt }];
|
||||
return prompt;
|
||||
}
|
||||
|
||||
/** Create a Claude client. Reads `ANTHROPIC_API_KEY` from the environment by default. */
|
||||
export function createAI(config: AIConfig = {}): AI {
|
||||
const baseURL = (config.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
||||
const version = config.version ?? DEFAULT_VERSION;
|
||||
const defaultModel = config.model ?? DEFAULT_MODEL;
|
||||
const defaultMaxTokens = config.maxTokens ?? DEFAULT_MAX_TOKENS;
|
||||
|
||||
const buildBody = (
|
||||
prompt: string | Message[],
|
||||
opts: GenerateOptions | undefined,
|
||||
stream: boolean,
|
||||
) => {
|
||||
// NOTE: temperature/top_p/top_k are intentionally omitted — they are rejected
|
||||
// (400) on claude-opus-4-8 and the current Claude models. Steer via prompting.
|
||||
const body: Record<string, unknown> = {
|
||||
model: opts?.model ?? defaultModel,
|
||||
max_tokens: opts?.maxTokens ?? defaultMaxTokens,
|
||||
messages: toMessages(prompt, opts),
|
||||
stream,
|
||||
};
|
||||
if (opts?.system) body.system = opts.system;
|
||||
if (opts?.thinking) body.thinking = { type: "adaptive" };
|
||||
if (opts?.effort) body.output_config = { effort: opts.effort };
|
||||
return body;
|
||||
};
|
||||
|
||||
const request = async (
|
||||
prompt: string | Message[],
|
||||
opts: GenerateOptions | undefined,
|
||||
stream: boolean,
|
||||
): Promise<Response> => {
|
||||
const apiKey = config.apiKey ?? envKey();
|
||||
if (!apiKey) {
|
||||
throw new AIError(
|
||||
"Missing Anthropic API key. Set ANTHROPIC_API_KEY in your environment or pass { apiKey } to createAI().",
|
||||
0,
|
||||
"authentication_error",
|
||||
);
|
||||
}
|
||||
const res = await fetch(`${baseURL}/v1/messages`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-api-key": apiKey,
|
||||
"anthropic-version": version,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(buildBody(prompt, opts, stream)),
|
||||
signal: opts?.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail = `${res.status} ${res.statusText}`;
|
||||
let type = "api_error";
|
||||
try {
|
||||
const err = (await res.json()) as { error?: { message?: string; type?: string } };
|
||||
if (err.error?.message) detail = err.error.message;
|
||||
if (err.error?.type) type = err.error.type;
|
||||
} catch {
|
||||
/* non-JSON error body */
|
||||
}
|
||||
throw new AIError(detail, res.status, type);
|
||||
}
|
||||
return res;
|
||||
};
|
||||
|
||||
const generate: AI["generate"] = async (prompt, opts) => {
|
||||
const res = await request(prompt, opts, false);
|
||||
const data = (await res.json()) as {
|
||||
stop_reason?: string;
|
||||
content?: Array<{ type: string; text?: string }>;
|
||||
};
|
||||
if (data.stop_reason === "refusal") {
|
||||
throw new AIError("The model declined to respond to this request.", 200, "refusal");
|
||||
}
|
||||
return (data.content ?? [])
|
||||
.filter((b) => b.type === "text" && typeof b.text === "string")
|
||||
.map((b) => b.text)
|
||||
.join("");
|
||||
};
|
||||
|
||||
async function* stream(
|
||||
prompt: string | Message[],
|
||||
opts?: GenerateOptions,
|
||||
): AsyncGenerator<string, void, unknown> {
|
||||
const res = await request(prompt, opts, true);
|
||||
if (!res.body) return;
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
const textDelta = (line: string): string | undefined => {
|
||||
if (!line.startsWith("data:")) return undefined;
|
||||
const payload = line.slice(5).trim();
|
||||
if (!payload || payload === "[DONE]") return undefined;
|
||||
try {
|
||||
const evt = JSON.parse(payload) as {
|
||||
type?: string;
|
||||
delta?: { type?: string; text?: string };
|
||||
};
|
||||
return evt.type === "content_block_delta" && evt.delta?.type === "text_delta"
|
||||
? evt.delta.text
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
buf += decoder.decode();
|
||||
const final = textDelta(buf.trimEnd());
|
||||
if (final) yield final;
|
||||
break;
|
||||
}
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
// SSE frames are separated by blank lines; process complete `data:` lines.
|
||||
let nl: number;
|
||||
while ((nl = buf.indexOf("\n")) !== -1) {
|
||||
const line = buf.slice(0, nl).trimEnd();
|
||||
buf = buf.slice(nl + 1);
|
||||
const delta = textDelta(line);
|
||||
if (delta) yield delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const streamResponse: AI["streamResponse"] = (prompt, opts) => {
|
||||
const iterator = stream(prompt, opts);
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
try {
|
||||
const { value, done } = await iterator.next();
|
||||
if (done) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(new TextEncoder().encode(value));
|
||||
} catch (err) {
|
||||
controller.error(err);
|
||||
}
|
||||
},
|
||||
});
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
"cache-control": "no-cache",
|
||||
"x-accel-buffering": "no",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return { generate, stream, streamResponse };
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { AIError, createAI } from "../src/index.ts";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test("requires an API key before making a request", async () => {
|
||||
const previous = process.env.ANTHROPIC_API_KEY;
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
await expect(createAI().generate("hello")).rejects.toBeInstanceOf(AIError);
|
||||
if (previous !== undefined) process.env.ANTHROPIC_API_KEY = previous;
|
||||
});
|
||||
|
||||
test("builds a bounded messages request and joins text blocks", async () => {
|
||||
let request: RequestInit | undefined;
|
||||
globalThis.fetch = (async (_url, init) => {
|
||||
request = init;
|
||||
return Response.json({ content: [{ type: "text", text: "Hello" }, { type: "tool_use" }] });
|
||||
}) as typeof fetch;
|
||||
const text = await createAI({ apiKey: "secret", model: "test-model", maxTokens: 123 }).generate(
|
||||
"Hi",
|
||||
);
|
||||
expect(text).toBe("Hello");
|
||||
expect(JSON.parse(String(request?.body))).toMatchObject({
|
||||
model: "test-model",
|
||||
max_tokens: 123,
|
||||
messages: [{ role: "user", content: "Hi" }],
|
||||
stream: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("streams fragmented SSE and keeps a final event without a newline", async () => {
|
||||
const encoder = new TextEncoder();
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode('data: {"type":"content_block_delta","delta":{"type":"text_'),
|
||||
);
|
||||
controller.enqueue(encoder.encode('delta","text":"A"}}\n'));
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"B"}}',
|
||||
),
|
||||
);
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
)) as unknown as typeof fetch;
|
||||
const chunks: string[] = [];
|
||||
for await (const chunk of createAI({ apiKey: "secret" }).stream("Hi")) chunks.push(chunk);
|
||||
expect(chunks).toEqual(["A", "B"]);
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
# @wrnexus/authz
|
||||
|
||||
> Composable authorization for WrNexus — role-based (RBAC), policy-based (PBAC), and attribute-based (ABAC) access control that reduces to a boolean check plus an `authorize()` guard.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
|
||||
## Overview
|
||||
|
||||
`@wrnexus/authz` is a small, server-side authorization toolkit. It gives you three
|
||||
interchangeable models — RBAC (roles → permissions), PBAC (policy predicates), and
|
||||
ABAC (attribute matchers) — that all collapse to a `boolean | Promise<boolean>` decision.
|
||||
Wrap any decision in a `Middleware` guard (`authorize`, `requireRole`, `requirePermission`)
|
||||
to protect WrNexus routes. Reach for it whenever a route or action needs to be gated on who
|
||||
the user is, what roles they hold, or attributes of the user and the resource. It plugs into
|
||||
`@wrnexus/core` by reading `ctx.user` as the authorization subject.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @wrnexus/authz
|
||||
```
|
||||
|
||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
|
||||
|
||||
## API
|
||||
|
||||
The package has a single entry point (`@wrnexus/authz`) exporting the following.
|
||||
|
||||
### Types
|
||||
|
||||
| Symbol | Description |
|
||||
| ---------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| `Subject` | The authorized principal: `{ id?: string; roles?: string[]; [attribute: string]: unknown }`. |
|
||||
| `Rbac` | An RBAC checker: `{ can(subject, permission): boolean; permissionsFor(roles): Set<string> }`. |
|
||||
| `Policy<S = Subject, R = unknown>` | A predicate `(subject: S, resource?: R) => boolean \| Promise<boolean>`. |
|
||||
|
||||
### RBAC
|
||||
|
||||
#### `defineRbac(roles: Record<string, string[]>): Rbac`
|
||||
|
||||
Builds an RBAC checker from a role → permissions map. Supported permission forms:
|
||||
|
||||
- `"*"` — grants every permission.
|
||||
- `"ns:*"` — namespace wildcard (e.g. `"post:*"` grants `"post:write"`).
|
||||
- `"role:<name>"` — inherits all permissions of another role (resolved recursively, cycle-safe).
|
||||
|
||||
The returned `Rbac` provides:
|
||||
|
||||
- `can(subject, permission)` — `true` if any of `subject.roles` grants `permission` (honouring `*` and namespace wildcards). Returns `false` when the subject has no roles.
|
||||
- `permissionsFor(roles)` — the resolved `Set<string>` of all permissions granted to a set of roles.
|
||||
|
||||
#### `hasRole(subject: Subject | undefined, ...required: string[]): boolean`
|
||||
|
||||
`true` if the subject holds **all** of the given roles.
|
||||
|
||||
### PBAC / ABAC combinators
|
||||
|
||||
- `any<S, R>(...policies: Policy<S, R>[]): Policy<S, R>` — allow if **any** policy passes (OR); awaits async policies.
|
||||
- `all<S, R>(...policies: Policy<S, R>[]): Policy<S, R>` — allow only if **all** policies pass (AND); awaits async policies.
|
||||
- `attr<S extends Subject>(name: string, match: unknown | ((value: unknown) => boolean)): Policy<S>` — ABAC helper that allows when `subject[name]` equals `match`, or when `match` is a function, when `match(value)` is truthy.
|
||||
|
||||
### Guards (middleware)
|
||||
|
||||
Each guard returns a `@wrnexus/core` `Middleware`. A denied request short-circuits with
|
||||
`Response.json({ ok: false, error: "Forbidden" }, { status: 403 })`.
|
||||
|
||||
- `authorize(policy: (ctx: Context) => boolean | Promise<boolean>): Middleware` — runs `policy` against the request `Context`; calls `next()` when it resolves truthy, otherwise returns 403.
|
||||
- `requireRole(...roles: string[]): Middleware` — allows when `ctx.user` holds **any** of the listed roles.
|
||||
- `requirePermission(rbac: Rbac, permission: string): Middleware` — allows when `rbac.can(ctx.user, permission)` is `true`.
|
||||
|
||||
## Usage
|
||||
|
||||
### RBAC
|
||||
|
||||
```ts
|
||||
import { defineRbac, hasRole } from "@wrnexus/authz";
|
||||
|
||||
const rbac = defineRbac({
|
||||
admin: ["*"],
|
||||
editor: ["post:read", "post:write"],
|
||||
viewer: ["post:read"],
|
||||
// role inheritance: lead gets everything an editor has, plus post:publish
|
||||
lead: ["role:editor", "post:publish"],
|
||||
});
|
||||
|
||||
const user = { id: "u1", roles: ["editor"] };
|
||||
|
||||
rbac.can(user, "post:write"); // true
|
||||
rbac.can(user, "post:delete"); // false
|
||||
rbac.permissionsFor(["lead"]); // Set { "post:read", "post:write", "post:publish" }
|
||||
hasRole(user, "editor"); // true
|
||||
```
|
||||
|
||||
### Guarding routes
|
||||
|
||||
```ts
|
||||
import { authorize, requireRole, requirePermission, defineRbac } from "@wrnexus/authz";
|
||||
|
||||
const rbac = defineRbac({ admin: ["*"], editor: ["post:read", "post:write"] });
|
||||
|
||||
// Only admins or editors
|
||||
app.get("/dashboard", requireRole("admin", "editor"), handler);
|
||||
|
||||
// Requires a specific permission
|
||||
app.post("/posts", requirePermission(rbac, "post:write"), handler);
|
||||
|
||||
// Arbitrary policy over the request context
|
||||
app.delete(
|
||||
"/posts/:id",
|
||||
authorize((ctx) => hasRole(ctx.user, "admin")),
|
||||
handler,
|
||||
);
|
||||
```
|
||||
|
||||
### PBAC / ABAC policies
|
||||
|
||||
```ts
|
||||
import { any, all, attr, authorize, type Policy } from "@wrnexus/authz";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
department?: string;
|
||||
roles?: string[];
|
||||
}
|
||||
interface Post {
|
||||
authorId: string;
|
||||
}
|
||||
|
||||
// Ownership policy (subject + resource)
|
||||
const ownsPost: Policy<User, Post> = (u, post) => u.id === post?.authorId;
|
||||
|
||||
// ABAC: attribute equality, or a predicate
|
||||
const inEngineering = attr<User>("department", "engineering");
|
||||
const isVerified = attr<User>("verified", (v) => v === true);
|
||||
|
||||
// Compose: allow if the user owns the post OR is in engineering AND verified
|
||||
const canEdit = any(ownsPost, all(inEngineering, isVerified));
|
||||
|
||||
app.put(
|
||||
"/posts/:id",
|
||||
authorize((ctx) => canEdit(ctx.user as User, loadPost(ctx))),
|
||||
handler,
|
||||
);
|
||||
```
|
||||
|
||||
## Requirements / Notes
|
||||
|
||||
- **Bun-only** — like the rest of WrNexus, this package targets the Bun runtime; Node is not supported.
|
||||
- Works with [`@wrnexus/core`](../core) — the guards return `Middleware` and read the subject from `ctx.user` on the request `Context`. Both types are imported from `@wrnexus/core`.
|
||||
- Policy combinators (`any`, `all`) and `authorize` are async-aware, so policies may return a `Promise<boolean>` (e.g. for a database ownership check).
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@wrnexus/authz",
|
||||
"version": "0.2.12",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* @wrnexus/authz — authorization: role-based (RBAC), policy-based (PBAC), and
|
||||
* attribute-based (ABAC). Compose freely; all three reduce to a boolean check
|
||||
* plus an `authorize()` guard middleware.
|
||||
*
|
||||
* const rbac = defineRbac({ admin: ["*"], editor: ["post:read", "post:write"] });
|
||||
* rbac.can(user, "post:write");
|
||||
*
|
||||
* // PBAC/ABAC: a policy is a predicate over subject + resource + attributes
|
||||
* const ownsPost: Policy<User, Post> = (u, post) => u.id === post.authorId;
|
||||
* authorize((ctx) => ownsPost(ctx.user, resource)) // middleware
|
||||
*/
|
||||
|
||||
import type { Context, Middleware } from "@wrnexus/core";
|
||||
|
||||
export interface Subject {
|
||||
id?: string;
|
||||
roles?: string[];
|
||||
[attribute: string]: unknown;
|
||||
}
|
||||
|
||||
// --- RBAC ------------------------------------------------------------------
|
||||
|
||||
export interface Rbac {
|
||||
/** True if any of the subject's roles grants `permission` (supports "*" and "ns:*"). */
|
||||
can(subject: Subject | undefined, permission: string): boolean;
|
||||
/** All permissions granted to a set of roles. */
|
||||
permissionsFor(roles: string[]): Set<string>;
|
||||
}
|
||||
|
||||
/** Build an RBAC checker from a role → permissions map. */
|
||||
export function defineRbac(roles: Record<string, string[]>): Rbac {
|
||||
const grants = (role: string, seen = new Set<string>()): string[] => {
|
||||
if (seen.has(role)) return [];
|
||||
seen.add(role);
|
||||
const out: string[] = [];
|
||||
for (const p of roles[role] ?? []) {
|
||||
// A permission that names another role (prefixed "role:") inherits it.
|
||||
if (p.startsWith("role:")) out.push(...grants(p.slice(5), seen));
|
||||
else out.push(p);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const permissionsFor = (subjectRoles: string[]): Set<string> => {
|
||||
const set = new Set<string>();
|
||||
for (const r of subjectRoles) for (const p of grants(r)) set.add(p);
|
||||
return set;
|
||||
};
|
||||
return {
|
||||
permissionsFor,
|
||||
can(subject, permission) {
|
||||
if (!subject?.roles?.length) return false;
|
||||
const perms = permissionsFor(subject.roles);
|
||||
if (perms.has("*") || perms.has(permission)) return true;
|
||||
// Namespace wildcards: "post:*" grants "post:write".
|
||||
const ns = permission.includes(":")
|
||||
? permission.slice(0, permission.indexOf(":")) + ":*"
|
||||
: null;
|
||||
return ns ? perms.has(ns) : false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** True if the subject has ALL of the given roles. */
|
||||
export function hasRole(subject: Subject | undefined, ...required: string[]): boolean {
|
||||
const roles = new Set(subject?.roles ?? []);
|
||||
return required.every((r) => roles.has(r));
|
||||
}
|
||||
|
||||
// --- PBAC / ABAC -----------------------------------------------------------
|
||||
|
||||
/** A policy predicate: subject (+ optional resource/attributes) → allowed. */
|
||||
export type Policy<S = Subject, R = unknown> = (
|
||||
subject: S,
|
||||
resource?: R,
|
||||
) => boolean | Promise<boolean>;
|
||||
|
||||
/** Combine policies: allow if ANY passes (OR). */
|
||||
export function any<S, R>(...policies: Policy<S, R>[]): Policy<S, R> {
|
||||
return async (s, r) => {
|
||||
for (const p of policies) if (await p(s, r)) return true;
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
/** Combine policies: allow only if ALL pass (AND). */
|
||||
export function all<S, R>(...policies: Policy<S, R>[]): Policy<S, R> {
|
||||
return async (s, r) => {
|
||||
for (const p of policies) if (!(await p(s, r))) return false;
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
/** ABAC helper: allow when an attribute matches (equality or predicate). */
|
||||
export function attr<S extends Subject>(
|
||||
name: string,
|
||||
match: unknown | ((value: unknown) => boolean),
|
||||
): Policy<S> {
|
||||
return (subject) => {
|
||||
const value = subject?.[name];
|
||||
return typeof match === "function"
|
||||
? (match as (v: unknown) => boolean)(value)
|
||||
: value === match;
|
||||
};
|
||||
}
|
||||
|
||||
// --- Guards (middleware) ---------------------------------------------------
|
||||
|
||||
function forbidden(): Response {
|
||||
return Response.json({ ok: false, error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
/** Guard a route with a policy over `ctx` (reads `ctx.user` as the subject). */
|
||||
export function authorize(policy: (ctx: Context) => boolean | Promise<boolean>): Middleware {
|
||||
return async (ctx, next) => ((await policy(ctx)) ? next() : forbidden());
|
||||
}
|
||||
|
||||
/** Guard requiring one of the given roles. */
|
||||
export function requireRole(...roles: string[]): Middleware {
|
||||
return authorize((ctx) => {
|
||||
const subject = ctx.user as Subject | undefined;
|
||||
const have = new Set(subject?.roles ?? []);
|
||||
return roles.some((r) => have.has(r));
|
||||
});
|
||||
}
|
||||
|
||||
/** Guard requiring an RBAC permission. */
|
||||
export function requirePermission(rbac: Rbac, permission: string): Middleware {
|
||||
return authorize((ctx) => rbac.can(ctx.user as Subject | undefined, permission));
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { createContext } from "@wrnexus/core";
|
||||
import {
|
||||
defineRbac,
|
||||
hasRole,
|
||||
authorize,
|
||||
requireRole,
|
||||
requirePermission,
|
||||
any,
|
||||
all,
|
||||
attr,
|
||||
type Policy,
|
||||
} from "../src/index.ts";
|
||||
|
||||
const rbac = defineRbac({
|
||||
admin: ["*"],
|
||||
editor: ["post:read", "post:write"],
|
||||
viewer: ["post:read"],
|
||||
moderator: ["role:editor", "comment:delete"], // inherits editor
|
||||
});
|
||||
|
||||
test("RBAC: roles, wildcards, namespaces, inheritance", () => {
|
||||
expect(rbac.can({ roles: ["viewer"] }, "post:read")).toBe(true);
|
||||
expect(rbac.can({ roles: ["viewer"] }, "post:write")).toBe(false);
|
||||
expect(rbac.can({ roles: ["admin"] }, "anything:goes")).toBe(true); // "*"
|
||||
expect(rbac.can({ roles: ["moderator"] }, "post:write")).toBe(true); // inherited from editor
|
||||
expect(rbac.can({ roles: ["moderator"] }, "comment:delete")).toBe(true);
|
||||
expect(rbac.can(undefined, "post:read")).toBe(false);
|
||||
expect(defineRbac({ ed: ["post:*"] }).can({ roles: ["ed"] }, "post:write")).toBe(true); // ns wildcard
|
||||
});
|
||||
|
||||
test("hasRole", () => {
|
||||
expect(hasRole({ roles: ["a", "b"] }, "a")).toBe(true);
|
||||
expect(hasRole({ roles: ["a"] }, "a", "b")).toBe(false);
|
||||
});
|
||||
|
||||
interface User extends Record<string, unknown> {
|
||||
id?: string;
|
||||
roles?: string[];
|
||||
tenant?: string;
|
||||
}
|
||||
interface Post {
|
||||
authorId: string;
|
||||
}
|
||||
|
||||
test("PBAC/ABAC: policies compose (any/all) + attribute match", async () => {
|
||||
const ownsPost: Policy<User, Post> = (u, post) => u.id === post?.authorId;
|
||||
const isAdmin: Policy<User> = (u) => (u.roles ?? []).includes("admin");
|
||||
const canEdit = any(ownsPost, isAdmin);
|
||||
|
||||
expect(await canEdit({ id: "u1" }, { authorId: "u1" })).toBe(true); // owner
|
||||
expect(await canEdit({ id: "u2", roles: ["admin"] }, { authorId: "u1" })).toBe(true); // admin
|
||||
expect(await canEdit({ id: "u2" }, { authorId: "u1" })).toBe(false);
|
||||
|
||||
const sameTenant = all(isAdmin, attr<User>("tenant", "acme"));
|
||||
expect(await sameTenant({ roles: ["admin"], tenant: "acme" })).toBe(true);
|
||||
expect(await sameTenant({ roles: ["admin"], tenant: "other" })).toBe(false);
|
||||
});
|
||||
|
||||
function ctx(user?: unknown) {
|
||||
const url = new URL("http://x/admin");
|
||||
const c = createContext(new Request(url), url);
|
||||
c.user = user;
|
||||
return c;
|
||||
}
|
||||
|
||||
test("guards: authorize / requireRole / requirePermission", async () => {
|
||||
const ok = () => new Response("ok");
|
||||
expect((await requireRole("admin")(ctx({ roles: ["admin"] }), ok)).status).toBe(200);
|
||||
expect((await requireRole("admin")(ctx({ roles: ["viewer"] }), ok)).status).toBe(403);
|
||||
expect((await requirePermission(rbac, "post:write")(ctx({ roles: ["editor"] }), ok)).status).toBe(
|
||||
200,
|
||||
);
|
||||
expect((await requirePermission(rbac, "post:write")(ctx({ roles: ["viewer"] }), ok)).status).toBe(
|
||||
403,
|
||||
);
|
||||
expect(
|
||||
(await authorize((c) => (c.user as User)?.id === "u1")(ctx({ id: "u1" }), ok)).status,
|
||||
).toBe(200);
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
# @wrnexus/cli
|
||||
|
||||
> 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.
|
||||
|
||||
## Overview
|
||||
|
||||
`@wrnexus/cli` provides the `wrnexus` executable — the single entry point for developing a WrNexus app. It runs the HMR dev server, produces a self-contained production build, scaffolds apps/pages/components, drives database migrations, regenerates typed routes and queries, runs tests, and manages configuration profiles. It also scaffolds multi-app monorepos and serves them behind a domain-routing gateway. This is a CLI/build-time package (it shells out to the Bun binary for the dev child and tests) and it also exports the workspace config types via a subpath.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @wrnexus/cli
|
||||
```
|
||||
|
||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
|
||||
|
||||
Once installed, invoke it from an app directory:
|
||||
|
||||
```bash
|
||||
bunx wrnexus dev
|
||||
# or add scripts: "dev": "wrnexus dev .", "build": "wrnexus build ."
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
Every command accepts an optional `[app-dir]` (defaults to `.`). Commands that read config or `.env` also accept `--profile=<name>` (see [Profiles](#profiles)).
|
||||
|
||||
| Command | Purpose |
|
||||
| ------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `wrnexus dev [app-dir] [--port=3000]` | Start the development server with live reload / HMR. |
|
||||
| `wrnexus build [app-dir]` | Build a self-contained production server bundle + assets into `dist/`. |
|
||||
| `wrnexus create <app-name>` | Scaffold a new single app from an inline template. |
|
||||
| `wrnexus workspace <name>` | Scaffold a monorepo (`apps/*` + shared `packages/*`). |
|
||||
| `wrnexus gateway [--port=3000]` | Serve every workspace app behind one port, routed by domain. |
|
||||
| `wrnexus generate <type> <name>` | Scaffold a `page` \| `component` \| `api` \| `schema`. |
|
||||
| `wrnexus generate routes` | Regenerate the typed routes file (`app/routes.gen.ts`). |
|
||||
| `wrnexus generate docker` | Scaffold `Dockerfile`, `.dockerignore`, and `docker-compose.yml`. |
|
||||
| `wrnexus generate mobile` | Scaffold a Capacitor shell for iOS and Android. |
|
||||
| `wrnexus mobile add <package...>` | Install Capacitor plugins and sync native projects. |
|
||||
| `wrnexus eject <name...>` | Copy Wire UI component `.wrn` sources into `app/components/`. |
|
||||
| `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 help` | Print usage. |
|
||||
|
||||
`wrnexus g` is an alias for `wrnexus generate`.
|
||||
|
||||
### `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`).
|
||||
|
||||
```bash
|
||||
wrnexus dev . --port=8080
|
||||
```
|
||||
|
||||
### `wrnexus build`
|
||||
|
||||
Emits into `<app-dir>/dist/`:
|
||||
|
||||
- `server.js` — a single, minified, self-contained Bun server with a **static** manifest of every page / api / realtime / middleware / component / layout module (no runtime filesystem scan or on-the-fly bundling).
|
||||
- `reactive.js`, `theme.css`, `theme.js`, `ui.css`, and (if present) `styles.css` — hashed, minified browser assets.
|
||||
- `public/` — copied verbatim.
|
||||
|
||||
Before bundling, it regenerates typed queries for the default and every named database. Run the output with:
|
||||
|
||||
```bash
|
||||
bun dist/server.js # PORT env var optional
|
||||
```
|
||||
|
||||
### `wrnexus create`
|
||||
|
||||
Scaffolds a new app from an inline (dependency-free) template — `package.json`, config, and starter `app/` files. Run `wrnexus dev` in the new directory to start.
|
||||
|
||||
```bash
|
||||
wrnexus create my-app
|
||||
```
|
||||
|
||||
### `wrnexus generate`
|
||||
|
||||
Scaffolds a single file from a template, refusing to overwrite an existing file. Types (with aliases): `page`/`p`, `component`/`c`, `api`/`a`, `schema`/`s`. Nested names create nested paths.
|
||||
|
||||
```bash
|
||||
wrnexus generate page about # app/pages/about.wrn
|
||||
wrnexus generate component user-card # app/components/user-card.wrn
|
||||
wrnexus generate api users/list # app/api/users/list.ts
|
||||
wrnexus generate schema signup # app/schemas/signup.ts
|
||||
wrnexus generate routes # regenerate app/routes.gen.ts
|
||||
wrnexus generate docker # Dockerfile + compose + .dockerignore
|
||||
wrnexus generate mobile --mode=webview --app-id=com.example.app --app-name="Example" --url=https://app.example.com
|
||||
wrnexus generate mobile --mode=native
|
||||
```
|
||||
|
||||
The mobile generator creates a separate `mobile/` package and reads
|
||||
`config.mobile.mode`. `webview` creates a Capacitor shell that renders the hosted
|
||||
WrNexus application. `native` creates a WebView-free Expo/React Native app whose
|
||||
screens call the shared backend through `mobile/src/wrnexus.ts`. Native screens
|
||||
do not render `.wrn` HTML. In either mode, run `bun install` in `mobile/`; iOS
|
||||
device builds require macOS and Xcode.
|
||||
|
||||
Install official or community Capacitor plugins through the root CLI:
|
||||
|
||||
```bash
|
||||
wrnexus mobile add @capacitor/camera @capacitor/haptics
|
||||
wrnexus mobile sync
|
||||
wrnexus mobile assets # generate native icons from config.mobile.icon
|
||||
```
|
||||
|
||||
In native mode, `mobile add` runs `expo install` and `mobile sync` runs Expo
|
||||
prebuild. In WebView mode they retain the Capacitor install/sync behavior.
|
||||
`wrnexus mobile compile` maps portable `app/pages/**/*.wrn` pages to Expo Router
|
||||
TSX routes. Native `bun run start` invokes this compilation automatically.
|
||||
|
||||
Browser code can access installed plugins through the SSR-safe
|
||||
`@wrnexus/mobile` bridge. The command adds each plugin to both the WrNexus app
|
||||
(JavaScript proxy) and `mobile/` (native synchronization).
|
||||
|
||||
`wrnexus mobile sync` also configures Android so only true network failures use
|
||||
the local connection-error screen. HTTP errors such as 404 and 500 keep their
|
||||
WrNexus response pages.
|
||||
|
||||
### `wrnexus eject`
|
||||
|
||||
Copies a Wire UI component's `.wrn` source out of `@wrnexus/ui` into `app/components/`, so the app owns and can edit it (the app copy shadows the library one by name). Run with no names to list available components. It skips components that already exist in the app.
|
||||
|
||||
```bash
|
||||
wrnexus eject button card modal
|
||||
```
|
||||
|
||||
### `wrnexus db`
|
||||
|
||||
Database migrations and tooling. Without a flag, commands target the **default** database (`db` in `wrnexus.config.ts`, files under `app/db/`). Pass `--db=<name>` to target a named database (`databases.<name>`, files under `app/db/<name>/`).
|
||||
|
||||
| Subcommand | Purpose |
|
||||
| ------------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| `db new <name> [--from-models]` | Scaffold a migration; `--from-models` derives it from the TS models in `schema.ts`. |
|
||||
| `db migrate` | Apply all pending migrations. |
|
||||
| `db rollback` | Revert the last applied migration. |
|
||||
| `db status` | List applied / pending migrations. |
|
||||
| `db generate` | Regenerate typed queries (`queries/*.sql` → `queries.gen.ts`). |
|
||||
| `db seed` | Run the database's `seed.ts` (default export / `seed` function). |
|
||||
| `db studio [table]` | Inspect tables — list row counts, or dump the first 50 rows of one table. |
|
||||
|
||||
```bash
|
||||
wrnexus db new create_users --from-models
|
||||
wrnexus db migrate
|
||||
wrnexus db studio users
|
||||
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).
|
||||
|
||||
```bash
|
||||
wrnexus workspace acme
|
||||
wrnexus gateway --port=3000
|
||||
```
|
||||
|
||||
### `wrnexus test`
|
||||
|
||||
Runs the app's tests with `bun test`. Defaults to the `test` profile (config + `.env.test`). Pass `--watch` to re-run on change; extra flags pass straight through to `bun test`.
|
||||
|
||||
```bash
|
||||
wrnexus test . --watch
|
||||
```
|
||||
|
||||
## 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`.
|
||||
|
||||
```bash
|
||||
wrnexus dev --profile=uat
|
||||
wrnexus profiles # ● development (config, .env.development)
|
||||
# ○ production
|
||||
# ○ uat (config, .env.uat)
|
||||
```
|
||||
|
||||
## Subpath exports
|
||||
|
||||
`@wrnexus/cli/workspace` exposes the workspace configuration types used by `wrnexus.workspace.ts`:
|
||||
|
||||
```ts
|
||||
import type { WorkspaceConfig, WorkspaceApp } from "@wrnexus/cli/workspace";
|
||||
|
||||
const config: WorkspaceConfig = {
|
||||
security: { trustedHostsOnly: true, headers: true, accessLog: true },
|
||||
apps: [{ name: "web", dir: "apps/web", domains: ["localhost", "web.localhost"] }],
|
||||
};
|
||||
|
||||
export default config;
|
||||
```
|
||||
|
||||
## Requirements / Notes
|
||||
|
||||
- **Bun-only.** The CLI runs on Bun, spawns the Bun binary for the dev child and `bun test`, and the production build uses `Bun.build`. Node is not supported.
|
||||
- Orchestrates the rest of the framework: `@wrnexus/dev-server` (dev/prod server + gateway), `@wrnexus/router` (route + typed-routes codegen), `@wrnexus/compiler` (`.wrn` → `.ts`), `@wrnexus/db` (migrations, typed queries), `@wrnexus/styles` (config, profiles, `.env`, themes, styles), `@wrnexus/ui` (ejectable Wire UI components), `@wrnexus/validation`, `@wrnexus/csr`, and `@wrnexus/i18n`.
|
||||
- Reads `wrnexus.config.ts` for `db` / `databases`, `theme`, `styles`, `seo`, `security`, `i18n`, and `profiles`, and `wrnexus.workspace.ts` for the gateway.
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.2.12",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./workspace": "./src/workspace.ts"
|
||||
},
|
||||
"bin": {
|
||||
"wrnexus": "src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
"@wrnexus/router": "workspace:*",
|
||||
"@wrnexus/csr": "workspace:*",
|
||||
"@wrnexus/compiler": "workspace:*",
|
||||
"@wrnexus/styles": "workspace:*",
|
||||
"@wrnexus/dev-server": "workspace:*",
|
||||
"@wrnexus/ui": "workspace:*",
|
||||
"@wrnexus/validation": "workspace:*",
|
||||
"@wrnexus/i18n": "workspace:*",
|
||||
"@wrnexus/db": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Framework conventions shipped into scaffolded apps as CLAUDE.md and llms.txt so
|
||||
* AI coding tools (Claude Code, Cursor, Copilot) generate correct WrNexus code.
|
||||
* Generated from the repo-root llms.txt - do not edit by hand.
|
||||
*/
|
||||
|
||||
/** The canonical WrNexus conventions reference (shipped as llms.txt). */
|
||||
export const AI_GUIDE = `# WrNexus
|
||||
|
||||
> WrNexus is an SSR-first, **Bun-native** full-stack web framework. UI is written in
|
||||
> \`.wrn\` files (its own component language — NOT React/JSX/Vue). Routing is file-based.
|
||||
> This document teaches an AI how to write correct WrNexus code. It is private and
|
||||
> post-dates model training data, so rely on THIS document, not prior web-framework
|
||||
> assumptions.
|
||||
|
||||
## Golden rules
|
||||
|
||||
- **Pages, components, and layouts are \`.wrn\` files.** Do NOT write \`.tsx\`/\`.jsx\`/React
|
||||
for UI. Do NOT use \`useState\`, hooks, JSX, or a client bundler.
|
||||
- **Routing is file-based** under \`app/\`. The filename is the route. No router config.
|
||||
- **Interactivity** lives in \`state\` + \`{expr}\` + \`@event\` inside \`.wrn\`. Components render
|
||||
on the server and hydrate automatically — you never write client-side JS islands.
|
||||
- **Runtime is Bun only** (uses \`Bun.serve\`, \`bun:sqlite\`, \`Bun.password\`, …). Node is not supported.
|
||||
- To add files, prefer the CLI: \`wrnexus generate page <Name>\` / \`component <name>\` / \`api <path>\` / \`schema <name>\`.
|
||||
|
||||
## Project layout
|
||||
|
||||
\`\`\`
|
||||
app/
|
||||
pages/ *.wrn → routes: index.wrn = "/", about.wrn = "/about", blog/[slug].wrn = "/blog/:slug"
|
||||
components/ *.wrn → reusable UI, mounted in a page/component via <div data-component="name" ...props>
|
||||
layouts/ *.wrn → named layouts; a page opts in with layout = "name"
|
||||
api/ *.ts → HTTP handlers: export const GET/POST/PUT/PATCH/DELETE = async (ctx) => Response
|
||||
middleware/ *.ts → export default async (ctx, next) => next()
|
||||
realtime/ *.ts → export default defineRoom({ ... }) from "@wrnexus/core" (ws://host/realtime/<name>)
|
||||
schemas/ *.ts → validation schemas (the \`v\` builder), used by forms + parseBody
|
||||
locales/ *.json → i18n messages per language
|
||||
db/ schema.ts, queries/*.sql, migrations/*.sql
|
||||
styles/ global.css → Tailwind (default) or plain CSS
|
||||
wrnexus.config.ts → app config (AppConfig from "@wrnexus/styles")
|
||||
public/ → static assets served at /
|
||||
\`\`\`
|
||||
|
||||
## \`.wrn\` page
|
||||
|
||||
\`\`\`wrn
|
||||
page Home {
|
||||
layout = "public" // optional: a component in app/layouts/<name>.wrn ("none" to skip)
|
||||
|
||||
state count = 0 // optional: seeds client-reactive state (omit for pure SSR)
|
||||
|
||||
seo {
|
||||
title = "Home"
|
||||
description = "..."
|
||||
canonical = "/"
|
||||
}
|
||||
|
||||
view {
|
||||
<h1>Hello</h1>
|
||||
<p>Count is {count}, doubled is {count * 2}.</p>
|
||||
<button @click="count++">Increment</button>
|
||||
<div data-component="counter" start="5" label="Clicks"></div>
|
||||
}
|
||||
|
||||
style {
|
||||
h1 { color: var(--wire-color-text); }
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## \`.wrn\` component
|
||||
|
||||
\`\`\`wrn
|
||||
component Counter {
|
||||
props { // props come from mount attributes; each is coerced to the
|
||||
start = 0 // TYPE of its default (so start="5" arrives as the number 5)
|
||||
label = "Count"
|
||||
}
|
||||
state count = start // state may reference props
|
||||
view {
|
||||
<button @click="count++">{label}: {count}</button>
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Mount it from any page/component: \`<div data-component="counter" start="0" label="Clicks"></div>\`.
|
||||
Components render on the server with their props, then hydrate — no per-component JS.
|
||||
|
||||
## The \`view { }\` block (plain HTML + a few directives)
|
||||
|
||||
- \`{expr}\` — interpolate a JS expression. Reactive if it references \`state\`: \`{count}\`, \`{count * 2}\`, \`{user.name}\`.
|
||||
- \`@event="expr"\` — bind a DOM event; the expression runs in the reactive scope: \`@click="count++"\`, \`@input="name = event.target.value"\`.
|
||||
- \`<div data-component="name" prop="v">\` — mount a component (attrs become string props, coerced).
|
||||
- \`<slot></slot>\` / \`<slot name="x"></slot>\` — component/layout slots; fill with \`<div data-slot="x">…</div>\`.
|
||||
- **Server loop (DB/list/table):** \`{#each <list> as <item>[, <i>]} …rows… {:empty} …fallback… {/each}\` — iterates SSR data on the server and renders markup per item. \`{item.field}\` interpolates (HTML-escaped, XSS-safe). \`<list>\` is a JS expression, usually an \`ssr\` data binding (see "Data-driven tables" below). This is how you render a database table in \`.wrn\`.
|
||||
- **Server conditional:** \`{#if <expr>} … {:else if <expr>} … {:else} … {/if}\` — renders the first truthy branch on the server. \`<expr>\` can reference \`ssr\` data, or the \`item\`/\`index\` of an enclosing \`{#each}\`. Works at page level and inside loops (e.g. \`{#if r.active}<span>●</span>{:else}<span>○</span>{/if}\` per row). For client-side show/hide based on reactive \`state\`, use \`data-show="expr"\` instead.
|
||||
- i18n: \`{t:home.title}\` in text, \`t:placeholder="form.name"\` on attributes — resolved per request from \`app/locales/\`.
|
||||
- Theme: any element with \`data-wire-theme-toggle\` toggles light/dark; \`data-wire-theme-set="dark"\` sets it.
|
||||
- Void/self-closing tags are fine: \`<br />\`, \`<img src="..." />\`.
|
||||
- Only \`{\` and \`}\` are special (interpolation). Don't use a bare \`}\` in view text.
|
||||
|
||||
## Data-driven tables / lists (server-rendered \`.wrn\`)
|
||||
|
||||
Use an \`ssr\` data binding to fetch rows on the server, then \`{#each}\` to render them.
|
||||
This renders on the **server** (SSR-first) and is HTML-escaped by default.
|
||||
|
||||
\`\`\`wrn
|
||||
page Admin {
|
||||
layout = "dashboard"
|
||||
|
||||
// Fetch on the server. The api handler at /api/contacts returns { contacts: [...] };
|
||||
// this block's \`return contacts\` exposes that array (via \`$data\`) as the binding \`rows\`.
|
||||
ssr {
|
||||
api rows GET /api/contacts { return contacts }
|
||||
}
|
||||
|
||||
view {
|
||||
<table>
|
||||
<tbody>
|
||||
{#each rows as r, i}
|
||||
<tr>
|
||||
<td>#{i}</td>
|
||||
<td>{r.name}</td>
|
||||
<td><a href="mailto:{r.email}">{r.email}</a></td>
|
||||
</tr>
|
||||
{:empty}
|
||||
<tr><td colspan="3">No submissions yet.</td></tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
The matching API returns the array under a key the \`ssr\` block reads:
|
||||
|
||||
\`\`\`ts
|
||||
// app/api/contacts.ts → GET /api/contacts
|
||||
import { getDb } from "@wrnexus/db";
|
||||
export const GET = async () => {
|
||||
const contacts = await getDb().all("SELECT id, name, email FROM contacts ORDER BY id DESC");
|
||||
return Response.json({ contacts }); // ssr block does \`return contacts\`
|
||||
};
|
||||
\`\`\`
|
||||
|
||||
**Prefer this \`.wrn\` + \`{#each}\` approach for DB-backed tables and lists.** (\`.ts\`/\`.tsx\`
|
||||
pages returning an HTML string are also supported for fully-custom programmatic rendering,
|
||||
but a \`.wrn\` page with \`ssr\` data + \`{#each}\` is the idiomatic, SSR-first way.)
|
||||
|
||||
## API routes (\`app/api/*.ts\`)
|
||||
|
||||
\`\`\`ts
|
||||
// app/api/users/list.ts → GET /api/users/list
|
||||
import { getDb } from "@wrnexus/db";
|
||||
|
||||
export const GET = async (ctx) => {
|
||||
return Response.json({ users: await ListUsers(getDb()) });
|
||||
};
|
||||
|
||||
export const POST = async (ctx) => {
|
||||
const body = await ctx.req.json();
|
||||
return Response.json({ ok: true, body }, { status: 201 });
|
||||
};
|
||||
\`\`\`
|
||||
|
||||
\`ctx\` (the \`Context\` from \`@wrnexus/core\`) has:
|
||||
\`req: Request\`, \`url: URL\`, \`params: Record<string,string>\` (dynamic route params, e.g. \`/users/[id]\` → \`ctx.params.id\`),
|
||||
\`lang: string\`, \`t(key, params?)\` (i18n), \`cookies\` (get/set), \`session\` (get/set). Auth: \`getUser(ctx)\` after \`sessionAuth\`/\`logIn\`.
|
||||
|
||||
## Middleware & realtime
|
||||
|
||||
\`\`\`ts
|
||||
// app/middleware/logger.ts
|
||||
export default async function logger(ctx, next) {
|
||||
console.log(ctx.req.method, ctx.url.pathname);
|
||||
return next(); // return a Response WITHOUT calling next() to short-circuit
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
\`\`\`ts
|
||||
// app/realtime/chat.ts → ws://host/realtime/chat
|
||||
import { defineRoom } from "@wrnexus/core";
|
||||
export default defineRoom({
|
||||
onConnect(client) { client.send({ type: "system", text: "connected" }); },
|
||||
onMessage(client, msg) { client.room.broadcast({ type: "message", data: msg }); },
|
||||
});
|
||||
\`\`\`
|
||||
Client side: a page opts in with \`data-room="chat"\` (handled by the realtime runtime).
|
||||
|
||||
## Config (\`wrnexus.config.ts\`)
|
||||
|
||||
\`\`\`ts
|
||||
import type { AppConfig } from "@wrnexus/styles";
|
||||
const config: AppConfig = {
|
||||
seo: { title: "App", titleTemplate: "%s | App", description: "..." },
|
||||
styles: { entry: "app/styles/global.css", process: async ({ entryPath, mode }) => /* Tailwind */ "" },
|
||||
fonts: { sans: '"Inter", system-ui, sans-serif', google: [{ family: "Inter", weights: [400, 600] }] },
|
||||
theme: { default: "dark", themes: { light: { "color-primary": "#2563eb" } } },
|
||||
i18n: { default: "en", locales: ["en", "es"] },
|
||||
db: { driver: "sqlite", url: "file:./dev.db" },
|
||||
security: { cors: { enabled: true, origin: ["http://localhost:5173"] } },
|
||||
// profiles: { production: { db: { driver: "postgres", url: process.env.DATABASE_URL } } },
|
||||
};
|
||||
export default config;
|
||||
\`\`\`
|
||||
|
||||
## Database (\`@wrnexus/db\`)
|
||||
|
||||
\`\`\`ts
|
||||
// app/db/schema.ts
|
||||
import { v, table } from "@wrnexus/db";
|
||||
export const users = table("users", {
|
||||
id: v.id(),
|
||||
name: v.string(),
|
||||
email: v.string().unique(),
|
||||
createdAt: v.timestamp(),
|
||||
});
|
||||
\`\`\`
|
||||
- Queries: write \`app/db/queries/*.sql\` with \`-- name: ListUsers :many\` blocks; \`wrnexus db generate\` emits typed functions.
|
||||
- Access at runtime: \`import { getDb } from "@wrnexus/db"; const rows = await ListUsers(getDb());\`
|
||||
- Migrations in \`app/db/migrations/\`; run \`wrnexus db migrate\` (dev auto-migrates sqlite).
|
||||
|
||||
## Validation (\`@wrnexus/validation\`)
|
||||
|
||||
\`\`\`ts
|
||||
// app/schemas/login.ts
|
||||
import { v } from "@wrnexus/validation";
|
||||
export default v.object({
|
||||
email: v.string().email(),
|
||||
password: v.string().min(8),
|
||||
});
|
||||
\`\`\`
|
||||
In an API route: \`import s from "../schemas/login"; import { parseBody } from "@wrnexus/validation"; const r = await parseBody(s, ctx.req);\` → \`r.ok ? r.value : r.response\`.
|
||||
In a form: \`<form data-schema="login" action="/api/login" method="post">\` + \`<span data-error="email"></span>\` (client + server validation wired automatically).
|
||||
|
||||
## AI / LLM (\`@wrnexus/ai\`)
|
||||
|
||||
\`\`\`ts
|
||||
// app/api/ai.ts
|
||||
import { createAI } from "@wrnexus/ai";
|
||||
const ai = createAI(); // reads ANTHROPIC_API_KEY; default model claude-opus-4-8
|
||||
export const POST = async (ctx) => {
|
||||
const { prompt } = await ctx.req.json();
|
||||
return ai.streamResponse(prompt); // or: return Response.json({ text: await ai.generate(prompt) })
|
||||
};
|
||||
\`\`\`
|
||||
|
||||
## CLI
|
||||
|
||||
\`\`\`
|
||||
wrnexus dev . # dev server + HMR
|
||||
wrnexus build . # production build → dist/server.js
|
||||
wrnexus create <name> # scaffold a new app
|
||||
wrnexus generate page <Name> # scaffold a page (aliases: g p)
|
||||
wrnexus generate component <name> | api <path> | schema <name>
|
||||
wrnexus db migrate | rollback | status | new [--from-models] | generate | seed
|
||||
wrnexus eject <component> # copy a Wire UI component's .wrn into app/components to customize
|
||||
\`\`\`
|
||||
|
||||
## When asked to "create a page/component/feature"
|
||||
|
||||
1. Create the \`.wrn\` file under \`app/pages/\` (or \`app/components/\`) with a \`page\`/\`component\` block — or run \`wrnexus generate page <Name>\`.
|
||||
2. Put markup in \`view { }\`, interactive bits in \`state\` + \`{expr}\` + \`@event\`, reusable UI as components mounted via \`data-component\`.
|
||||
3. For data, add an \`app/api/*.ts\` route and \`getDb()\`; for forms, add an \`app/schemas/*.ts\` and \`data-schema\`.
|
||||
4. Style with Tailwind utility classes in the view, or theme tokens (\`var(--wire-*)\`), or \`style { }\`.
|
||||
5. Never emit React/JSX, a manual router, or client-side island JS — the framework handles hydration.\n`;
|
||||
|
||||
/** Agent-oriented instructions (shipped as CLAUDE.md): a preamble + the full guide. */
|
||||
export const CLAUDE_MD =
|
||||
`# WrNexus app - instructions for AI coding assistants
|
||||
|
||||
This is a **WrNexus** app. When creating or editing pages, components, API routes,
|
||||
or features, follow the framework conventions below. WrNexus is private and not in
|
||||
your training data, so rely on these rules - do NOT assume React/Next.js/Vue patterns.
|
||||
\n` + AI_GUIDE;
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* `wrnexus build` — production build (Point 4).
|
||||
*
|
||||
* Emits into `<appRoot>/dist`:
|
||||
* islands/<name>.js pre-built, minified island chunks
|
||||
* server.js a self-contained Bun server with a STATIC manifest of
|
||||
* every page/api/realtime/middleware module (no runtime
|
||||
* filesystem scan, no on-the-fly bundling)
|
||||
*
|
||||
* Run it with: bun dist/server.js (PORT env var optional)
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { buildRouter, type Route } from "@wrnexus/router";
|
||||
import { getReactiveRuntime } from "@wrnexus/csr";
|
||||
import { compileWireFile } from "@wrnexus/compiler";
|
||||
import {
|
||||
loadAppConfig,
|
||||
headToString,
|
||||
renderFontHead,
|
||||
findStyleEntry,
|
||||
renderStyles,
|
||||
resolveThemeConfig,
|
||||
renderThemeCss,
|
||||
renderThemeRuntime,
|
||||
} from "@wrnexus/styles";
|
||||
import { uiComponentsDir, uiCss } from "@wrnexus/ui";
|
||||
import { renderSchemasScript, type ObjectSchema, type SchemaDescriptor } from "@wrnexus/validation";
|
||||
import { loadLocales, resolveI18n } from "@wrnexus/i18n";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
// Import the production server from the package specifier (not a source path) so
|
||||
// the generated entry resolves whether @wrnexus/dev-server is a workspace or an
|
||||
// installed dependency. Bun.build bundles it into a self-contained server.js.
|
||||
const PROD_MODULE = "@wrnexus/dev-server";
|
||||
const INLINE_CSS_LIMIT_BYTES = 4096;
|
||||
|
||||
const fwd = (p: string) => p.replace(/\\/g, "/");
|
||||
|
||||
export async function runBuild(appRoot: string): Promise<void> {
|
||||
const root = resolve(appRoot);
|
||||
const appDir = join(root, "app");
|
||||
const distDir = join(root, "dist");
|
||||
const compiledDir = join(distDir, "compiled");
|
||||
const reactivePath = join(distDir, "reactive.js");
|
||||
const publicDir = join(root, "public");
|
||||
const distPublicDir = join(distDir, "public");
|
||||
|
||||
console.log(`Building ${appDir} -> ${distDir}`);
|
||||
|
||||
// Clean output.
|
||||
rmSync(distDir, { recursive: true, force: true });
|
||||
mkdirSync(compiledDir, { recursive: true });
|
||||
if (existsSync(publicDir)) {
|
||||
cpSync(publicDir, distPublicDir, { recursive: true });
|
||||
console.log(`✓ Public: ${distPublicDir}`);
|
||||
}
|
||||
|
||||
// `.wrn` route files are compiled to `.ts` so Bun.build can bundle them.
|
||||
let compiledCount = 0;
|
||||
const importPathFor = (file: string): string => {
|
||||
if (!file.endsWith(".wrn")) return fwd(file);
|
||||
const ts = compileWireFile(readFileSync(file, "utf8"));
|
||||
const out = join(compiledDir, `route${compiledCount++}.ts`);
|
||||
writeFileSync(out, ts, "utf8");
|
||||
return fwd(out);
|
||||
};
|
||||
|
||||
const config = await loadAppConfig(root);
|
||||
// Regenerate typed DB queries (app/db/queries/*.sql → queries.gen.ts) first, so
|
||||
// any page/API importing them is built against the current SQL.
|
||||
const { regenerateQueries } = await import("./db.ts");
|
||||
const generated = await regenerateQueries(appDir, config.db?.driver);
|
||||
if (generated >= 0) console.log(`✓ Queries: ${generated} (db/queries.gen.ts)`);
|
||||
for (const [name, cfg] of Object.entries(config.databases ?? {})) {
|
||||
const n = await regenerateQueries(appDir, cfg.driver, name);
|
||||
if (n >= 0) console.log(`✓ Queries: ${n} (db/${name}/queries.gen.ts)`);
|
||||
}
|
||||
|
||||
// Bundle DB migrations into the build so the production server can auto-apply
|
||||
// them on startup (dev auto-migrates from app/db/migrations; prod needs the
|
||||
// .sql files inside dist/). The default db's migrations go to dist/migrations;
|
||||
// each named db's to dist/db/<name>/migrations.
|
||||
const defaultMigrationsSrc = join(appDir, "db", "migrations");
|
||||
const hasDefaultMigrations = !!config.db && existsSync(defaultMigrationsSrc);
|
||||
if (hasDefaultMigrations) {
|
||||
cpSync(defaultMigrationsSrc, join(distDir, "migrations"), { recursive: true });
|
||||
console.log(`✓ Migrations: dist/migrations`);
|
||||
}
|
||||
const namedMigrationDbs: string[] = [];
|
||||
for (const name of Object.keys(config.databases ?? {})) {
|
||||
const src = join(appDir, "db", name, "migrations");
|
||||
if (!existsSync(src)) continue;
|
||||
cpSync(src, join(distDir, "db", name, "migrations"), { recursive: true });
|
||||
namedMigrationDbs.push(name);
|
||||
console.log(`✓ Migrations: dist/db/${name}/migrations`);
|
||||
}
|
||||
|
||||
const router = buildRouter(appDir, { componentDirs: [uiComponentsDir()] });
|
||||
const assetHash = createHash("sha256");
|
||||
|
||||
// 1) Components are `.wrn` modules rendered server-side — no browser chunks.
|
||||
// They are compiled + statically imported into the manifest below.
|
||||
const reactiveCode = await buildBrowserRuntime(
|
||||
getReactiveRuntime(),
|
||||
reactivePath,
|
||||
join(compiledDir, "reactive.entry.js"),
|
||||
);
|
||||
assetHash.update(reactiveCode);
|
||||
console.log(`✓ Runtime: ${reactivePath}`);
|
||||
|
||||
// 1a) Theme tokens + client switcher (always emitted; built-in light/dark).
|
||||
const theme = resolveThemeConfig(config.theme);
|
||||
const themeCss = renderThemeCss(theme);
|
||||
const themeJs = renderThemeRuntime(theme);
|
||||
writeFileSync(join(distDir, "theme.css"), themeCss, "utf8");
|
||||
writeFileSync(join(distDir, "theme.js"), themeJs, "utf8");
|
||||
assetHash.update(themeCss);
|
||||
assetHash.update(themeJs);
|
||||
console.log(`✓ Theme: ${theme.names.length} themes (default: ${theme.default})`);
|
||||
|
||||
// 1a2) Wire UI stylesheet (all component classes, themed via tokens).
|
||||
const uiStyles = uiCss();
|
||||
writeFileSync(join(distDir, "ui.css"), uiStyles, "utf8");
|
||||
assetHash.update(uiStyles);
|
||||
console.log(`✓ UI: dist/ui.css`);
|
||||
|
||||
// 1a3) Validation: bake schema descriptors into the client script.
|
||||
const descriptors: Record<string, SchemaDescriptor> = {};
|
||||
for (const s of router.schemas) {
|
||||
const mod = (await import(pathToFileURL(s.file).href)) as { default?: ObjectSchema };
|
||||
if (mod.default && typeof mod.default.describe === "function") {
|
||||
descriptors[s.name] = mod.default.describe();
|
||||
}
|
||||
}
|
||||
const schemasJs = renderSchemasScript(descriptors);
|
||||
assetHash.update(schemasJs);
|
||||
if (router.schemas.length) console.log(`✓ Schemas: ${router.schemas.length}`);
|
||||
|
||||
// 1a4) i18n: bake locale messages into the manifest (opt-in via app/locales).
|
||||
const localeMessages = loadLocales(join(appDir, "locales"));
|
||||
const i18n = Object.keys(localeMessages).length
|
||||
? resolveI18n(localeMessages, config.i18n)
|
||||
: undefined;
|
||||
if (i18n) console.log(`✓ i18n: ${i18n.langs.length} locales (default: ${i18n.default})`);
|
||||
|
||||
// 1b) Build the global stylesheet, if any.
|
||||
const styleEntry = findStyleEntry(appDir, root, config.styles?.entry);
|
||||
let hasStyles = false;
|
||||
let inlineStyles = "";
|
||||
if (styleEntry) {
|
||||
const css = await renderStyles(
|
||||
{ entryPath: styleEntry, appDir, appRoot: root, mode: "production" },
|
||||
config.styles,
|
||||
);
|
||||
assetHash.update(css);
|
||||
writeFileSync(join(distDir, "styles.css"), css, "utf8");
|
||||
hasStyles = true;
|
||||
if (Buffer.byteLength(css, "utf8") <= INLINE_CSS_LIMIT_BYTES) {
|
||||
inlineStyles = css;
|
||||
}
|
||||
console.log(`✓ Styles: ${join(distDir, "styles.css")}`);
|
||||
}
|
||||
const assetVersion = assetHash.digest("hex").slice(0, 12);
|
||||
const headStr = [renderFontHead(config.fonts), headToString(config.head)]
|
||||
.filter(Boolean)
|
||||
.join("\n ");
|
||||
|
||||
// 2) Generate a server entry with STATIC imports + a manifest.
|
||||
const imports: string[] = [];
|
||||
let counter = 0;
|
||||
|
||||
const manifestRoutes = (routes: Route[]): string => {
|
||||
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} },`;
|
||||
});
|
||||
return parts.length ? `\n${parts.join("\n")}\n ` : "";
|
||||
};
|
||||
|
||||
const pagesLit = manifestRoutes(router.pages);
|
||||
const apiLit = manifestRoutes(router.api);
|
||||
const realtimeLit = manifestRoutes(router.realtime);
|
||||
|
||||
const mwVars = router.middlewareFiles.map((file) => {
|
||||
const v = `mw${counter++}`;
|
||||
imports.push(`import ${v} from ${JSON.stringify(fwd(file))};`);
|
||||
return v;
|
||||
});
|
||||
|
||||
// Components: compile each `.wrn` to a module and statically import it,
|
||||
// keyed by name so the production runtime can render it on demand.
|
||||
const componentsLit = router.components
|
||||
.map((c) => {
|
||||
const v = `c${counter++}`;
|
||||
imports.push(`import * as ${v} from ${JSON.stringify(importPathFor(c.file))};`);
|
||||
return `{ name: ${JSON.stringify(c.name)}, mod: ${v} }`;
|
||||
})
|
||||
.join(", ");
|
||||
console.log(`✓ Components: ${router.components.length}`);
|
||||
|
||||
// Named page layouts (app/layouts/*.wrn), compiled + imported like components.
|
||||
const layoutsLit = router.layouts
|
||||
.map((l) => {
|
||||
const v = `c${counter++}`;
|
||||
imports.push(`import * as ${v} from ${JSON.stringify(importPathFor(l.file))};`);
|
||||
return `{ name: ${JSON.stringify(l.name)}, mod: ${v} }`;
|
||||
})
|
||||
.join(", ");
|
||||
if (router.layouts.length) console.log(`✓ Layouts: ${router.layouts.length}`);
|
||||
|
||||
const entry = `// AUTO-GENERATED production server entry — do not edit.
|
||||
import { join } from "node:path";
|
||||
import { createProductionServer } from ${JSON.stringify(PROD_MODULE)};
|
||||
${imports.join("\n")}
|
||||
|
||||
await createProductionServer(
|
||||
{
|
||||
pages: [${pagesLit}],
|
||||
api: [${apiLit}],
|
||||
realtime: [${realtimeLit}],
|
||||
middleware: [${mwVars.join(", ")}],
|
||||
components: [${componentsLit}],
|
||||
layouts: [${layoutsLit}],
|
||||
},
|
||||
{
|
||||
reactivePath: join(import.meta.dir, "reactive.js"),
|
||||
themePath: join(import.meta.dir, "theme.css"),
|
||||
themeJsPath: join(import.meta.dir, "theme.js"),
|
||||
theme: ${JSON.stringify(theme)},
|
||||
uiCssPath: join(import.meta.dir, "ui.css"),
|
||||
schemasJs: ${JSON.stringify(schemasJs)},
|
||||
i18n: ${i18n ? JSON.stringify(i18n) : "undefined"},
|
||||
db: ${config.db ? JSON.stringify(config.db) : "undefined"},
|
||||
databases: ${config.databases ? JSON.stringify(config.databases) : "undefined"},
|
||||
storage: ${config.storage ? JSON.stringify(config.storage) : "undefined"},
|
||||
${hasDefaultMigrations ? `migrationsDir: join(import.meta.dir, "migrations"),` : ""}
|
||||
${
|
||||
namedMigrationDbs.length
|
||||
? `databaseMigrationDirs: { ${namedMigrationDbs
|
||||
.map(
|
||||
(n) =>
|
||||
`${JSON.stringify(n)}: join(import.meta.dir, "db", ${JSON.stringify(n)}, "migrations")`,
|
||||
)
|
||||
.join(", ")} },`
|
||||
: ""
|
||||
}
|
||||
realtime: ${config.realtime ? JSON.stringify(config.realtime) : "undefined"},
|
||||
publicDir: join(import.meta.dir, "public"),
|
||||
${hasStyles ? `stylesPath: join(import.meta.dir, "styles.css"),` : ""}
|
||||
${inlineStyles ? `inlineStyles: ${JSON.stringify(inlineStyles)},` : ""}
|
||||
assetVersion: ${JSON.stringify(assetVersion)},
|
||||
head: ${JSON.stringify(headStr)},
|
||||
seo: ${JSON.stringify(config.seo ?? {})},
|
||||
mobile: ${JSON.stringify(config.mobile ?? {})},
|
||||
pwa: ${JSON.stringify(config.pwa ?? {})},
|
||||
security: ${JSON.stringify(config.security ?? {})},
|
||||
},
|
||||
);
|
||||
`;
|
||||
|
||||
const entryPath = join(distDir, ".server-entry.ts");
|
||||
writeFileSync(entryPath, entry, "utf8");
|
||||
|
||||
// 3) Bundle the entry into a single self-contained, minified server.js
|
||||
// (target bun). This also minifies every bundled page/component/route module.
|
||||
const result = await Bun.build({
|
||||
entrypoints: [entryPath],
|
||||
target: "bun",
|
||||
format: "esm",
|
||||
minify: true,
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error("Server build failed:\n" + result.logs.map(String).join("\n"));
|
||||
}
|
||||
writeFileSync(join(distDir, "server.js"), await result.outputs[0]!.text(), "utf8");
|
||||
|
||||
console.log(`✓ Server: ${join(distDir, "server.js")}`);
|
||||
console.log(
|
||||
`✓ Routes: ${router.pages.length} pages, ${router.api.length} api, ${router.realtime.length} realtime, ${mwVars.length} middleware`,
|
||||
);
|
||||
console.log(`\nRun it: bun ${fwd(join(distDir, "server.js"))}`);
|
||||
}
|
||||
|
||||
async function buildBrowserRuntime(
|
||||
source: string,
|
||||
outFile: string,
|
||||
entryFile: string,
|
||||
): Promise<string> {
|
||||
writeFileSync(entryFile, source, "utf8");
|
||||
const result = await Bun.build({
|
||||
entrypoints: [entryFile],
|
||||
target: "browser",
|
||||
format: "esm",
|
||||
minify: true,
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error("Runtime build failed:\n" + result.logs.map(String).join("\n"));
|
||||
}
|
||||
const code = await result.outputs[0]!.text();
|
||||
writeFileSync(outFile, code, "utf8");
|
||||
return code;
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* `wrnexus create <app-name>` — scaffold a new app from an inline template.
|
||||
*
|
||||
* Kept dependency-free and explicit: the template files live here as strings so
|
||||
* scaffolding works without copying from anywhere on disk.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { AI_GUIDE, CLAUDE_MD } from "./ai-guide.ts";
|
||||
|
||||
/** Map of file (relative to app root) -> contents. */
|
||||
const TEMPLATE: Record<string, string> = {
|
||||
// AI/agent context: teaches Claude Code / Cursor / Copilot the WrNexus conventions.
|
||||
"CLAUDE.md": CLAUDE_MD,
|
||||
"llms.txt": AI_GUIDE,
|
||||
"package.json": `{
|
||||
"name": "APP_NAME",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wrnexus dev .",
|
||||
"build": "wrnexus build .",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier . --write",
|
||||
"format:check": "prettier . --check",
|
||||
"check": "bun run lint && bun run format:check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/ai": "^0.2.0",
|
||||
"@wrnexus/core": "^0.2.0",
|
||||
"@wrnexus/styles": "^0.2.0",
|
||||
"@wrnexus/validation": "^0.2.0",
|
||||
"@wrnexus/db": "^0.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@wrnexus/cli": "^0.2.0",
|
||||
"@eslint/js": "^9.0.0",
|
||||
"@tailwindcss/cli": "^4.0.0",
|
||||
"@types/bun": "latest",
|
||||
"eslint": "^9.0.0",
|
||||
"prettier": "latest",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.5.0",
|
||||
"typescript-eslint": "latest"
|
||||
}
|
||||
}
|
||||
`,
|
||||
"tsconfig.json": `{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"types": ["bun"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": false,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "@wrnexus/core"
|
||||
},
|
||||
"include": ["app", "wrnexus.config.ts"],
|
||||
"exclude": ["node_modules", "dist", "**/dist", "**/.wrnexus"]
|
||||
}
|
||||
`,
|
||||
"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/**", ".wrnexus/**", "**/.wrnexus/**", "mobile/android/**", "mobile/ios/**"],
|
||||
},
|
||||
{
|
||||
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: "^_",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
`,
|
||||
".prettierrc.json": `{
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
`,
|
||||
".prettierignore": `node_modules/
|
||||
dist/
|
||||
.wrnexus/
|
||||
**/.wrnexus/
|
||||
*.log
|
||||
`,
|
||||
".editorconfig": `root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
`,
|
||||
"wrnexus.config.ts": `import type { AppConfig } from "@wrnexus/styles";
|
||||
|
||||
const config: AppConfig = {
|
||||
mobile: {
|
||||
enabled: true,
|
||||
appId: "com.example.APP_SLUG",
|
||||
appName: "APP_NAME",
|
||||
userAgent: "WrNexusMobile",
|
||||
backgroundColor: "#0f172a",
|
||||
// layout: "mobile", // app/layouts/mobile.wrn
|
||||
// icon: "resources/icon.png",
|
||||
},
|
||||
|
||||
// PWA support is enabled automatically. Override any install metadata here.
|
||||
pwa: {
|
||||
name: "APP_NAME",
|
||||
shortName: "APP_NAME",
|
||||
display: "standalone",
|
||||
themeColor: "#6366f1",
|
||||
backgroundColor: "#0f172a",
|
||||
},
|
||||
|
||||
seo: {
|
||||
title: "APP_NAME",
|
||||
titleTemplate: "%s | APP_NAME",
|
||||
description: "An SSR-first WrNexus app.",
|
||||
robots: "index,follow",
|
||||
themeColor: "#6366f1",
|
||||
},
|
||||
|
||||
styles: {
|
||||
entry: "app/styles/global.css",
|
||||
|
||||
// Tailwind v4 build. Runs once at dev-serve time (cached; re-run on restart)
|
||||
// and at \`wrnexus build\`. \`@tailwindcss/cli\` writes to stdout, so we capture
|
||||
// and return the final CSS. Delete this hook to drop Tailwind — global.css is
|
||||
// still bundled and served as-is.
|
||||
process: async ({ entryPath, mode }) => {
|
||||
const args = ["@tailwindcss/cli", "-i", entryPath!];
|
||||
if (mode === "production") args.push("--minify");
|
||||
return await Bun.$\`bunx \${args}\`.text();
|
||||
},
|
||||
},
|
||||
|
||||
// Fonts — the framework emits optimized <head> markup (preconnect, subsetted
|
||||
// Google Fonts with font-display, self-hosted @font-face with preload) and
|
||||
// auto-extends the CSP for Google Fonts. Uncomment to use a custom font:
|
||||
//
|
||||
// fonts: {
|
||||
// sans: '"Inter", ui-sans-serif, system-ui, sans-serif',
|
||||
// google: [{ family: "Inter", weights: [400, 500, 600, 700] }],
|
||||
// // 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"],
|
||||
// },
|
||||
// },
|
||||
};
|
||||
|
||||
export default config;
|
||||
`,
|
||||
"public/robots.txt": `User-agent: *
|
||||
Allow: /
|
||||
`,
|
||||
"app/styles/global.css": `/*
|
||||
* Global stylesheet. Tailwind v4 is compiled by the styles.process hook in
|
||||
* wrnexus.config.ts and served at /__wrnexus/styles.css on every page.
|
||||
*
|
||||
* @source tells Tailwind which files to scan for class names.
|
||||
*/
|
||||
@import "tailwindcss";
|
||||
@source "../**/*.wrn";
|
||||
@source "../**/*.tsx";
|
||||
|
||||
/* Make Tailwind's \`dark:\` variant follow the framework's data-theme attribute
|
||||
* (set on <html> by the theme system), not the OS setting. Any element with
|
||||
* data-wire-theme-toggle flips it. */
|
||||
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
||||
|
||||
body {
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
`,
|
||||
"app/pages/index.wrn": `// Home page (route: /). SSR-first: the view is server-rendered, then components
|
||||
// (.wrn files under app/components) hydrate in the browser. Styled with Tailwind.
|
||||
page Home {
|
||||
seo {
|
||||
title = "Home"
|
||||
description = "APP_NAME — built with WrNexus, an SSR-first Bun framework."
|
||||
}
|
||||
|
||||
view {
|
||||
<main class="relative min-h-screen overflow-hidden bg-white text-slate-900 dark:bg-[#0b0f1e] dark:text-slate-100">
|
||||
<div aria-hidden="true" class="pointer-events-none absolute inset-x-0 -top-40 mx-auto h-96 max-w-2xl rounded-full bg-indigo-500/20 blur-3xl"></div>
|
||||
|
||||
<div class="relative mx-auto flex min-h-screen max-w-3xl flex-col px-6">
|
||||
<header class="flex items-center justify-between py-6">
|
||||
<span class="flex items-center gap-2.5 font-semibold tracking-tight">
|
||||
<span class="grid h-7 w-7 place-items-center rounded-md bg-gradient-to-br from-indigo-500 to-violet-600 text-sm font-bold text-white">W</span>
|
||||
APP_NAME
|
||||
</span>
|
||||
<button data-wire-theme-toggle class="rounded-md border border-slate-200 px-3 py-1.5 text-sm text-slate-600 transition hover:border-slate-300 hover:text-slate-900 dark:border-white/10 dark:text-slate-400 dark:hover:border-white/20 dark:hover:text-white">
|
||||
Toggle theme
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section class="flex flex-1 flex-col items-center justify-center py-16 text-center">
|
||||
<p class="font-mono text-xs uppercase tracking-[0.2em] text-indigo-500 dark:text-indigo-400">SSR-first · Bun-native</p>
|
||||
|
||||
<h1 class="mt-5 text-4xl font-bold leading-[1.1] tracking-tight sm:text-6xl">
|
||||
Server-rendered.<br />
|
||||
Instantly <span class="bg-gradient-to-r from-indigo-500 to-violet-500 bg-clip-text text-transparent">interactive</span>.
|
||||
</h1>
|
||||
|
||||
<p class="mt-5 max-w-md text-base leading-relaxed text-slate-600 dark:text-slate-400">
|
||||
APP_NAME runs on WrNexus — write <code class="rounded bg-slate-100 px-1.5 py-0.5 font-mono text-[0.85em] text-slate-800 dark:bg-white/10 dark:text-slate-200">.wrn</code> components, ship no client boilerplate, and let the server do the work.
|
||||
</p>
|
||||
|
||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||
<a href="/about" class="rounded-lg bg-slate-900 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition hover:bg-slate-700 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200">Get started</a>
|
||||
<a href="/hello" class="rounded-lg border border-slate-200 px-5 py-2.5 text-sm font-medium text-slate-700 transition hover:border-slate-300 dark:border-white/10 dark:text-slate-300 dark:hover:border-white/20">View demo</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-14 w-full max-w-md rounded-2xl border border-slate-200 bg-white p-6 text-left shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||
<div class="flex items-center gap-2 font-mono text-xs text-slate-400">
|
||||
<span class="h-2 w-2 rounded-full bg-emerald-400"></span>
|
||||
live · hydrated on the server
|
||||
</div>
|
||||
<div class="mt-4 flex items-center justify-between gap-4">
|
||||
<div data-component="counter" start="0" label="Clicks"></div>
|
||||
<span class="max-w-[10rem] text-right text-xs leading-snug text-slate-500">This button works. You wrote zero client JavaScript.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-10 font-mono text-xs text-slate-400 dark:text-slate-600">
|
||||
edit <span class="text-slate-600 dark:text-slate-400">app/pages/index.wrn</span> to make it yours
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<footer class="border-t border-slate-100 py-6 text-center text-xs text-slate-400 dark:border-white/5 dark:text-slate-600">
|
||||
Built with <a href="https://www.npmjs.com/package/@wrnexus/cli" class="text-slate-600 underline-offset-2 hover:underline dark:text-slate-400">WrNexus</a>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
`,
|
||||
"app/components/counter.wrn": `// A reusable component. Route: none — mounted inside a page with
|
||||
// <div data-component="counter" ...props></div>.
|
||||
//
|
||||
// Components render on the SERVER (with their props applied) and are hydrated in
|
||||
// the browser by the generic reactive runtime — they ship no JS of their own.
|
||||
component Counter {
|
||||
// Props arrive as mount attributes, each coerced to the type of its default
|
||||
// (so start="5" arrives as the number 5).
|
||||
props {
|
||||
start = 0
|
||||
label = "Count"
|
||||
}
|
||||
|
||||
// State can reference props. \`count\` seeds the reactive scope.
|
||||
state count = start
|
||||
|
||||
view {
|
||||
<button @click="count++" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-indigo-500 active:scale-[0.98]">{label}: {count}</button>
|
||||
}
|
||||
}
|
||||
`,
|
||||
"app/api/hello.ts": `export const GET = async () => {
|
||||
return Response.json({ message: "Hello API" });
|
||||
};
|
||||
`,
|
||||
"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";
|
||||
|
||||
const ai = createAI(); // reads ANTHROPIC_API_KEY; defaults to claude-opus-4-8
|
||||
|
||||
export const POST = async (ctx) => {
|
||||
if (!process.env.ANTHROPIC_API_KEY) {
|
||||
return Response.json({ error: "Set ANTHROPIC_API_KEY to use AI." }, { status: 501 });
|
||||
}
|
||||
const { prompt } = await ctx.req.json().catch(() => ({}));
|
||||
if (!prompt) return Response.json({ error: "Provide a 'prompt'." }, { status: 400 });
|
||||
|
||||
// Stream the reply back as plain text. Use \`ai.generate(prompt)\` for a one-shot string.
|
||||
return ai.streamResponse(prompt);
|
||||
};
|
||||
`,
|
||||
"app/middleware/logger.ts": `export default async function logger(ctx, next) {
|
||||
console.log(ctx.req.method, ctx.url.pathname);
|
||||
return next();
|
||||
}
|
||||
`,
|
||||
"app/realtime/chat.ts": `// ws://<host>/realtime/chat — a simple broadcast room.
|
||||
//
|
||||
// The client side is the framework's realtime runtime; a page opts in with
|
||||
// \`data-room="chat"\`. Here we only handle room events.
|
||||
//
|
||||
// client.send(msg) → just this connection
|
||||
// client.broadcast(msg) → everyone else in the room
|
||||
// client.room.broadcast(msg) → everyone, including the sender
|
||||
import { defineRoom } from "@wrnexus/core";
|
||||
|
||||
export default defineRoom({
|
||||
onConnect(client) {
|
||||
client.send({ type: "system", text: "connected" });
|
||||
},
|
||||
|
||||
onMessage(client, msg) {
|
||||
// Echo each message to the whole room so every tab stays in sync.
|
||||
client.room.broadcast({ type: "message", data: msg });
|
||||
},
|
||||
});
|
||||
`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Write the app template into `root` (absolute), substituting the app name.
|
||||
* Reused by `wrnexus create` and the workspace scaffolder. Refuses to overwrite.
|
||||
*/
|
||||
export function scaffoldApp(root: string, appName: string): void {
|
||||
if (existsSync(root)) {
|
||||
console.error(`Refusing to overwrite existing directory: ${root}`);
|
||||
process.exit(1);
|
||||
}
|
||||
for (const [rel, contents] of Object.entries(TEMPLATE)) {
|
||||
const target = join(root, rel);
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
const appSlug = appName.toLowerCase().replace(/[^a-z0-9]+/g, "") || "app";
|
||||
writeFileSync(
|
||||
target,
|
||||
contents.replaceAll("APP_NAME", appName).replaceAll("APP_SLUG", appSlug),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function createApp(name: string): void {
|
||||
if (!name) {
|
||||
console.error("Usage: wrnexus create <app-name>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
scaffoldApp(resolve(process.cwd(), name), name);
|
||||
|
||||
console.log(`✓ Created ${name}`);
|
||||
console.log(`\nNext steps:`);
|
||||
console.log(` cd ${name}`);
|
||||
console.log(` bun install`);
|
||||
console.log(` bun run dev`);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* `wrnexus db <cmd> [--db=<name>]` — database migrations & tooling.
|
||||
*
|
||||
* wrnexus db new <name> [--from-models] scaffold a migration (from TS models)
|
||||
* wrnexus db migrate apply all pending migrations
|
||||
* wrnexus db rollback revert the last applied migration
|
||||
* wrnexus db status list applied / pending migrations
|
||||
* wrnexus db generate regenerate typed queries
|
||||
* wrnexus db seed run the seed script
|
||||
* wrnexus db studio [table] inspect tables
|
||||
*
|
||||
* Without `--db`, commands target the DEFAULT database (`db` in wrnexus.config.ts),
|
||||
* with files under `app/db/`. With `--db=<name>`, they target the named database
|
||||
* (`databases.<name>`), with files under `app/db/<name>/`.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { loadAppConfig, type AppConfig } from "@wrnexus/styles";
|
||||
import {
|
||||
generateQueriesFile,
|
||||
migrate,
|
||||
parseQueries,
|
||||
rollback,
|
||||
scaffoldMigration,
|
||||
status,
|
||||
type Dialect,
|
||||
type Model,
|
||||
type ModelRef,
|
||||
} from "@wrnexus/db";
|
||||
import { connectFromConfig } from "@wrnexus/db/connect";
|
||||
|
||||
function dialectOf(driver: string | undefined): Dialect {
|
||||
return driver === "postgres" || driver === "mysql" ? driver : "sqlite";
|
||||
}
|
||||
|
||||
/** The directory holding a database's schema/migrations/queries. */
|
||||
function dbBaseOf(appDir: string, dbName: string | null): string {
|
||||
return dbName ? join(appDir, "db", dbName) : join(appDir, "db");
|
||||
}
|
||||
|
||||
/** List user tables for the connected database (dialect-aware introspection). */
|
||||
async function listTables(db: import("@wrnexus/db").Db): Promise<string[]> {
|
||||
const dialect = db.driver.dialect;
|
||||
const sql =
|
||||
dialect === "postgres"
|
||||
? "SELECT tablename AS name FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename"
|
||||
: dialect === "mysql"
|
||||
? "SELECT table_name AS name FROM information_schema.tables WHERE table_schema = DATABASE() ORDER BY table_name"
|
||||
: "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name";
|
||||
const rows = await db.all<{ name: string }>(sql);
|
||||
return rows.map((r) => r.name).filter((n) => n !== "_wire_migrations");
|
||||
}
|
||||
|
||||
function isModel(value: unknown): value is Model {
|
||||
const m = value as Partial<Model> | null;
|
||||
return (
|
||||
!!m &&
|
||||
typeof m === "object" &&
|
||||
typeof m.name === "string" &&
|
||||
typeof m.parse === "function" &&
|
||||
typeof m.describe === "function" &&
|
||||
!!m.columns
|
||||
);
|
||||
}
|
||||
|
||||
/** Load model refs from a database's `schema.ts` (dbBase is app/db or app/db/<name>). */
|
||||
async function loadModelRefs(dbBase: string): Promise<ModelRef[]> {
|
||||
const schemaFile = join(dbBase, "schema.ts");
|
||||
if (!existsSync(schemaFile)) return [];
|
||||
const mod = (await import(pathToFileURL(schemaFile).href)) as Record<string, unknown>;
|
||||
return Object.entries(mod)
|
||||
.filter(([, value]) => isModel(value))
|
||||
.map(([varName, model]) => ({ varName, model: model as Model }));
|
||||
}
|
||||
|
||||
async function loadModels(dbBase: string): Promise<Model[]> {
|
||||
return (await loadModelRefs(dbBase)).map((r) => r.model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate one database's `queries.gen.ts` from its `queries/*.sql`. Returns the
|
||||
* number of queries generated, or -1 if there is no queries directory. `dbName`
|
||||
* selects a named database (files under app/db/<name>/); null = the default.
|
||||
*/
|
||||
export async function regenerateQueries(
|
||||
appDir: string,
|
||||
driver: string | undefined,
|
||||
dbName: string | null = null,
|
||||
): Promise<number> {
|
||||
const dbBase = dbBaseOf(appDir, dbName);
|
||||
const queriesDir = join(dbBase, "queries");
|
||||
if (!existsSync(queriesDir)) return -1;
|
||||
const queries = readdirSync(queriesDir)
|
||||
.filter((f) => f.endsWith(".sql"))
|
||||
.flatMap((f) => parseQueries(readFileSync(join(queriesDir, f), "utf8")));
|
||||
const refs = await loadModelRefs(dbBase);
|
||||
const code = generateQueriesFile(queries, refs, dialectOf(driver));
|
||||
writeFileSync(join(dbBase, "queries.gen.ts"), code, "utf8");
|
||||
return queries.length;
|
||||
}
|
||||
|
||||
/** Regenerate typed queries for the default database and every named one. */
|
||||
export async function regenerateAllQueries(appDir: string, config: AppConfig): Promise<void> {
|
||||
await regenerateQueries(appDir, config.db?.driver, null);
|
||||
for (const [name, cfg] of Object.entries(config.databases ?? {})) {
|
||||
await regenerateQueries(appDir, cfg.driver, name);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runDbCommand(
|
||||
appRoot: string,
|
||||
sub: string | undefined,
|
||||
args: string[],
|
||||
): Promise<void> {
|
||||
const root = resolve(appRoot);
|
||||
const appDir = join(root, "app");
|
||||
const config = await loadAppConfig(root);
|
||||
|
||||
// --db=<name> targets a named database + its app/db/<name>/ folder.
|
||||
const dbFlag = args.find((a) => a.startsWith("--db="));
|
||||
const dbName = dbFlag ? (dbFlag.split("=")[1] ?? "") : null;
|
||||
const dbConfig = dbName ? config.databases?.[dbName] : config.db;
|
||||
const dbBase = dbBaseOf(appDir, dbName);
|
||||
const migrationsDir = join(dbBase, "migrations");
|
||||
const label = dbName ? ` (db: ${dbName})` : "";
|
||||
|
||||
if (dbName && !config.databases?.[dbName]) {
|
||||
console.error(
|
||||
`No database named '${dbName}' in wrnexus.config.ts. ` +
|
||||
`Add it under databases: { ${dbName}: { driver, url } }.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (sub === "new") {
|
||||
const name = args.find((a) => !a.startsWith("--")) ?? "migration";
|
||||
const fromModels = args.includes("--from-models");
|
||||
const models = fromModels ? await loadModels(dbBase) : [];
|
||||
if (fromModels && models.length === 0) {
|
||||
console.warn(`[wrnexus] no models found in ${join(dbBase, "schema.ts")}`);
|
||||
}
|
||||
console.log(
|
||||
`✓ Created ${scaffoldMigration(migrationsDir, name, dialectOf(dbConfig?.driver), models)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === "generate") {
|
||||
const count = await regenerateQueries(appDir, dbConfig?.driver, dbName);
|
||||
if (count < 0) console.warn(`[wrnexus] no ${join(dbBase, "queries")} directory`);
|
||||
else console.log(`✓ Generated ${join(dbBase, "queries.gen.ts")} (${count} queries)`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dbConfig) {
|
||||
console.error(
|
||||
"No `db` config in wrnexus.config.ts. Add: db: { driver: 'sqlite', url: 'file:./dev.db' }",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const db = connectFromConfig(dbConfig, root);
|
||||
try {
|
||||
switch (sub) {
|
||||
case "seed": {
|
||||
const seedFile = join(dbBase, "seed.ts");
|
||||
if (!existsSync(seedFile)) {
|
||||
console.warn(`[wrnexus] no ${seedFile}`);
|
||||
break;
|
||||
}
|
||||
const mod = (await import(pathToFileURL(seedFile).href)) as {
|
||||
default?: (db: unknown) => Promise<void>;
|
||||
seed?: (db: unknown) => Promise<void>;
|
||||
};
|
||||
const fn = mod.default ?? mod.seed;
|
||||
if (typeof fn !== "function") {
|
||||
console.error(`${seedFile} must export a default async function(db).`);
|
||||
process.exit(1);
|
||||
}
|
||||
await fn(db);
|
||||
console.log(`✓ Seeded${label}`);
|
||||
break;
|
||||
}
|
||||
case "studio": {
|
||||
const target = args.find((a) => !a.startsWith("--"));
|
||||
const tables = await listTables(db);
|
||||
if (target) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(target)) {
|
||||
console.error(`Invalid table name: ${target}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!tables.includes(target)) {
|
||||
console.error(`No such table: ${target}. Available: ${tables.join(", ") || "(none)"}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const rows = await db.all(`SELECT * FROM ${target} LIMIT 50`);
|
||||
console.log(`\n${target}${label} — first ${rows.length} row(s):`);
|
||||
console.table(rows);
|
||||
} else if (tables.length === 0) {
|
||||
console.log(
|
||||
`No tables found${label}. Run \`wrnexus db migrate${dbFlag ? " " + dbFlag : ""}\` first.`,
|
||||
);
|
||||
} else {
|
||||
console.log(`\nTables${label}:`);
|
||||
for (const t of tables) {
|
||||
const count = await db.one<{ n: number }>(`SELECT COUNT(*) AS n FROM ${t}`);
|
||||
console.log(` ${t.padEnd(24)} ${Number(count?.n ?? 0)} rows`);
|
||||
}
|
||||
console.log("\nInspect one with: wrnexus db studio <table>");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "migrate": {
|
||||
const applied = await migrate(db, migrationsDir);
|
||||
console.log(
|
||||
applied.length
|
||||
? `✓ Applied ${applied.length}${label}:\n ${applied.join("\n ")}`
|
||||
: `Already up to date${label}.`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "rollback": {
|
||||
const name = await rollback(db, migrationsDir);
|
||||
console.log(name ? `✓ Rolled back ${name}${label}` : `Nothing to roll back${label}.`);
|
||||
break;
|
||||
}
|
||||
case "status": {
|
||||
const rows = await status(db, migrationsDir);
|
||||
if (rows.length === 0) console.log(`No migrations found in ${migrationsDir}.`);
|
||||
else for (const r of rows) console.log(` [${r.applied ? "x" : " "}] ${r.name}`);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.error(
|
||||
"Usage: wrnexus db <migrate|rollback|status|generate|seed|studio [table]|new [name] [--from-models]> [--db=<name>]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* `wrnexus dev` — the development supervisor.
|
||||
*
|
||||
* The child server process owns file watching and HMR now (see @wrnexus/dev-server):
|
||||
* - CSS and client-island edits update the live page over a WebSocket with no
|
||||
* process restart and no full reload.
|
||||
* - When a server module changes (it can't be re-imported in-process), the
|
||||
* child exits with RESTART_EXIT_CODE and this supervisor respawns it. The
|
||||
* browser reconnects and morphs in the new HTML — no visible refresh.
|
||||
*
|
||||
* The supervisor therefore only (re)launches the child; it does not watch files.
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { resolve, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { RESTART_EXIT_CODE } from "@wrnexus/dev-server";
|
||||
|
||||
// Resolve the dev server child entry through the package (not a source path) so
|
||||
// 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 {
|
||||
const appDir = join(resolve(appRoot), "app");
|
||||
let child: ChildProcess | null = null;
|
||||
let shuttingDown = false;
|
||||
|
||||
const spawnChild = (): void => {
|
||||
child = spawn(
|
||||
process.execPath, // the Bun binary
|
||||
[SERVE_ENTRY, appDir, String(port), "development", hostname],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (shuttingDown || signal) return;
|
||||
if (code === RESTART_EXIT_CODE) {
|
||||
spawnChild(); // requested restart — respawn immediately
|
||||
return;
|
||||
}
|
||||
if (code && code !== 0) {
|
||||
// Crash (e.g. a syntax error). Respawn after a short delay so the
|
||||
// watcher comes back and the server auto-recovers once it's fixed.
|
||||
console.error(`[wrnexus] server exited (code ${code}); retrying in 1.2s…`);
|
||||
setTimeout(() => {
|
||||
if (!shuttingDown) spawnChild();
|
||||
}, 1200);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
console.log(`\n ⚡ WrNexus dev (HMR) — ${appDir}`);
|
||||
|
||||
// Regenerate typed DB queries + typed routes once before starting, then launch.
|
||||
// (Best effort; rerun `wrnexus db generate` after editing .sql.)
|
||||
void (async () => {
|
||||
try {
|
||||
const { loadAppConfig } = await import("@wrnexus/styles");
|
||||
const { regenerateAllQueries } = await import("./db.ts");
|
||||
const config = await loadAppConfig(resolve(appRoot));
|
||||
await regenerateAllQueries(appDir, config); // default + every named database
|
||||
console.log(" ↻ db queries generated");
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
try {
|
||||
const { regenerateRoutes } = await import("./routes.ts");
|
||||
const n = regenerateRoutes(appDir);
|
||||
console.log(` ↻ ${n} typed routes generated`);
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
spawnChild();
|
||||
})();
|
||||
|
||||
const shutdown = () => {
|
||||
shuttingDown = true;
|
||||
child?.kill();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* `wrnexus generate docker` — scaffold containerization for a WrNexus app:
|
||||
* a multi-stage Dockerfile (build with Bun → slim runtime), a .dockerignore,
|
||||
* and a docker-compose.yml (app + Postgres). Uses the app's `/healthz` endpoint
|
||||
* for the container health check.
|
||||
*/
|
||||
|
||||
import { existsSync, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
const DOCKERFILE = `# syntax=docker/dockerfile:1
|
||||
# --- build stage: install deps + produce dist/server.js ---
|
||||
FROM oven/bun:1 AS build
|
||||
WORKDIR /app
|
||||
COPY package.json bun.lock* bun.lockb* ./
|
||||
RUN bun install
|
||||
COPY . .
|
||||
RUN bun run build
|
||||
|
||||
# --- runtime stage: slim image with only the built server + migrations ---
|
||||
FROM oven/bun:1-slim AS runtime
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY --from=build /app/app/db/migrations ./app/db/migrations
|
||||
EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \\
|
||||
CMD bun -e "fetch('http://localhost:'+(process.env.PORT||3000)+'/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
CMD ["bun", "dist/server.js"]
|
||||
`;
|
||||
|
||||
const DOCKERIGNORE = `node_modules
|
||||
dist
|
||||
**/.wrnexus
|
||||
.git
|
||||
*.log
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
.DS_Store
|
||||
`;
|
||||
|
||||
const COMPOSE = `services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: "3000"
|
||||
DATABASE_URL: postgres://wire:wire@db:5432/app
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: wire
|
||||
POSTGRES_PASSWORD: wire
|
||||
POSTGRES_DB: app
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U wire -d app"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
`;
|
||||
|
||||
function writeIfAbsent(path: string, content: string, name: string): void {
|
||||
if (existsSync(path)) {
|
||||
console.warn(` • ${name} already exists — skipped`);
|
||||
return;
|
||||
}
|
||||
writeFileSync(path, content, "utf8");
|
||||
console.log(` ✓ ${name}`);
|
||||
}
|
||||
|
||||
/** Scaffold Dockerfile, .dockerignore, and docker-compose.yml into `appRoot`. */
|
||||
export function generateDocker(appRoot: string): void {
|
||||
const root = resolve(appRoot);
|
||||
console.log("Scaffolding containerization:");
|
||||
writeIfAbsent(join(root, "Dockerfile"), DOCKERFILE, "Dockerfile");
|
||||
writeIfAbsent(join(root, ".dockerignore"), DOCKERIGNORE, ".dockerignore");
|
||||
writeIfAbsent(join(root, "docker-compose.yml"), COMPOSE, "docker-compose.yml");
|
||||
console.log("\nBuild + run: docker compose up --build");
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
export interface DoctorCheck {
|
||||
name: string;
|
||||
ok: boolean;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export function inspectProject(appRoot: string): DoctorCheck[] {
|
||||
const root = resolve(appRoot);
|
||||
const checks: DoctorCheck[] = [];
|
||||
const pkgPath = join(root, "package.json");
|
||||
checks.push({
|
||||
name: "Bun runtime",
|
||||
ok: typeof Bun !== "undefined",
|
||||
detail: typeof Bun !== "undefined" ? `v${Bun.version}` : "Bun is required",
|
||||
});
|
||||
checks.push({
|
||||
name: "package.json",
|
||||
ok: existsSync(pkgPath),
|
||||
detail: existsSync(pkgPath) ? pkgPath : "Run this command from a WrNexus project root",
|
||||
});
|
||||
const app = join(root, "app");
|
||||
checks.push({
|
||||
name: "app/pages",
|
||||
ok: existsSync(join(app, "pages")),
|
||||
detail: existsSync(join(app, "pages")) ? "page directory found" : "Create app/pages",
|
||||
});
|
||||
const config = ["wrnexus.config.ts", "wrnexus.config.mjs", "wrnexus.config.js"].find((name) =>
|
||||
existsSync(join(root, name)),
|
||||
);
|
||||
checks.push({
|
||||
name: "configuration",
|
||||
ok: !!config,
|
||||
detail: config ?? "No wrnexus.config file; framework defaults will be used",
|
||||
});
|
||||
const mobilePkg = join(root, "mobile", "package.json");
|
||||
if (existsSync(mobilePkg)) {
|
||||
try {
|
||||
const mobile = JSON.parse(readFileSync(mobilePkg, "utf8")) as {
|
||||
wrnexus?: { mode?: string };
|
||||
};
|
||||
checks.push({
|
||||
name: "mobile project",
|
||||
ok: mobile.wrnexus?.mode === "webview" || mobile.wrnexus?.mode === "native",
|
||||
detail: `mode: ${mobile.wrnexus?.mode ?? "missing"}`,
|
||||
});
|
||||
} catch {
|
||||
checks.push({
|
||||
name: "mobile project",
|
||||
ok: false,
|
||||
detail: "mobile/package.json is invalid JSON",
|
||||
});
|
||||
}
|
||||
}
|
||||
return checks;
|
||||
}
|
||||
|
||||
export function runDoctor(appRoot: string): boolean {
|
||||
const checks = inspectProject(appRoot);
|
||||
console.log("WrNexus doctor\n");
|
||||
for (const check of checks)
|
||||
console.log(` ${check.ok ? "✓" : "✗"} ${check.name}: ${check.detail}`);
|
||||
console.log("\n Security dependencies: run `bun audit`");
|
||||
console.log(" Complete verification: run `bun run check`");
|
||||
return checks.every((check) => check.ok || check.name === "configuration");
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* `wrnexus eject <name...>` — copy a Wire UI component's `.wrn` source into the
|
||||
* app's `app/components/`, so you fully own and can edit it. The auto-discovered
|
||||
* library version is shadowed by the app copy (same name → app wins).
|
||||
*/
|
||||
|
||||
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { uiComponentsDir, uiComponentNames } from "@wrnexus/ui";
|
||||
|
||||
export function runEject(appRoot: string, names: string[]): void {
|
||||
const root = resolve(appRoot);
|
||||
const dest = join(root, "app", "components");
|
||||
const available = uiComponentNames();
|
||||
|
||||
if (names.length === 0) {
|
||||
console.log("Usage: wrnexus eject <name...>\n\nAvailable components:");
|
||||
console.log(" " + available.join(", "));
|
||||
return;
|
||||
}
|
||||
|
||||
mkdirSync(dest, { recursive: true });
|
||||
for (const name of names) {
|
||||
if (!available.includes(name)) {
|
||||
console.error(`✗ Unknown component "${name}". Available: ${available.join(", ")}`);
|
||||
continue;
|
||||
}
|
||||
const src = join(uiComponentsDir(), `${name}.wrn`);
|
||||
const out = join(dest, `${name}.wrn`);
|
||||
if (existsSync(out)) {
|
||||
console.error(`✗ ${name}: app/components/${name}.wrn already exists — skipped`);
|
||||
continue;
|
||||
}
|
||||
copyFileSync(src, out);
|
||||
console.log(`✓ Ejected ${name} -> app/components/${name}.wrn`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* `wrnexus generate <type> <name>` — scaffold a page, component, API route, or
|
||||
* schema from a template. Keeps new files consistent and gets users moving fast.
|
||||
*
|
||||
* wrnexus generate page about
|
||||
* wrnexus generate component user-card
|
||||
* wrnexus generate api users/list
|
||||
* wrnexus generate schema signup
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
export type GenerateType = "page" | "component" | "api" | "schema";
|
||||
|
||||
const ALIASES: Record<string, GenerateType> = {
|
||||
page: "page",
|
||||
p: "page",
|
||||
component: "component",
|
||||
c: "component",
|
||||
api: "api",
|
||||
a: "api",
|
||||
schema: "schema",
|
||||
s: "schema",
|
||||
};
|
||||
|
||||
export interface GeneratedFile {
|
||||
/** Path relative to the `app/` directory. */
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
function toPascalCase(name: string): string {
|
||||
return name
|
||||
.split(/[^A-Za-z0-9]+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function baseName(name: string): string {
|
||||
const parts = name.split("/");
|
||||
return parts[parts.length - 1] ?? name;
|
||||
}
|
||||
|
||||
/** Produce the file (relative path + content) for a generate request. */
|
||||
export function scaffold(type: GenerateType, name: string): GeneratedFile {
|
||||
const clean = name.replace(/\.(wrn|ts)$/, "").replace(/^\/+|\/+$/g, "");
|
||||
const pascal = toPascalCase(baseName(clean));
|
||||
|
||||
switch (type) {
|
||||
case "page":
|
||||
return {
|
||||
path: `pages/${clean}.wrn`,
|
||||
content: `page ${pascal} {
|
||||
layout = "public"
|
||||
|
||||
seo {
|
||||
title = "${pascal}"
|
||||
}
|
||||
|
||||
view {
|
||||
<h1>${pascal}</h1>
|
||||
<p>Edit app/pages/${clean}.wrn to build this page.</p>
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
case "component":
|
||||
return {
|
||||
path: `components/${clean}.wrn`,
|
||||
content: `component ${pascal} {
|
||||
props {
|
||||
label = "${pascal}"
|
||||
}
|
||||
|
||||
view {
|
||||
<div class="wire-${baseName(clean)}">{label}</div>
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
case "api":
|
||||
return {
|
||||
path: `api/${clean}.ts`,
|
||||
content: `import type { Context } from "@wrnexus/core";
|
||||
|
||||
export async function GET(ctx: Context): Promise<Response> {
|
||||
return Response.json({ ok: true, route: ctx.url.pathname });
|
||||
}
|
||||
`,
|
||||
};
|
||||
case "schema":
|
||||
return {
|
||||
path: `schemas/${clean}.ts`,
|
||||
content: `import { v } from "@wrnexus/validation";
|
||||
|
||||
export default v.object({
|
||||
name: v.string().min(1, "Required"),
|
||||
});
|
||||
`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Write a scaffolded file under `<appRoot>/app`, refusing to overwrite. */
|
||||
export function runGenerate(
|
||||
appRoot: string,
|
||||
typeArg: string | undefined,
|
||||
name: string | undefined,
|
||||
): void {
|
||||
const type = typeArg ? ALIASES[typeArg] : undefined;
|
||||
if (!type || !name) {
|
||||
console.error("Usage: wrnexus generate <page|component|api|schema> <name>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const file = scaffold(type, name);
|
||||
const target = join(resolve(appRoot), "app", file.path);
|
||||
if (existsSync(target)) {
|
||||
console.error(`Refusing to overwrite existing file: app/${file.path}`);
|
||||
process.exit(1);
|
||||
}
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(target, file.content, "utf8");
|
||||
console.log(`✓ Created app/${file.path}`);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* @wrnexus/cli — the `wrnexus` command line.
|
||||
*
|
||||
* wrnexus dev [app-dir] [--port=3000] start the dev server (live reload)
|
||||
* wrnexus build [app-dir] build a production server + assets
|
||||
* wrnexus create <app-name> scaffold a new app
|
||||
* wrnexus eject <name...> copy a Wire UI component into your app
|
||||
* wrnexus db <migrate|rollback|status|new> database migrations
|
||||
*/
|
||||
|
||||
import { join, resolve } from "node:path";
|
||||
import { resolveProfile, loadEnv } from "@wrnexus/styles";
|
||||
import { runDev } from "./dev.ts";
|
||||
import { createApp } from "./create.ts";
|
||||
|
||||
/**
|
||||
* Resolve the active profile from `--profile=<name>` (or WRNEXUS_PROFILE / mode),
|
||||
* publish it as WRNEXUS_PROFILE (so config loaders + the dev child pick it up),
|
||||
* and load its `.env` cascade into process.env. Returns the profile name.
|
||||
*/
|
||||
function bootstrapProfile(
|
||||
appRoot: string,
|
||||
mode: "development" | "production",
|
||||
args: string[],
|
||||
): string {
|
||||
const flag = args.find((a) => a.startsWith("--profile="));
|
||||
const profile = resolveProfile({ explicit: flag?.split("=")[1], mode });
|
||||
process.env.WRNEXUS_PROFILE = profile;
|
||||
const loaded = loadEnv(resolve(appRoot), profile);
|
||||
const count = Object.keys(loaded).length;
|
||||
console.log(` ▸ profile: ${profile}${count ? ` (${count} env vars loaded)` : ""}`);
|
||||
return profile;
|
||||
}
|
||||
|
||||
function help(): void {
|
||||
console.log(`wrnexus — WrNexus CLI
|
||||
|
||||
Usage:
|
||||
wrnexus dev [app-dir] [--port=3000] [--host=::]
|
||||
Start the development server (live reload)
|
||||
wrnexus build [app-dir] Build a production server bundle + assets
|
||||
wrnexus create <app-name> Scaffold a new app
|
||||
wrnexus workspace <name> Scaffold a monorepo (apps/* + shared packages/*)
|
||||
wrnexus gateway [--port=3000] Serve every workspace app behind one port, routed by domain
|
||||
wrnexus generate <type> <name> Scaffold a page | component | api | schema
|
||||
wrnexus generate routes | docker | mobile
|
||||
Generate routes or scaffold deployment targets
|
||||
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
|
||||
wrnexus native add <capability...> Install capability packages for the configured mobile mode
|
||||
wrnexus eject <name...> Copy a Wire UI component into app/components
|
||||
wrnexus update [dir] [--latest] Upgrade @wrnexus/* deps + apply config/file migrations
|
||||
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 profiles [app-dir] List config profiles (dev/prod/uat/…) and their env files
|
||||
wrnexus doctor [app-dir] Check project structure, runtime, mobile config, and next fixes
|
||||
|
||||
Profiles: pass --profile=<name> to dev/build/db (or set WRNEXUS_PROFILE) to load
|
||||
that profile's .env cascade and config overrides. e.g. wrnexus dev --profile=uat
|
||||
`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const [command, ...rest] = process.argv.slice(2);
|
||||
|
||||
switch (command) {
|
||||
case "dev": {
|
||||
const appRoot = rest.find((a) => !a.startsWith("--")) ?? ".";
|
||||
const portArg = rest.find((a) => a.startsWith("--port="));
|
||||
const hostArg = rest.find((a) => a.startsWith("--host="));
|
||||
const port = portArg ? Number(portArg.split("=")[1]) : 3000;
|
||||
const host = hostArg?.split("=")[1] || "::";
|
||||
bootstrapProfile(appRoot, "development", rest);
|
||||
runDev(appRoot, port, host);
|
||||
break;
|
||||
}
|
||||
case "build": {
|
||||
const appRoot = rest.find((a) => !a.startsWith("--")) ?? ".";
|
||||
bootstrapProfile(appRoot, "production", rest);
|
||||
const { runBuild } = await import("./build.ts");
|
||||
await runBuild(appRoot);
|
||||
break;
|
||||
}
|
||||
case "create":
|
||||
createApp(rest[0] ?? "");
|
||||
break;
|
||||
case "workspace": {
|
||||
const { createWorkspace } = await import("./workspace.ts");
|
||||
createWorkspace(rest.find((a) => !a.startsWith("--")) ?? "");
|
||||
break;
|
||||
}
|
||||
case "gateway": {
|
||||
const appRoot = rest.find((a) => !a.startsWith("--")) ?? ".";
|
||||
const { runGateway } = await import("./workspace.ts");
|
||||
await runGateway(appRoot, rest);
|
||||
break;
|
||||
}
|
||||
case "generate":
|
||||
case "g": {
|
||||
if (rest[0] === "routes") {
|
||||
const { regenerateRoutes } = await import("./routes.ts");
|
||||
const n = regenerateRoutes(join(process.cwd(), "app"));
|
||||
console.log(`✓ Generated app/routes.gen.ts (${n} routes)`);
|
||||
break;
|
||||
}
|
||||
if (rest[0] === "docker") {
|
||||
const { generateDocker } = await import("./docker.ts");
|
||||
generateDocker(process.cwd());
|
||||
break;
|
||||
}
|
||||
if (rest[0] === "mobile") {
|
||||
const { generateMobile, mobileOptions } = await import("./mobile.ts");
|
||||
await generateMobile(process.cwd(), mobileOptions(rest.slice(1)));
|
||||
break;
|
||||
}
|
||||
const { runGenerate } = await import("./generate.ts");
|
||||
runGenerate(".", rest[0], rest[1]);
|
||||
break;
|
||||
}
|
||||
case "eject": {
|
||||
const { runEject } = await import("./eject.ts");
|
||||
const args = rest.filter((a) => !a.startsWith("--"));
|
||||
// First arg may be an app dir; treat known component names as names.
|
||||
runEject(".", args);
|
||||
break;
|
||||
}
|
||||
case "mobile": {
|
||||
const { runMobileCommand } = await import("./mobile-command.ts");
|
||||
await runMobileCommand(".", rest[0], rest.slice(1));
|
||||
break;
|
||||
}
|
||||
case "native": {
|
||||
const { runNativeCommand } = await import("./native-command.ts");
|
||||
await runNativeCommand(".", rest[0], rest.slice(1));
|
||||
break;
|
||||
}
|
||||
case "update":
|
||||
case "upgrade": {
|
||||
const dir = rest.find((a) => !a.startsWith("--")) ?? ".";
|
||||
const { runUpdate } = await import("./update.ts");
|
||||
await runUpdate(dir, rest);
|
||||
break;
|
||||
}
|
||||
case "db": {
|
||||
bootstrapProfile(".", "development", rest);
|
||||
const { runDbCommand } = await import("./db.ts");
|
||||
const [sub, ...dbArgs] = rest.filter((a) => !a.startsWith("--profile="));
|
||||
await runDbCommand(".", sub, dbArgs);
|
||||
break;
|
||||
}
|
||||
case "profiles": {
|
||||
const { listProfiles } = await import("./profiles.ts");
|
||||
await listProfiles(rest.find((a) => !a.startsWith("--")) ?? ".");
|
||||
break;
|
||||
}
|
||||
case "doctor": {
|
||||
const { runDoctor } = await import("./doctor.ts");
|
||||
const healthy = runDoctor(rest.find((a) => !a.startsWith("--")) ?? ".");
|
||||
if (!healthy) process.exitCode = 1;
|
||||
break;
|
||||
}
|
||||
case "test": {
|
||||
const appRoot = rest.find((a) => !a.startsWith("--")) ?? ".";
|
||||
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;
|
||||
}
|
||||
case undefined:
|
||||
case "help":
|
||||
case "--help":
|
||||
case "-h":
|
||||
help();
|
||||
break;
|
||||
default:
|
||||
console.error(`Unknown command: ${command}\n`);
|
||||
help();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
/** Commands for maintaining the generated Capacitor package. */
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { loadAppConfig } from "@wrnexus/styles";
|
||||
import { compileNativeWireFile } from "@wrnexus/compiler";
|
||||
|
||||
function validPackageName(value: string): boolean {
|
||||
return /^(?:@[a-z0-9._~-]+\/)?[a-z0-9._~-]+(?:@[a-zA-Z0-9._~^<>=|*+-]+)?$/.test(value);
|
||||
}
|
||||
|
||||
function run(command: string, args: string[], cwd: string): void {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd,
|
||||
stdio: "inherit",
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${command} ${args.join(" ")} failed with exit code ${result.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (char) => {
|
||||
const entities: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
return entities[char]!;
|
||||
});
|
||||
}
|
||||
|
||||
async function updateMobileErrorPage(root: string, mobileDir: string): Promise<void> {
|
||||
const config = await loadAppConfig(root);
|
||||
const mobile = config.mobile ?? {};
|
||||
const title = mobile.errorTitle ?? "Connection unavailable";
|
||||
const message =
|
||||
mobile.errorMessage ?? "Check your Wi-Fi and make sure the WrNexus server is running.";
|
||||
const serverUrl = mobile.serverUrl ?? "http://localhost:3000";
|
||||
const background = mobile.backgroundColor ?? "#0f172a";
|
||||
const retryUrl = JSON.stringify(serverUrl).replaceAll("<", "\\u003c");
|
||||
const page = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(title)}</title>
|
||||
<style>html{color-scheme:dark}body{margin:0;min-height:100vh;display:grid;place-items:center;font:16px system-ui;background:${background};color:#e2e8f0}.card{max-width:28rem;padding:2rem;text-align:center}button{padding:.8rem 1.2rem;border:0;border-radius:.75rem;background:#6366f1;color:white;font-weight:700}</style></head>
|
||||
<body><main class="card"><h1>${escapeHtml(title)}</h1><p>${escapeHtml(message)}</p><button id="retry">Try again</button></main><script>document.getElementById("retry").onclick=()=>location.replace(${retryUrl});</script></body>
|
||||
</html>
|
||||
`;
|
||||
const path = join(mobileDir, "web", "error.html");
|
||||
mkdirSync(resolve(path, ".."), { recursive: true });
|
||||
writeFileSync(path, page, "utf8");
|
||||
console.log(" ✓ mobile/web/error.html updated from wrnexus.config.ts");
|
||||
}
|
||||
|
||||
function configureAndroidNetworkErrors(mobileDir: string): void {
|
||||
const javaRoot = join(mobileDir, "android", "app", "src", "main", "java");
|
||||
if (!existsSync(javaRoot)) return;
|
||||
const file = (readdirSync(javaRoot, { recursive: true }) as string[])
|
||||
.map((entry) => join(javaRoot, entry))
|
||||
.find((entry) => entry.endsWith("MainActivity.java"));
|
||||
if (!file) return;
|
||||
const current = readFileSync(file, "utf8");
|
||||
if (current.includes("WRNEXUS_NETWORK_ERROR_ONLY")) return;
|
||||
const packageName = /^package\s+([\w.]+);/m.exec(current)?.[1];
|
||||
if (
|
||||
!packageName ||
|
||||
!/public\s+class\s+MainActivity\s+extends\s+BridgeActivity\s*\{\s*\}/s.test(current)
|
||||
) {
|
||||
console.warn(" • MainActivity.java is customized — network-error handling was not changed");
|
||||
return;
|
||||
}
|
||||
writeFileSync(
|
||||
file,
|
||||
`package ${packageName};
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.webkit.WebResourceError;
|
||||
import android.webkit.WebResourceRequest;
|
||||
import android.webkit.WebResourceResponse;
|
||||
import android.webkit.WebView;
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
import com.getcapacitor.BridgeWebViewClient;
|
||||
|
||||
// WRNEXUS_NETWORK_ERROR_ONLY
|
||||
public class MainActivity extends BridgeActivity {
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
bridge.setWebViewClient(new BridgeWebViewClient(bridge) {
|
||||
@Override
|
||||
public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) {
|
||||
if (request.isForMainFrame()) view.loadUrl("file:///android_asset/public/error.html");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse response) {
|
||||
// Preserve WrNexus HTTP error pages (404, 500, etc.).
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
console.log(" ✓ Android network-only error page configured");
|
||||
}
|
||||
|
||||
export async function runMobileCommand(
|
||||
appRoot: string,
|
||||
subcommand?: string,
|
||||
args: string[] = [],
|
||||
): Promise<void> {
|
||||
const root = resolve(appRoot);
|
||||
const mobileDir = join(root, "mobile");
|
||||
if (!existsSync(join(mobileDir, "package.json"))) {
|
||||
throw new Error("No mobile project found. Run `wrnexus generate mobile` first.");
|
||||
}
|
||||
const mobilePackage = JSON.parse(readFileSync(join(mobileDir, "package.json"), "utf8")) as {
|
||||
wrnexus?: { mode?: "webview" | "native" };
|
||||
};
|
||||
const mode = mobilePackage.wrnexus?.mode ?? "webview";
|
||||
|
||||
if (subcommand === "compile") {
|
||||
if (mode !== "native") throw new Error("`wrnexus mobile compile` requires native mode.");
|
||||
const pagesDir = join(root, "app", "pages");
|
||||
if (!existsSync(pagesDir)) throw new Error(`Pages directory not found: ${pagesDir}`);
|
||||
let count = 0;
|
||||
for (const entry of readdirSync(pagesDir, { recursive: true }) as string[]) {
|
||||
if (!entry.endsWith(".wrn")) continue;
|
||||
const source = join(pagesDir, entry);
|
||||
const relative = entry.replace(/\.wrn$/, ".tsx");
|
||||
const output = join(mobileDir, "app", relative);
|
||||
mkdirSync(resolve(output, ".."), { recursive: true });
|
||||
try {
|
||||
writeFileSync(output, compileNativeWireFile(readFileSync(source, "utf8")), "utf8");
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Native compilation failed for app/pages/${entry}: ${(error as Error).message}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
count++;
|
||||
}
|
||||
console.log(` ✓ compiled ${count} .wrn page${count === 1 ? "" : "s"} to mobile/app`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "add") {
|
||||
const packages = args.filter((arg) => !arg.startsWith("--"));
|
||||
if (!packages.length || packages.some((name) => !validPackageName(name))) {
|
||||
throw new Error("Usage: wrnexus mobile add <native-package...>");
|
||||
}
|
||||
if (mode === "native") {
|
||||
run("bunx", ["expo", "install", ...packages], mobileDir);
|
||||
return;
|
||||
}
|
||||
// The root app needs the JavaScript proxy for its future browser bundle;
|
||||
// the mobile package needs the dependency so Capacitor can sync native code.
|
||||
run("bun", ["add", ...packages], root);
|
||||
run("bun", ["add", ...packages], mobileDir);
|
||||
await updateMobileErrorPage(root, mobileDir);
|
||||
run("bun", ["run", "sync"], mobileDir);
|
||||
configureAndroidNetworkErrors(mobileDir);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "sync") {
|
||||
if (mode === "native") {
|
||||
run("bunx", ["expo", "prebuild"], mobileDir);
|
||||
return;
|
||||
}
|
||||
await updateMobileErrorPage(root, mobileDir);
|
||||
run("bun", ["run", "sync"], mobileDir);
|
||||
configureAndroidNetworkErrors(mobileDir);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "assets") {
|
||||
const config = await loadAppConfig(root);
|
||||
if (!config.mobile?.icon) {
|
||||
throw new Error("Set `mobile.icon` in wrnexus.config.ts before generating native assets.");
|
||||
}
|
||||
const source = resolve(root, config.mobile.icon);
|
||||
if (!existsSync(source)) throw new Error(`Mobile icon not found: ${source}`);
|
||||
const resources = join(mobileDir, "resources");
|
||||
mkdirSync(resources, { recursive: true });
|
||||
copyFileSync(source, join(resources, "icon.png"));
|
||||
if (mode === "native") {
|
||||
console.log(" ✓ mobile/resources/icon.png copied (reference it with `mobile.expo.icon`)");
|
||||
return;
|
||||
}
|
||||
run("bunx", ["capacitor-assets", "generate"], mobileDir);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("Usage: wrnexus mobile <compile|add <package...>|sync|assets>");
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* `wrnexus generate mobile` — scaffold a WebView or fully native mobile app.
|
||||
*
|
||||
* WrNexus remains the hosted SSR/API/WebSocket server. Capacitor loads that
|
||||
* server in a native WebView and provides the bridge for native plugins.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import { loadAppConfig } from "@wrnexus/styles";
|
||||
|
||||
export type MobileMode = "webview" | "native";
|
||||
|
||||
export interface MobileOptions {
|
||||
appId?: string;
|
||||
appName?: string;
|
||||
serverUrl?: string;
|
||||
mode?: MobileMode;
|
||||
}
|
||||
|
||||
function slug(value: string): string {
|
||||
const cleaned = value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "")
|
||||
.replace(/^\d+/, "");
|
||||
return cleaned || "app";
|
||||
}
|
||||
|
||||
function projectName(root: string): string {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { name?: string };
|
||||
if (pkg.name) return pkg.name.split("/").pop() || basename(root);
|
||||
} catch {
|
||||
// A package manifest is helpful but not required to generate the shell.
|
||||
}
|
||||
return basename(root);
|
||||
}
|
||||
|
||||
function writeIfAbsent(path: string, content: string, label: string): void {
|
||||
if (existsSync(path)) {
|
||||
console.warn(` • ${label} already exists — skipped`);
|
||||
return;
|
||||
}
|
||||
mkdirSync(resolve(path, ".."), { recursive: true });
|
||||
writeFileSync(path, content, "utf8");
|
||||
console.log(` ✓ ${label}`);
|
||||
}
|
||||
|
||||
/** Scaffold a Capacitor mobile wrapper under `<appRoot>/mobile`. */
|
||||
export async function generateMobile(appRoot: string, options: MobileOptions = {}): Promise<void> {
|
||||
const root = resolve(appRoot);
|
||||
const appConfig = await loadAppConfig(root);
|
||||
const configured = appConfig.mobile ?? {};
|
||||
const mode = options.mode ?? configured.mode ?? "webview";
|
||||
if (mode !== "webview" && mode !== "native") {
|
||||
throw new Error('mobile.mode must be either "webview" or "native"');
|
||||
}
|
||||
const name = options.appName || configured.appName || projectName(root);
|
||||
const appId = options.appId || configured.appId || `com.example.${slug(name)}`;
|
||||
const serverUrl = options.serverUrl || configured.serverUrl || "http://localhost:3000";
|
||||
const mobile = join(root, "mobile");
|
||||
|
||||
if (mode === "native") {
|
||||
generateNativeMobile(mobile, {
|
||||
name,
|
||||
appId,
|
||||
apiUrl: configured.apiUrl ?? serverUrl,
|
||||
configured,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const pkg = {
|
||||
name: `${slug(name)}-mobile`,
|
||||
version: "0.1.0",
|
||||
private: true,
|
||||
wrnexus: { mode: "webview" },
|
||||
type: "module",
|
||||
scripts: {
|
||||
"add:ios": "cap add ios",
|
||||
"add:android": "cap add android",
|
||||
sync: "cap sync",
|
||||
"open:ios": "cap open ios",
|
||||
"open:android": "cap open android",
|
||||
},
|
||||
dependencies: {
|
||||
"@capacitor/core": "^8.0.0",
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/ios": "^8.0.0",
|
||||
"@capacitor/android": "^8.0.0",
|
||||
},
|
||||
devDependencies: {
|
||||
"@capacitor/cli": "^8.0.0",
|
||||
"@capacitor/assets": "^3.0.0",
|
||||
typescript: "^5.5.0",
|
||||
},
|
||||
};
|
||||
|
||||
const config = `import type { CapacitorConfig } from "@capacitor/cli";
|
||||
import appConfig from "../wrnexus.config.ts";
|
||||
|
||||
const mobile = appConfig.mobile ?? {};
|
||||
const serverUrl = process.env.WRNEXUS_MOBILE_URL ?? mobile.serverUrl ?? ${JSON.stringify(serverUrl)};
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: mobile.appId ?? ${JSON.stringify(appId)},
|
||||
appName: mobile.appName ?? ${JSON.stringify(name)},
|
||||
webDir: "web",
|
||||
appendUserAgent: mobile.userAgent ?? " WrNexusMobile",
|
||||
backgroundColor: mobile.backgroundColor,
|
||||
...(mobile.capacitor ?? {}),
|
||||
server: {
|
||||
...((mobile.capacitor?.server as CapacitorConfig["server"]) ?? {}),
|
||||
// Development bridge only: Capacitor does not recommend server.url in production.
|
||||
url: serverUrl,
|
||||
cleartext: serverUrl.startsWith("http://"),
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
`;
|
||||
|
||||
const errorPage = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Connection unavailable</title>
|
||||
<style>html{color-scheme:dark}body{margin:0;min-height:100vh;display:grid;place-items:center;font:16px system-ui;background:#0f172a;color:#e2e8f0}.card{max-width:28rem;padding:2rem;text-align:center}button{padding:.8rem 1.2rem;border:0;border-radius:.75rem;background:#6c8cff;color:white;font-weight:700}</style></head>
|
||||
<body><main class="card"><h1>Connection unavailable</h1><p>Check your Wi-Fi and make sure the WrNexus server is running.</p><button id="retry">Try again</button></main><script>document.getElementById("retry").onclick=()=>location.replace(${JSON.stringify(serverUrl)});</script></body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const fallback = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${name}</title></head>
|
||||
<body><p>Run the WrNexus server and then sync this mobile project.</p></body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const readme = `# ${name} mobile
|
||||
|
||||
Capacitor development shell for the hosted WrNexus application. The Bun server,
|
||||
SSR, APIs, database, and WebSockets continue to run on your server; this directory
|
||||
contains the native iOS and Android projects and native plugin dependencies.
|
||||
|
||||
> Capacitor documents \`server.url\` as a live-reload option that is not intended
|
||||
> for production. This shell is useful for native development and testing, but a
|
||||
> store release needs a bundled client build/static export that WrNexus does not
|
||||
> currently produce.
|
||||
|
||||
## Setup
|
||||
|
||||
\`\`\`bash
|
||||
node --version # Capacitor 8 requires Node.js 22+
|
||||
bun install
|
||||
bun run add:android
|
||||
bun run add:ios # macOS with Xcode is required
|
||||
bun run sync
|
||||
\`\`\`
|
||||
|
||||
Set \`WRNEXUS_MOBILE_URL\` to a reachable URL before syncing the development shell:
|
||||
|
||||
\`\`\`bash
|
||||
WRNEXUS_MOBILE_URL=https://app.example.com bun run sync
|
||||
\`\`\`
|
||||
|
||||
For a physical device, \`localhost\` refers to the device, not your computer.
|
||||
Use your computer's LAN URL during development. Do not ship the generated
|
||||
\`server.url\` configuration as a production store build.
|
||||
|
||||
Open the native projects with \`bun run open:android\` or \`bun run open:ios\`.
|
||||
Add native features with Capacitor plugins and run \`bun run sync\` afterward.
|
||||
`;
|
||||
|
||||
console.log("Scaffolding Capacitor mobile app:");
|
||||
writeIfAbsent(
|
||||
join(mobile, "package.json"),
|
||||
`${JSON.stringify(pkg, null, 2)}\n`,
|
||||
"mobile/package.json",
|
||||
);
|
||||
writeIfAbsent(join(mobile, "capacitor.config.ts"), config, "mobile/capacitor.config.ts");
|
||||
writeIfAbsent(join(mobile, "web", "index.html"), fallback, "mobile/web/index.html");
|
||||
writeIfAbsent(join(mobile, "web", "error.html"), errorPage, "mobile/web/error.html");
|
||||
writeIfAbsent(
|
||||
join(mobile, ".gitignore"),
|
||||
"node_modules\nandroid/.gradle\nios/App/Pods\n",
|
||||
"mobile/.gitignore",
|
||||
);
|
||||
writeIfAbsent(join(mobile, "README.md"), readme, "mobile/README.md");
|
||||
console.log("\nNext: cd mobile && bun install && bun run add:android");
|
||||
console.log("iOS generation requires macOS with Xcode: bun run add:ios");
|
||||
}
|
||||
|
||||
/** Convert CLI flags into generator options. */
|
||||
export function mobileOptions(args: string[]): MobileOptions {
|
||||
const value = (flag: string) =>
|
||||
args.find((arg) => arg.startsWith(`${flag}=`))?.slice(flag.length + 1);
|
||||
const modeValue = value("--mode");
|
||||
if (modeValue && modeValue !== "webview" && modeValue !== "native") {
|
||||
throw new Error('--mode must be either "webview" or "native"');
|
||||
}
|
||||
const mode: MobileMode | undefined =
|
||||
modeValue === "webview" || modeValue === "native" ? modeValue : undefined;
|
||||
return {
|
||||
appId: value("--app-id"),
|
||||
appName: value("--app-name"),
|
||||
serverUrl: value("--url"),
|
||||
mode,
|
||||
};
|
||||
}
|
||||
|
||||
function generateNativeMobile(
|
||||
mobile: string,
|
||||
input: { name: string; appId: string; apiUrl: string; configured: { scheme?: string } },
|
||||
): void {
|
||||
const { name, appId, apiUrl, configured } = input;
|
||||
const scheme = configured.scheme ?? slug(name);
|
||||
const pkg = {
|
||||
name: `${slug(name)}-mobile`,
|
||||
version: "0.1.0",
|
||||
private: true,
|
||||
main: "expo-router/entry",
|
||||
wrnexus: { mode: "native" },
|
||||
scripts: {
|
||||
compile: "cd .. && wrnexus mobile compile",
|
||||
prestart: "bun run compile",
|
||||
start: "expo start",
|
||||
android: "expo run:android",
|
||||
ios: "expo run:ios",
|
||||
web: "expo start --web",
|
||||
prebuild: "expo prebuild",
|
||||
},
|
||||
dependencies: {
|
||||
expo: "^57.0.0",
|
||||
"expo-router": "~57.0.4",
|
||||
"expo-status-bar": "~57.0.0",
|
||||
react: "19.2.3",
|
||||
"react-native": "0.86.0",
|
||||
"react-native-safe-area-context": "^5.6.0",
|
||||
"react-native-screens": "^4.23.0",
|
||||
},
|
||||
devDependencies: { "@types/react": "^19.2.0", typescript: "^5.9.0" },
|
||||
};
|
||||
const expo = `import type { ExpoConfig } from "expo/config";\nimport appConfig from "../wrnexus.config.ts";\n\nconst mobile = appConfig.mobile ?? {};\nconst config: ExpoConfig = {\n name: mobile.appName ?? ${JSON.stringify(name)},\n slug: ${JSON.stringify(slug(name))},\n scheme: mobile.scheme ?? ${JSON.stringify(scheme)},\n ios: { bundleIdentifier: mobile.appId ?? ${JSON.stringify(appId)} },\n android: { package: mobile.appId ?? ${JSON.stringify(appId)} },\n plugins: ["expo-router"],\n ...(mobile.expo ?? {}),\n};\nexport default config;\n`;
|
||||
const env = `/** Shared connection settings for native screens. */\nexport const API_URL = process.env.EXPO_PUBLIC_WRNEXUS_URL ?? ${JSON.stringify(apiUrl)};\nexport async function api<T>(path: string, init?: RequestInit): Promise<T> {\n const response = await fetch(new URL(path, API_URL), init);\n if (!response.ok) throw new Error(\`WrNexus request failed: \${response.status}\`);\n return response.json() as Promise<T>;\n}\nexport function realtimeUrl(path: string): string {\n const url = new URL(path, API_URL);\n url.protocol = url.protocol === "https:" ? "wss:" : "ws:";\n return url.toString();\n}\n`;
|
||||
const screen = `import { StyleSheet, Text, View } from "react-native";\nimport { StatusBar } from "expo-status-bar";\n\nexport default function Home() {\n return <View style={styles.container}><StatusBar style="auto" /><Text style={styles.title}>${name}</Text><Text>Fully native WrNexus client</Text></View>;\n}\nconst styles = StyleSheet.create({ container: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 }, title: { fontSize: 28, fontWeight: "700", marginBottom: 8 } });\n`;
|
||||
const readme = `# ${name} native mobile\n\nThis is a fully native Expo/React Native client: it does not use a WebView. Portable pages from \`../app/pages/**/*.wrn\` compile into Expo routes with \`bun run compile\` (also run automatically before \`start\`). You can edit or add native-only TSX screens in \`app/\`, and call the shared WrNexus backend through \`src/wrnexus.ts\`.\n\n\`\`\`bash\nbun install\nbun run compile\nbun run start\nbun run android\n# macOS/Xcode: bun run ios\n\`\`\`\n\nServer API routes, authentication endpoints, uploads, and WebSockets remain reusable. Unsupported DOM-only markup fails compilation with a specific error. Override the backend per environment with \`EXPO_PUBLIC_WRNEXUS_URL\`. Add native modules with \`bunx expo install <package>\`.\n`;
|
||||
console.log("Scaffolding fully native Expo mobile app:");
|
||||
writeIfAbsent(
|
||||
join(mobile, "package.json"),
|
||||
`${JSON.stringify(pkg, null, 2)}\n`,
|
||||
"mobile/package.json",
|
||||
);
|
||||
writeIfAbsent(join(mobile, "app.config.ts"), expo, "mobile/app.config.ts");
|
||||
writeIfAbsent(join(mobile, "app", "index.tsx"), screen, "mobile/app/index.tsx");
|
||||
writeIfAbsent(join(mobile, "src", "wrnexus.ts"), env, "mobile/src/wrnexus.ts");
|
||||
writeIfAbsent(
|
||||
join(mobile, "tsconfig.json"),
|
||||
`${JSON.stringify({ extends: "expo/tsconfig.base", compilerOptions: { strict: true } }, null, 2)}\n`,
|
||||
"mobile/tsconfig.json",
|
||||
);
|
||||
writeIfAbsent(
|
||||
join(mobile, ".gitignore"),
|
||||
"node_modules\n.expo\nandroid\nios\n",
|
||||
"mobile/.gitignore",
|
||||
);
|
||||
writeIfAbsent(join(mobile, "README.md"), readme, "mobile/README.md");
|
||||
console.log("\nNext: cd mobile && bun install && bun run start");
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { runMobileCommand } from "./mobile-command.ts";
|
||||
|
||||
interface CapabilityPackage {
|
||||
browser: string;
|
||||
capacitor?: string;
|
||||
expo?: string;
|
||||
}
|
||||
|
||||
export const nativeCatalog: Record<string, CapabilityPackage> = {
|
||||
camera: {
|
||||
browser: "MediaDevices / file input",
|
||||
capacitor: "@capacitor/camera",
|
||||
expo: "expo-camera",
|
||||
},
|
||||
clipboard: {
|
||||
browser: "Clipboard API",
|
||||
capacitor: "@capacitor/clipboard",
|
||||
expo: "expo-clipboard",
|
||||
},
|
||||
device: {
|
||||
browser: "Browser device information",
|
||||
capacitor: "@capacitor/device",
|
||||
expo: "expo-device",
|
||||
},
|
||||
filesystem: {
|
||||
browser: "File System Access API",
|
||||
capacitor: "@capacitor/filesystem",
|
||||
expo: "expo-file-system",
|
||||
},
|
||||
geolocation: {
|
||||
browser: "Geolocation API",
|
||||
capacitor: "@capacitor/geolocation",
|
||||
expo: "expo-location",
|
||||
},
|
||||
haptics: { browser: "Vibration API", capacitor: "@capacitor/haptics", expo: "expo-haptics" },
|
||||
network: {
|
||||
browser: "Navigator online status",
|
||||
capacitor: "@capacitor/network",
|
||||
expo: "expo-network",
|
||||
},
|
||||
notifications: {
|
||||
browser: "Notifications API",
|
||||
capacitor: "@capacitor/local-notifications",
|
||||
expo: "expo-notifications",
|
||||
},
|
||||
share: { browser: "Web Share API", capacitor: "@capacitor/share" },
|
||||
storage: {
|
||||
browser: "Web Storage",
|
||||
capacitor: "@capacitor/preferences",
|
||||
expo: "expo-secure-store",
|
||||
},
|
||||
};
|
||||
|
||||
function mode(root: string): "webview" | "native" {
|
||||
const file = join(root, "mobile", "package.json");
|
||||
if (!existsSync(file))
|
||||
throw new Error("No mobile project found. Run `wrnexus generate mobile` first.");
|
||||
return (
|
||||
(JSON.parse(readFileSync(file, "utf8")) as { wrnexus?: { mode?: "webview" | "native" } })
|
||||
.wrnexus?.mode ?? "webview"
|
||||
);
|
||||
}
|
||||
|
||||
export async function runNativeCommand(
|
||||
appRoot: string,
|
||||
subcommand?: string,
|
||||
args: string[] = [],
|
||||
): Promise<void> {
|
||||
if (subcommand === "list" || !subcommand) {
|
||||
console.log("WrNexus native capabilities:\n");
|
||||
for (const [name, entry] of Object.entries(nativeCatalog)) {
|
||||
console.log(
|
||||
` ${name.padEnd(14)} browser: ${entry.browser}; Capacitor: ${entry.capacitor ?? "built in"}; Expo: ${entry.expo ?? "built in"}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (subcommand === "add") {
|
||||
const names = args.filter((arg) => !arg.startsWith("--"));
|
||||
if (!names.length) throw new Error("Usage: wrnexus native add <capability...>");
|
||||
const unknown = names.filter((name) => !nativeCatalog[name]);
|
||||
if (unknown.length)
|
||||
throw new Error(
|
||||
`Unknown native capability: ${unknown.join(", ")}. Run \`wrnexus native list\`.`,
|
||||
);
|
||||
const root = resolve(appRoot);
|
||||
const target = mode(root);
|
||||
const packages = names
|
||||
.map((name) => nativeCatalog[name]![target === "native" ? "expo" : "capacitor"])
|
||||
.filter((value): value is string => !!value);
|
||||
if (!packages.length) {
|
||||
console.log(` ✓ ${names.join(", ")} use built-in APIs; no package installation required`);
|
||||
return;
|
||||
}
|
||||
await runMobileCommand(root, "add", packages);
|
||||
return;
|
||||
}
|
||||
throw new Error("Usage: wrnexus native <list|add <capability...>>");
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* `wrnexus profiles` — list the config profiles defined in `wrnexus.config.ts`,
|
||||
* mark the active one, and show which `.env` files exist for each.
|
||||
*/
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { loadRawConfig, resolveProfile } from "@wrnexus/styles";
|
||||
|
||||
export async function listProfiles(appRoot: string): Promise<void> {
|
||||
const root = resolve(appRoot);
|
||||
const config = await loadRawConfig(root);
|
||||
const active = resolveProfile();
|
||||
const defined = Object.keys(config.profiles ?? {});
|
||||
// Always show the two conventional profiles plus any custom ones.
|
||||
const names = Array.from(new Set(["development", "production", ...defined]));
|
||||
|
||||
console.log("Profiles (select with --profile=<name> or WRNEXUS_PROFILE):\n");
|
||||
for (const name of names) {
|
||||
const marker = name === active ? "●" : "○";
|
||||
const hasConfig = defined.includes(name) ? "config" : "";
|
||||
const envFiles = [`.env.${name}`, `.env.${name}.local`].filter((f) =>
|
||||
existsSync(join(root, f)),
|
||||
);
|
||||
const bits = [hasConfig, ...envFiles].filter(Boolean).join(", ");
|
||||
console.log(` ${marker} ${name.padEnd(14)}${bits ? " (" + bits + ")" : ""}`);
|
||||
}
|
||||
const baseEnv = [".env", ".env.local"].filter((f) => existsSync(join(root, f)));
|
||||
if (baseEnv.length) console.log(`\n base env: ${baseEnv.join(", ")} (loaded for every profile)`);
|
||||
console.log(`\n active: ${active}`);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* `wrnexus` typed-routes codegen. Scans the app's pages and writes
|
||||
* `app/routes.gen.ts` (a `Routes` map + `href()` builder). Run at `wrnexus dev`
|
||||
* startup; also exposed via `wrnexus generate routes`.
|
||||
*/
|
||||
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { buildRouter, generateRoutesFile } from "@wrnexus/router";
|
||||
|
||||
/** Regenerate `app/routes.gen.ts`. Returns the number of page routes. */
|
||||
export function regenerateRoutes(appDir: string): number {
|
||||
const router = buildRouter(appDir);
|
||||
const code = generateRoutesFile(router.pages);
|
||||
writeFileSync(join(appDir, "routes.gen.ts"), code, "utf8");
|
||||
return router.pages.length;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* `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.
|
||||
*/
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* `wrnexus update [dir] [--version=x.y.z | --latest] [--dry-run]`
|
||||
*
|
||||
* Upgrade an app (or every app in a workspace) to a WrNexus release:
|
||||
* 1. Bump every `@wrnexus/*` dependency to the target version.
|
||||
* 2. `bun install`.
|
||||
* 3. Refresh framework-owned reference files (llms.txt) and apply any
|
||||
* versioned, idempotent migrations that newer releases introduce.
|
||||
* 4. Record the applied version in package.json (`"wrnexus": { version }`).
|
||||
*
|
||||
* Target version resolution: `--version=x.y.z` > `--latest` (queries npm) >
|
||||
* the running CLI's own version (the default — pair with `bunx @wrnexus/cli@latest
|
||||
* update` to jump to the newest release with no network guesswork).
|
||||
*
|
||||
* Migrations are CONSERVATIVE: they only add/refresh framework-owned things and
|
||||
* never clobber your own code or edited CLAUDE.md. Add new ones to `MIGRATIONS`
|
||||
* as the framework evolves — that is how "new things" reach existing apps.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { AI_GUIDE, CLAUDE_MD } from "./ai-guide.ts";
|
||||
|
||||
/** The version of the CLI currently running (its own package.json). */
|
||||
function cliVersion(): string {
|
||||
try {
|
||||
return JSON.parse(readFileSync(join(import.meta.dir, "..", "package.json"), "utf8")).version;
|
||||
} catch {
|
||||
return "0.0.0";
|
||||
}
|
||||
}
|
||||
|
||||
/** Query the registry for the latest published `@wrnexus/cli` version. */
|
||||
function latestPublished(): string | null {
|
||||
try {
|
||||
const out = spawnSync("npm", ["view", "@wrnexus/cli", "version"], { encoding: "utf8" });
|
||||
const v = (out.stdout ?? "").trim();
|
||||
return /^\d+\.\d+\.\d+/.test(v) ? v : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Numeric compare of `x.y.z` (pre-release/build tags ignored). */
|
||||
function cmp(a: string, b: string): number {
|
||||
const pa = a.split("-")[0]!.split(".").map(Number);
|
||||
const pb = b.split("-")[0]!.split(".").map(Number);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const d = (pa[i] || 0) - (pb[i] || 0);
|
||||
if (d) return d > 0 ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
interface MigrationCtx {
|
||||
appRoot: string;
|
||||
from: string;
|
||||
to: string;
|
||||
dryRun: boolean;
|
||||
log: (msg: string) => void;
|
||||
}
|
||||
|
||||
interface Migration {
|
||||
/** Framework version that introduced this change. Runs when `from < version <= to`. */
|
||||
version: string;
|
||||
id: string;
|
||||
description: string;
|
||||
apply: (ctx: MigrationCtx) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Versioned, idempotent upgrade steps. Each MUST be safe to re-run and MUST NOT
|
||||
* overwrite user code. Append new entries with the version that ships them.
|
||||
*/
|
||||
const MIGRATIONS: Migration[] = [
|
||||
{
|
||||
version: "0.2.8",
|
||||
id: "gitignore-artifacts",
|
||||
description: "Ensure .gitignore covers framework build artifacts",
|
||||
apply(ctx) {
|
||||
const file = join(ctx.appRoot, ".gitignore");
|
||||
const want = ["node_modules/", "dist/", ".wrnexus/", "*.db"];
|
||||
const current = existsSync(file) ? readFileSync(file, "utf8") : "";
|
||||
const have = new Set(current.split(/\r?\n/).map((l) => l.trim()));
|
||||
const missing = want.filter((w) => !have.has(w));
|
||||
if (missing.length === 0) return;
|
||||
ctx.log(`+ .gitignore: ${missing.join(", ")}`);
|
||||
if (ctx.dryRun) return;
|
||||
const next = current.replace(/\s*$/, "") + "\n" + missing.join("\n") + "\n";
|
||||
writeFileSync(file, next.replace(/^\n+/, ""), "utf8");
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Bump every `@wrnexus/*` range to `^target`. Returns the human-readable changes. */
|
||||
function bumpDeps(pkg: Record<string, unknown>, target: string): string[] {
|
||||
const changed: string[] = [];
|
||||
const next = `^${target}`;
|
||||
for (const field of ["dependencies", "devDependencies", "peerDependencies"]) {
|
||||
const deps = pkg[field] as Record<string, string> | undefined;
|
||||
if (!deps) continue;
|
||||
for (const name of Object.keys(deps)) {
|
||||
if (name.startsWith("@wrnexus/") && deps[name] !== next) {
|
||||
changed.push(`${name} ${deps[name]} → ${next}`);
|
||||
deps[name] = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** The framework version an app was last updated to (or its installed CLI version). */
|
||||
function appVersion(appRoot: string, pkg: Record<string, unknown>): string {
|
||||
const marker = (pkg.wrnexus as { version?: string } | undefined)?.version;
|
||||
if (marker) return marker;
|
||||
try {
|
||||
const p = join(appRoot, "node_modules", "@wrnexus", "cli", "package.json");
|
||||
if (existsSync(p)) return JSON.parse(readFileSync(p, "utf8")).version;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return "0.0.0";
|
||||
}
|
||||
|
||||
/** Refresh pure framework-owned reference files. Never touches user-edited CLAUDE.md. */
|
||||
function refreshFrameworkFiles(appRoot: string, dryRun: boolean, log: (m: string) => void): void {
|
||||
// llms.txt is a generated reference — always safe to overwrite.
|
||||
const llms = join(appRoot, "llms.txt");
|
||||
if (!existsSync(llms) || readFileSync(llms, "utf8") !== AI_GUIDE) {
|
||||
log("~ llms.txt refreshed");
|
||||
if (!dryRun) writeFileSync(llms, AI_GUIDE, "utf8");
|
||||
}
|
||||
// CLAUDE.md is often user-edited — only create it when absent.
|
||||
const claude = join(appRoot, "CLAUDE.md");
|
||||
if (!existsSync(claude)) {
|
||||
log("+ CLAUDE.md created");
|
||||
if (!dryRun) writeFileSync(claude, CLAUDE_MD, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
/** Update a single app dir (bump its package.json, refresh files, run migrations). */
|
||||
function updateApp(appRoot: string, target: string, dryRun: boolean): boolean {
|
||||
const pkgPath = join(appRoot, "package.json");
|
||||
if (!existsSync(pkgPath)) {
|
||||
console.log(` ⚠ ${appRoot}: no package.json — skipped`);
|
||||
return false;
|
||||
}
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as Record<string, unknown>;
|
||||
const from = appVersion(appRoot, pkg);
|
||||
const log = (m: string) => console.log(` ${m}`);
|
||||
console.log(` ▸ ${pkg.name ?? appRoot} (${from} → ${target})`);
|
||||
|
||||
const changes = bumpDeps(pkg, target);
|
||||
changes.forEach((c) => log(c));
|
||||
if (!changes.length) log("dependencies already current");
|
||||
|
||||
refreshFrameworkFiles(appRoot, dryRun, log);
|
||||
|
||||
for (const m of MIGRATIONS) {
|
||||
if (cmp(m.version, from) > 0 && cmp(m.version, target) <= 0) {
|
||||
m.apply({ appRoot, from, to: target, dryRun, log });
|
||||
}
|
||||
}
|
||||
|
||||
// Record the applied version so the next update knows where it started.
|
||||
pkg.wrnexus = { ...(pkg.wrnexus as object), version: target };
|
||||
if (!dryRun) writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Load a `wrnexus.workspace.ts`, returning its app dirs (or null if not a workspace). */
|
||||
async function workspaceApps(root: string): Promise<string[] | null> {
|
||||
for (const f of ["wrnexus.workspace.ts", "wrnexus.workspace.js", "wrnexus.workspace.mjs"]) {
|
||||
const path = join(root, f);
|
||||
if (!existsSync(path)) continue;
|
||||
const mod = (await import(pathToFileURL(path).href)) as {
|
||||
default?: { apps?: { dir: string }[] };
|
||||
};
|
||||
return (mod.default?.apps ?? []).map((a) => resolve(root, a.dir));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function runUpdate(dir: string, args: string[]): Promise<void> {
|
||||
const root = resolve(dir);
|
||||
const dryRun = args.includes("--dry-run");
|
||||
const versionArg = args.find((a) => a.startsWith("--version="))?.split("=")[1];
|
||||
const target =
|
||||
versionArg ?? (args.includes("--latest") ? latestPublished() : null) ?? cliVersion();
|
||||
|
||||
console.log(`\n ⚡ wrnexus update → ${target}${dryRun ? " (dry run)" : ""}\n`);
|
||||
|
||||
// Workspace → update the root manifest + every app; else just this app.
|
||||
const apps = await workspaceApps(root);
|
||||
const targets = apps ? [root, ...apps] : [root];
|
||||
let updated = 0;
|
||||
for (const t of targets) if (updateApp(t, target, dryRun)) updated++;
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\n Dry run — no files written. Re-run without --dry-run to apply.\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
// One install at the top (Bun workspaces hoist), using the bun that's running us.
|
||||
console.log(`\n Installing…`);
|
||||
const res = spawnSync(process.execPath, ["install"], { cwd: root, stdio: "inherit" });
|
||||
if (res.status !== 0) {
|
||||
console.error(`\n ⚠ bun install exited with ${res.status}. Fix the error and re-run.`);
|
||||
process.exit(res.status ?? 1);
|
||||
}
|
||||
|
||||
console.log(`\n ✓ Updated ${updated} package(s) to ${target}.`);
|
||||
console.log(` Review the changes, then rebuild/redeploy (wrnexus build).\n`);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Monorepo support:
|
||||
* - `wrnexus workspace <name>` scaffolds a multi-app workspace (apps/* + shared packages/*)
|
||||
* - `wrnexus gateway [--port]` serves every app behind one port, routed by domain
|
||||
*
|
||||
* A workspace holds several WrNexus apps under `apps/*` and shared libraries under
|
||||
* `packages/*`. Apps share code by importing a workspace package (e.g. `@app/shared`),
|
||||
* share databases, and talk at runtime via @wrnexus/pubsub (Redis driver for
|
||||
* cross-process). `wrnexus.workspace.ts` maps each app to the domains it serves.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { scaffoldApp } from "./create.ts";
|
||||
import type { GatewayAuth, GatewaySecurity } from "@wrnexus/dev-server";
|
||||
|
||||
export interface WorkspaceApp {
|
||||
name: string;
|
||||
dir: string;
|
||||
domains: string[];
|
||||
port?: number;
|
||||
/** Per-app access control enforced at the gateway (basic auth, IP allowlist, forward-auth). */
|
||||
auth?: GatewayAuth;
|
||||
}
|
||||
export interface WorkspaceConfig {
|
||||
apps: WorkspaceApp[];
|
||||
/** Gateway-wide security (trusted hosts, rate limit, headers, access log). */
|
||||
security?: GatewaySecurity;
|
||||
}
|
||||
|
||||
const files = (name: string): Record<string, string> => ({
|
||||
"package.json": `{
|
||||
"name": "${name}",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": ["apps/*", "packages/*"],
|
||||
"scripts": {
|
||||
"dev": "wrnexus gateway",
|
||||
"gateway": "wrnexus gateway"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@wrnexus/cli": "^0.2.0"
|
||||
}
|
||||
}
|
||||
`,
|
||||
"wrnexus.workspace.ts": `import type { WorkspaceConfig } from "@wrnexus/cli/workspace";
|
||||
|
||||
// Map each app to the domains it serves. \`wrnexus gateway\` runs them all behind
|
||||
// one port and routes by Host header (add these hosts to your /etc/hosts).
|
||||
const config: WorkspaceConfig = {
|
||||
// Gateway-wide security (all optional):
|
||||
security: {
|
||||
trustedHostsOnly: true, // reject requests for unknown domains
|
||||
rateLimit: { max: 300, windowMs: 60_000 }, // per client IP
|
||||
headers: true, // baseline security headers at the edge
|
||||
accessLog: true, // log host → app, method, path, status
|
||||
},
|
||||
apps: [
|
||||
{ name: "web", dir: "apps/web", domains: ["localhost", "web.localhost"] },
|
||||
{
|
||||
name: "admin",
|
||||
dir: "apps/admin",
|
||||
domains: ["admin.localhost"],
|
||||
// Lock the admin app down at the edge (pick one):
|
||||
auth: { basic: { user: "admin", pass: "change-me" } },
|
||||
// auth: { allowIps: ["127.0.0.1", "::1"] },
|
||||
// auth: { forward: { url: "http://localhost:4001/api/verify" } }, // SSO
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default config;
|
||||
`,
|
||||
".gitignore": `node_modules/
|
||||
dist/
|
||||
.wrnexus/
|
||||
*.log
|
||||
*.db
|
||||
`,
|
||||
"packages/shared/package.json": `{
|
||||
"name": "@app/shared",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": { ".": "./src/index.ts" },
|
||||
"dependencies": {
|
||||
"@wrnexus/pubsub": "^0.2.0"
|
||||
}
|
||||
}
|
||||
`,
|
||||
"packages/shared/src/index.ts": `/**
|
||||
* Shared code for every app in this workspace. Import it anywhere: \`@app/shared\`.
|
||||
* The cross-app event bus uses Redis so messages reach every app process/domain.
|
||||
*/
|
||||
import { createPubSub } from "@wrnexus/pubsub";
|
||||
import { redisDriver } from "@wrnexus/pubsub/redis";
|
||||
|
||||
// One bus per process, backed by Redis (set REDIS_URL, defaults to localhost:6379).
|
||||
export const bus = createPubSub(redisDriver(process.env.REDIS_URL));
|
||||
|
||||
// Shared domain types can live here and be imported by every app.
|
||||
export interface Tenant {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
`,
|
||||
"README.md": `# ${name}
|
||||
|
||||
A WrNexus **workspace** — multiple apps, one gateway, interconnected.
|
||||
|
||||
\`\`\`
|
||||
${name}/
|
||||
wrnexus.workspace.ts # apps ↔ domains map (used by \`wrnexus gateway\`)
|
||||
apps/
|
||||
web/ # a WrNexus app → localhost, web.localhost
|
||||
admin/ # a WrNexus app → admin.localhost
|
||||
packages/
|
||||
shared/ # @app/shared — shared code + cross-app pubsub bus
|
||||
\`\`\`
|
||||
|
||||
## Run everything (one port, routed by domain)
|
||||
|
||||
\`\`\`bash
|
||||
bun install
|
||||
bun run dev # = wrnexus gateway → http://localhost:3000
|
||||
\`\`\`
|
||||
|
||||
Add the hosts to your machine (e.g. /etc/hosts):
|
||||
|
||||
\`\`\`
|
||||
127.0.0.1 web.localhost admin.localhost
|
||||
\`\`\`
|
||||
|
||||
## Interconnect
|
||||
|
||||
- **Shared code:** import \`@app/shared\` in any app.
|
||||
- **Runtime messaging:** \`import { bus } from "@app/shared"\` then
|
||||
\`bus.publish("tenant:created", {...})\` in one app and
|
||||
\`bus.subscribe("tenant:*", fn)\` in another (needs Redis).
|
||||
- **Databases:** point apps at the same \`db\`/\`databases\` in their config.
|
||||
|
||||
## Add another app
|
||||
|
||||
\`\`\`bash
|
||||
wrnexus create apps/reports
|
||||
# then add it to wrnexus.workspace.ts with its domains
|
||||
\`\`\`
|
||||
`,
|
||||
});
|
||||
|
||||
/** Scaffold a monorepo workspace with two starter apps + a shared package. */
|
||||
export function createWorkspace(name: string): void {
|
||||
if (!name) {
|
||||
console.error("Usage: wrnexus workspace <name>");
|
||||
process.exit(1);
|
||||
}
|
||||
const root = resolve(process.cwd(), name);
|
||||
if (existsSync(root)) {
|
||||
console.error(`Refusing to overwrite existing directory: ${root}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
for (const [rel, contents] of Object.entries(files(name))) {
|
||||
const target = join(root, rel);
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(target, contents, "utf8");
|
||||
}
|
||||
// Two starter apps under apps/.
|
||||
scaffoldApp(join(root, "apps", "web"), "web");
|
||||
scaffoldApp(join(root, "apps", "admin"), "admin");
|
||||
|
||||
console.log(`✓ Created workspace ${name}`);
|
||||
console.log(`\nNext steps:`);
|
||||
console.log(` cd ${name}`);
|
||||
console.log(` bun install`);
|
||||
console.log(` bun run dev # wrnexus gateway (web + admin, routed by domain)`);
|
||||
}
|
||||
|
||||
/** Load `wrnexus.workspace.ts` from a directory. */
|
||||
export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig> {
|
||||
for (const file of ["wrnexus.workspace.ts", "wrnexus.workspace.js", "wrnexus.workspace.mjs"]) {
|
||||
const path = join(root, file);
|
||||
if (existsSync(path)) {
|
||||
const mod = (await import(pathToFileURL(path).href)) as { default?: WorkspaceConfig };
|
||||
if (!mod.default?.apps?.length)
|
||||
throw new Error(`${file} must default-export { apps: [...] }`);
|
||||
return mod.default;
|
||||
}
|
||||
}
|
||||
throw new Error("No wrnexus.workspace.ts found. Run `wrnexus workspace <name>` to scaffold one.");
|
||||
}
|
||||
|
||||
/** Run the multi-app gateway from `wrnexus.workspace.ts`. */
|
||||
export async function runGateway(root: string, args: string[]): Promise<void> {
|
||||
const config = await loadWorkspaceConfig(resolve(root));
|
||||
const portArg = args.find((a) => a.startsWith("--port="));
|
||||
const hostArg = args.find((a) => a.startsWith("--host="));
|
||||
const port = portArg ? Number(portArg.split("=")[1]) : 3000;
|
||||
const hostname = hostArg?.split("=")[1] || "::";
|
||||
const mode = args.includes("--prod") ? "production" : "development";
|
||||
|
||||
const { startGateway } = await import("@wrnexus/dev-server");
|
||||
await startGateway({
|
||||
port,
|
||||
hostname,
|
||||
mode,
|
||||
security: config.security,
|
||||
apps: config.apps.map((a) => ({
|
||||
name: a.name,
|
||||
dir: resolve(root, a.dir),
|
||||
domains: a.domains,
|
||||
port: a.port,
|
||||
auth: a.auth,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { mkdtempSync, existsSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { generateDocker } from "../src/docker.ts";
|
||||
|
||||
test("generateDocker scaffolds Dockerfile, .dockerignore, compose", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "wire-docker-"));
|
||||
generateDocker(dir);
|
||||
expect(existsSync(join(dir, "Dockerfile"))).toBe(true);
|
||||
expect(existsSync(join(dir, ".dockerignore"))).toBe(true);
|
||||
expect(existsSync(join(dir, "docker-compose.yml"))).toBe(true);
|
||||
|
||||
const dockerfile = readFileSync(join(dir, "Dockerfile"), "utf8");
|
||||
expect(dockerfile).toContain("FROM oven/bun:1 AS build");
|
||||
expect(dockerfile).toContain("bun run build");
|
||||
expect(dockerfile).toContain("/healthz"); // health check uses the app endpoint
|
||||
expect(dockerfile).toContain('CMD ["bun", "dist/server.js"]');
|
||||
|
||||
expect(readFileSync(join(dir, "docker-compose.yml"), "utf8")).toContain("postgres:16-alpine");
|
||||
});
|
||||
|
||||
test("generateDocker does not overwrite existing files", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "wire-docker-"));
|
||||
generateDocker(dir);
|
||||
const before = readFileSync(join(dir, "Dockerfile"), "utf8");
|
||||
// second run must leave the file untouched
|
||||
generateDocker(dir);
|
||||
expect(readFileSync(join(dir, "Dockerfile"), "utf8")).toBe(before);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { inspectProject } from "../src/doctor.ts";
|
||||
|
||||
test("doctor reports a healthy minimal project", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-doctor-"));
|
||||
mkdirSync(join(root, "app", "pages"), { recursive: true });
|
||||
writeFileSync(join(root, "package.json"), JSON.stringify({ name: "app" }));
|
||||
writeFileSync(join(root, "wrnexus.config.ts"), "export default {};");
|
||||
const checks = inspectProject(root);
|
||||
expect(checks.every((check) => check.ok)).toBe(true);
|
||||
});
|
||||
|
||||
test("doctor returns actionable missing-project checks", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-doctor-"));
|
||||
const checks = inspectProject(root);
|
||||
expect(checks.find((check) => check.name === "package.json")?.ok).toBe(false);
|
||||
expect(checks.find((check) => check.name === "app/pages")?.detail).toBe("Create app/pages");
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { scaffold } from "../src/generate.ts";
|
||||
|
||||
test("scaffold page: kebab file, PascalCase page name", () => {
|
||||
const f = scaffold("page", "about-us");
|
||||
expect(f.path).toBe("pages/about-us.wrn");
|
||||
expect(f.content).toContain("page AboutUs {");
|
||||
expect(f.content).toContain('layout = "public"');
|
||||
});
|
||||
|
||||
test("scaffold component: props + class", () => {
|
||||
const f = scaffold("component", "user-card");
|
||||
expect(f.path).toBe("components/user-card.wrn");
|
||||
expect(f.content).toContain("component UserCard {");
|
||||
expect(f.content).toContain('class="wire-user-card"');
|
||||
});
|
||||
|
||||
test("scaffold api: nested path keeps directories, exports GET", () => {
|
||||
const f = scaffold("api", "users/list");
|
||||
expect(f.path).toBe("api/users/list.ts");
|
||||
expect(f.content).toContain("export async function GET");
|
||||
});
|
||||
|
||||
test("scaffold schema: validation object", () => {
|
||||
const f = scaffold("schema", "signup");
|
||||
expect(f.path).toBe("schemas/signup.ts");
|
||||
expect(f.content).toContain('import { v } from "@wrnexus/validation"');
|
||||
expect(f.content).toContain("export default v.object(");
|
||||
});
|
||||
|
||||
test("scaffold strips extensions from the given name", () => {
|
||||
expect(scaffold("page", "home.wrn").path).toBe("pages/home.wrn");
|
||||
expect(scaffold("api", "ping.ts").path).toBe("api/ping.ts");
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { generateMobile, mobileOptions } from "../src/mobile.ts";
|
||||
import { runMobileCommand } from "../src/mobile-command.ts";
|
||||
import { nativeCatalog, runNativeCommand } from "../src/native-command.ts";
|
||||
|
||||
test("generateMobile scaffolds a configurable Capacitor project", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "wire-mobile-"));
|
||||
writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "@acme/store-front" }));
|
||||
|
||||
await generateMobile(dir, {
|
||||
appId: "com.acme.store",
|
||||
appName: "Acme Store",
|
||||
serverUrl: "https://store.example.com",
|
||||
});
|
||||
|
||||
const config = readFileSync(join(dir, "mobile", "capacitor.config.ts"), "utf8");
|
||||
expect(config).toContain('appId: mobile.appId ?? "com.acme.store"');
|
||||
expect(config).toContain('appName: mobile.appName ?? "Acme Store"');
|
||||
expect(config).toContain("...(mobile.capacitor ?? {})");
|
||||
expect(config).not.toContain("errorPath:");
|
||||
expect(config).toContain('"https://store.example.com"');
|
||||
expect(existsSync(join(dir, "mobile", "web", "index.html"))).toBe(true);
|
||||
|
||||
const pkg = JSON.parse(readFileSync(join(dir, "mobile", "package.json"), "utf8"));
|
||||
expect(pkg.dependencies["@capacitor/android"]).toBe("^8.0.0");
|
||||
expect(pkg.scripts["open:ios"]).toBe("cap open ios");
|
||||
});
|
||||
|
||||
test("generateMobile derives defaults and does not overwrite files", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "wire-mobile-"));
|
||||
writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "demo-app" }));
|
||||
await generateMobile(dir);
|
||||
const path = join(dir, "mobile", "capacitor.config.ts");
|
||||
const before = readFileSync(path, "utf8");
|
||||
expect(before).toContain('appId: mobile.appId ?? "com.example.demoapp"');
|
||||
expect(before).toContain('"http://localhost:3000"');
|
||||
await generateMobile(dir, { appName: "Changed" });
|
||||
expect(readFileSync(path, "utf8")).toBe(before);
|
||||
});
|
||||
|
||||
test("mobileOptions parses generator flags", () => {
|
||||
expect(
|
||||
mobileOptions(["--app-id=com.acme.app", "--app-name=Acme", "--url=https://app.test"]),
|
||||
).toEqual({
|
||||
appId: "com.acme.app",
|
||||
appName: "Acme",
|
||||
serverUrl: "https://app.test",
|
||||
mode: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("generateMobile scaffolds a WebView-free native project from config", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "wire-native-"));
|
||||
writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "native-demo" }));
|
||||
writeFileSync(
|
||||
join(dir, "wrnexus.config.mjs"),
|
||||
`export default { mobile: { mode: "native", appId: "com.acme.native", apiUrl: "https://api.example.com" } }`,
|
||||
);
|
||||
await generateMobile(dir);
|
||||
const pkg = JSON.parse(readFileSync(join(dir, "mobile", "package.json"), "utf8"));
|
||||
expect(pkg.wrnexus.mode).toBe("native");
|
||||
expect(pkg.dependencies.expo).toBeDefined();
|
||||
expect(existsSync(join(dir, "mobile", "app", "index.tsx"))).toBe(true);
|
||||
expect(readFileSync(join(dir, "mobile", "src", "wrnexus.ts"), "utf8")).toContain(
|
||||
"https://api.example.com",
|
||||
);
|
||||
expect(existsSync(join(dir, "mobile", "capacitor.config.ts"))).toBe(false);
|
||||
});
|
||||
|
||||
test("mobile compile turns wrn pages into Expo routes", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "wire-native-compile-"));
|
||||
writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "native-compile" }));
|
||||
writeFileSync(join(dir, "wrnexus.config.mjs"), `export default { mobile: { mode: "native" } }`);
|
||||
await generateMobile(dir);
|
||||
const pages = join(dir, "app", "pages");
|
||||
const nested = join(pages, "account");
|
||||
mkdirSync(nested, { recursive: true });
|
||||
writeFileSync(
|
||||
join(nested, "profile.wrn"),
|
||||
"page Profile { view { <main><h1>Profile</h1></main> } }",
|
||||
);
|
||||
await runMobileCommand(dir, "compile");
|
||||
const output = readFileSync(join(dir, "mobile", "app", "account", "profile.tsx"), "utf8");
|
||||
expect(output).toContain("function Profile()");
|
||||
expect(output).toContain("<View><Text>Profile</Text></View>");
|
||||
});
|
||||
|
||||
test("native catalog uses built-in React Native sharing without installing expo-sharing", async () => {
|
||||
expect(nativeCatalog.share?.expo).toBeUndefined();
|
||||
const dir = mkdtempSync(join(tmpdir(), "wire-native-share-"));
|
||||
writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "native-share" }));
|
||||
writeFileSync(join(dir, "wrnexus.config.mjs"), `export default { mobile: { mode: "native" } }`);
|
||||
await generateMobile(dir);
|
||||
await runNativeCommand(dir, "add", ["share"]);
|
||||
const pkg = JSON.parse(readFileSync(join(dir, "mobile", "package.json"), "utf8"));
|
||||
expect(pkg.dependencies["expo-sharing"]).toBeUndefined();
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
# @wrnexus/compiler
|
||||
|
||||
> 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.
|
||||
|
||||
## 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.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @wrnexus/compiler
|
||||
```
|
||||
|
||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
|
||||
|
||||
## API
|
||||
|
||||
All exports come from the package root (`@wrnexus/compiler`).
|
||||
|
||||
### `compileWireFile(source: string): string`
|
||||
|
||||
Compile `.wrn` source to a TypeScript module string. Throws `ParseError` on invalid input. The output is prefixed with a `// compiled from .wrn` comment.
|
||||
|
||||
### `compile(source: string): CompileResult`
|
||||
|
||||
Richer entry point that returns the generated code, the AST, and any diagnostics.
|
||||
|
||||
```ts
|
||||
interface CompileResult {
|
||||
code: string;
|
||||
ast: PageAst;
|
||||
diagnostics: string[];
|
||||
}
|
||||
```
|
||||
|
||||
On a `ParseError` it pushes the message into `diagnostics` and re-throws.
|
||||
|
||||
### `parse(source: string): PageAst`
|
||||
|
||||
Run the lexer + recursive-descent parser and return the AST. Throws `ParseError` (lexer `LexError`s are caught and rethrown as `ParseError`).
|
||||
|
||||
### `generate(ast: PageAst): string`
|
||||
|
||||
Lower a `PageAst` to TypeScript. `page` ASTs become a default-export page component (plus `meta`, optional `layout`, `__wrnexusApi`/method handlers, `websocket`, and SSR/CSR data bindings); `component` ASTs become a module exporting `render(props)` and `__wrnexusComponent`.
|
||||
|
||||
### `Lexer`
|
||||
|
||||
On-demand lexer for `.wrn`. Yields structural tokens and exposes raw-span readers for the parser.
|
||||
|
||||
```ts
|
||||
class Lexer {
|
||||
pos: number;
|
||||
constructor(src: string);
|
||||
next(): Token; // consume next structural token
|
||||
peek(): Token; // look ahead without consuming
|
||||
readPath(): string; // route path, e.g. /users/[id]
|
||||
readToLineEnd(): string; // rest of line (state/prop initializers)
|
||||
readBalancedBraces(): string; // inner text of a { ... } block, string-aware
|
||||
}
|
||||
```
|
||||
|
||||
`Token` is `{ type: TokenType; value: string; pos: number }`, where `TokenType` is one of `ident`, `string`, `lbrace`, `rbrace`, `lparen`, `rparen`, `at`, `eq`, `comma`, `eof`.
|
||||
|
||||
### Errors
|
||||
|
||||
| Class | Thrown by | Meaning |
|
||||
| ------------ | ------------------------------------------------- | --------------------------------------------------------------- |
|
||||
| `ParseError` | `parse`, `compile`, `compileWireFile`, `generate` | Invalid `.wrn` grammar or (rewrapped) lex failure. |
|
||||
| `LexError` | `Lexer` | Unexpected character / unterminated string / unbalanced braces. |
|
||||
|
||||
### AST types
|
||||
|
||||
Exported type-only symbols describing the parsed tree:
|
||||
|
||||
| Type | Description |
|
||||
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `PageAst` | Root node: `kind` (`"page" \| "component"`), `name`, optional `layout`, `props`, `states`, `seo`, `view`, `styles`, `functions`, `dataApis`, `modeFunctions`, `apis`, `realtimes`. |
|
||||
| `ViewNode` | `{ type: "text"; value }` or `{ type: "element"; tag; attrs; children }`. |
|
||||
| `Attr` | `{ name; value; event; boolean? }` — `event` marks `@event` bindings. |
|
||||
| `StateDecl` | `{ name; expr }` — a `state x = <expr>` declaration. |
|
||||
| `SeoBlock` | `Record<string, string>` from the `seo { ... }` block. |
|
||||
| `ApiBlock` | `{ method; path; body }` — a top-level `api METHOD /path { ... }`. |
|
||||
| `DataApiBlock` | `{ mode; name; method; path; body }` — an `api` inside an `ssr`/`client` block. |
|
||||
| `DataMode` | `"ssr" \| "client"`. |
|
||||
| `ModeFunctionsBlock` | `{ mode; body }` — a `functions { ... }` inside an `ssr`/`client` block. |
|
||||
| `RealtimeBlock` | `{ name; handlers }` — a `realtime <name> { on evt(args) { ... } }` block. |
|
||||
|
||||
## Usage
|
||||
|
||||
Compile a page:
|
||||
|
||||
```ts
|
||||
import { compileWireFile } from "@wrnexus/compiler";
|
||||
|
||||
const ts = compileWireFile(`
|
||||
page Home {
|
||||
state count = 0
|
||||
seo { title = "Home" description = "Welcome" }
|
||||
view {
|
||||
<button @click="count++">Clicked {count} times</button>
|
||||
}
|
||||
}
|
||||
`);
|
||||
// ts is a TypeScript module: exports `meta`, and a default page component
|
||||
// returning an HTML string, wrapped in a data-scope for the reactive runtime.
|
||||
```
|
||||
|
||||
Inspect the AST and diagnostics:
|
||||
|
||||
```ts
|
||||
import { compile, ParseError } from "@wrnexus/compiler";
|
||||
|
||||
try {
|
||||
const { code, ast, diagnostics } = compile(source);
|
||||
console.log(ast.kind, ast.name, ast.states.length);
|
||||
} catch (err) {
|
||||
if (err instanceof ParseError) console.error(err.message);
|
||||
}
|
||||
```
|
||||
|
||||
Drive the parse/codegen stages directly:
|
||||
|
||||
```ts
|
||||
import { parse, generate } from "@wrnexus/compiler";
|
||||
|
||||
const ast = parse(componentSource); // ast.kind === "component"
|
||||
const module = generate(ast); // exports render(props) + __wrnexusComponent
|
||||
```
|
||||
|
||||
Use the lexer standalone:
|
||||
|
||||
```ts
|
||||
import { Lexer } from "@wrnexus/compiler";
|
||||
|
||||
const lx = new Lexer("page Home {");
|
||||
lx.next(); // { type: "ident", value: "page", pos: 0 }
|
||||
lx.next(); // { type: "ident", value: "Home", pos: 5 }
|
||||
lx.next(); // { type: "lbrace", value: "{", pos: 10 }
|
||||
```
|
||||
|
||||
## The `.wrn` language (as parsed)
|
||||
|
||||
A file opens with `page <Name>` or `component <Name>` followed by a `{ ... }` body containing zero or more members:
|
||||
|
||||
- `layout = "<name>"` — selects `app/layouts/<name>.wrn` (pages only).
|
||||
- `props { name = <default> ... }` — component props; each default's type drives coercion.
|
||||
- `state <ident> = <expr>` — reactive state seeded from a raw JS expression.
|
||||
- `view { <html> }` — plain HTML with `{expr}` interpolation, hyphenated attributes, boolean attributes, `@event="..."` client bindings, and `<!-- comments -->`.
|
||||
- `seo { key = "value" ... }` — metadata merged into the generated `meta`.
|
||||
- `style { <raw css> }` — inlined page/component stylesheet (repeatable).
|
||||
- `functions { <raw js> }` — shared server-side helpers (repeatable).
|
||||
- `api <METHOD> <path> { <raw js> }` — route handler, lowered to a `METHOD` export (repeatable).
|
||||
- `ssr { ... }` / `client { ... }` — data blocks holding `api <name> <METHOD> <path> { ... }` bindings and their own `functions { ... }`.
|
||||
- `realtime <name> { on <evt>(<args>) { <raw js> } ... }` — websocket handlers, lowered to a `websocket` export.
|
||||
|
||||
`view` markup is parsed by a lenient dedicated HTML parser (`parseHtmlView`); HTML void elements (`<br>`, `<img>`, …) take no closing tag. Line comments (`//`) are skipped by the lexer.
|
||||
|
||||
## Requirements / Notes
|
||||
|
||||
- Pure TypeScript with no runtime dependencies; runs under **Bun** as part of the WrNexus toolchain (Node is not supported).
|
||||
- Generated modules target WrNexus runtime primitives (`data-scope`, `data-text`, `data-on-*`, `data-for`, `data-component`, `__wrnexus*`/`__wire*` helpers) — consume the output within a WrNexus app, e.g. via `@wrnexus/core`'s dev loader.
|
||||
@@ -0,0 +1,62 @@
|
||||
# The `.wrn` language vision
|
||||
|
||||
`.wrn` is a planned single-file component language for WrNexus. One file can
|
||||
declare page state, view markup, SSR/client data bindings, styles, and realtime
|
||||
handlers, and the compiler lowers all of it to the TypeScript primitives the
|
||||
runtime already understands. Business logic should live in normal `app/api`
|
||||
route files; `.wrn` data bindings call those routes and render the response.
|
||||
|
||||
## Example
|
||||
|
||||
```my
|
||||
page Home {
|
||||
state count = 0
|
||||
|
||||
ssr {
|
||||
api users GET /api/users {
|
||||
return users.map((user) => user.name).join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
client {
|
||||
api latestUsers GET /api/users/latest {
|
||||
return users.map((user) => user.name).join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<h1>Hello</h1>
|
||||
<button @click="count++">Count: {count}</button>
|
||||
<div api="users">Loading users...</div>
|
||||
<div api="latestUsers">Loading latest users...</div>
|
||||
}
|
||||
|
||||
realtime chat {
|
||||
on message(data) {
|
||||
broadcast(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Lowering targets
|
||||
|
||||
| `.wrn` construct | Compiles to |
|
||||
| ----------------------------------------- | ------------------------------------------------------- |
|
||||
| `state x = 0` | a `data-scope` seed hydrated by the reactive runtime |
|
||||
| `view { ... }` | a page component returning an SSR HTML string |
|
||||
| `@click="count++"` | a `data-on-click` binding in the reactive runtime |
|
||||
| `{count}` | a mustache text binding evaluated against `data-scope` |
|
||||
| `ssr { api users GET /api/users { } }` | server-side API call + HTML replacement before response |
|
||||
| `client { api users GET /api/users { } }` | opaque CSR binding resolved through `/__wrnexus/csr` |
|
||||
| `realtime chat { }` | a `websocket` export in the realtime router |
|
||||
|
||||
## Pipeline
|
||||
|
||||
```
|
||||
.wrn source ──▶ tokenizer ──▶ parser ──▶ AST ──▶ codegen ──▶ .ts ──▶ Bun runtime
|
||||
```
|
||||
|
||||
The MVP now includes tokenizing, parsing, and code generation for a small real
|
||||
subset. Future milestones can add richer expressions, typed data contracts,
|
||||
component composition, and a safer custom expression evaluator.
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@wrnexus/compiler",
|
||||
"version": "0.2.12",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,862 @@
|
||||
/**
|
||||
* Code generation: lower a `.wrn` AST to TypeScript that targets the framework's
|
||||
* existing primitives.
|
||||
*
|
||||
* state -> a `data-scope` declaration consumed by the runtime
|
||||
* view -> an HTML string returned by a page component
|
||||
* @event="..." -> data-on-<event>="..."
|
||||
* "...{expr}..." -> text kept verbatim ({expr} is mustache for runtime)
|
||||
* api="<name>" -> SSR/client data binding declared in a mode block
|
||||
* ssrGet/ssrText -> legacy server-side API fetch + render
|
||||
* csrGet/csrText -> legacy browser-side API fetch + render
|
||||
* style -> an inline page stylesheet
|
||||
* functions -> server-only helpers for API/realtime code
|
||||
* api M /p {b} -> export const M = async (ctx) => { b }
|
||||
* realtime {..} -> export const websocket = { evt(ws, ...args) { b } }
|
||||
*/
|
||||
|
||||
import { VOID_ELEMENTS, type Attr, type DataMode, type PageAst, type ViewNode } from "./parser.ts";
|
||||
|
||||
interface RenderBinding {
|
||||
method: string;
|
||||
path: string;
|
||||
body: string;
|
||||
helpers: string;
|
||||
}
|
||||
|
||||
interface SsrBinding extends RenderBinding {
|
||||
marker: string;
|
||||
}
|
||||
|
||||
interface CsrBinding extends RenderBinding {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface NamedDataBinding extends RenderBinding {
|
||||
mode: DataMode;
|
||||
}
|
||||
|
||||
/** Escape a value placed inside a double-quoted HTML attribute. */
|
||||
function attrEscape(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/** Make HTML safe to embed inside a JS template literal. */
|
||||
function templateEscape(html: string): string {
|
||||
return html.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
|
||||
}
|
||||
|
||||
function styleEscape(css: string): string {
|
||||
return css.replace(/<\/style/gi, "<\\/style");
|
||||
}
|
||||
|
||||
function attrValue(attrs: Attr[], name: string): string | undefined {
|
||||
return attrs.find((attr) => !attr.event && attr.name === name)?.value;
|
||||
}
|
||||
|
||||
function renderAttr(attr: Attr): string {
|
||||
if (attr.event) return ` ${eventAttribute(attr.name)}="${attrEscape(attr.value)}"`;
|
||||
|
||||
switch (attr.name) {
|
||||
case "api":
|
||||
case "ssrGet":
|
||||
case "ssrText":
|
||||
case "csrGet":
|
||||
case "csrText":
|
||||
return "";
|
||||
default:
|
||||
return attr.boolean ? ` ${attr.name}` : ` ${attr.name}="${attrEscape(attr.value)}"`;
|
||||
}
|
||||
}
|
||||
|
||||
function eventAttribute(name: string): string {
|
||||
if (name.startsWith("browser-")) return `data-on-wrnexus-browser-${name.slice(8)}`;
|
||||
if (name.startsWith("mobile-")) return `data-on-wrnexus-mobile-${name.slice(7)}`;
|
||||
return `data-on-${name}`;
|
||||
}
|
||||
|
||||
function renderAttrs(attrs: Attr[], csrId?: string): string {
|
||||
const rendered = attrs.map(renderAttr).join("");
|
||||
return csrId ? `${rendered} data-wrnexus-csr="${attrEscape(csrId)}"` : rendered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace i18n text sugar `{t:key}` with a `<span data-t="key">` marker the
|
||||
* runtime resolves server-side. Other `{expr}` mustaches are left untouched.
|
||||
*/
|
||||
function substituteTMarkers(text: string): string {
|
||||
return text.replace(
|
||||
/\{t:([^{}]+)\}/g,
|
||||
(_m, key: string) => `<span data-t="${attrEscape(key.trim())}"></span>`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Escape a value for safe embedding in HTML text. */
|
||||
function htmlTextEscape(value: string): string {
|
||||
return value.replace(/[&<>]/g, (c) => (c === "&" ? "&" : c === "<" ? "<" : ">"));
|
||||
}
|
||||
|
||||
/** Reactive page context: state names + their initial (SSR) values. */
|
||||
interface PageReactive {
|
||||
stateNames: Set<string>;
|
||||
scope: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a page's `state` seed expressions at compile time to obtain the
|
||||
* initial SSR values used to bake `data-text` spans. Seeds may reference
|
||||
* earlier ones; anything that can't be evaluated becomes `undefined`.
|
||||
*/
|
||||
function evalStateSeeds(states: { name: string; expr: string }[]): Record<string, unknown> {
|
||||
const scope: Record<string, unknown> = {};
|
||||
for (const s of states) {
|
||||
try {
|
||||
scope[s.name] = new Function("with(this){return (" + s.expr + ");}").call(scope);
|
||||
} catch {
|
||||
scope[s.name] = undefined;
|
||||
}
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
/**
|
||||
* Page text compilation: resolve `{t:key}` i18n markers, then bake state
|
||||
* interpolations (`{count}`, `{count * 2}`) into `data-text` spans carrying the
|
||||
* evaluated initial value — so no-JS clients see real content and the reactive
|
||||
* runtime keeps it live. Non-state `{expr}` and un-evaluable expressions are
|
||||
* left as literal client mustaches.
|
||||
*/
|
||||
function substituteReactiveText(raw: string, reactive: PageReactive | null): string {
|
||||
const text = substituteTMarkers(raw);
|
||||
if (!reactive || reactive.stateNames.size === 0) return text;
|
||||
return text.replace(/\{([^{}]+)\}/g, (whole, inner: string) => {
|
||||
const expr = inner.trim();
|
||||
if (expr.startsWith("t:") || !exprRefsState(expr, reactive.stateNames)) return whole;
|
||||
let value: unknown;
|
||||
try {
|
||||
value = new Function("with(this){return (" + expr + ");}").call(reactive.scope);
|
||||
} catch {
|
||||
return whole; // can't evaluate → keep as a client-only mustache
|
||||
}
|
||||
const baked = htmlTextEscape(value == null ? "" : String(value));
|
||||
return `<span data-text="${attrEscape(expr)}">${baked}</span>`;
|
||||
});
|
||||
}
|
||||
|
||||
type EachNode = Extract<ViewNode, { type: "each" }>;
|
||||
type IfNode = Extract<ViewNode, { type: "if" }>;
|
||||
|
||||
/**
|
||||
* Bake a loop-body text run into template-literal source: static text is escaped
|
||||
* for the literal, `{expr}` becomes `${__wrnexusEscapeHtml(expr)}` (server-rendered,
|
||||
* escaped), and `{t:key}` becomes a `data-t` marker resolved later by translateHtml.
|
||||
*/
|
||||
function bakeLoopText(raw: string): string {
|
||||
let out = "";
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
const re = /\{([^{}]+)\}/g;
|
||||
while ((m = re.exec(raw))) {
|
||||
out += escLit(raw.slice(last, m.index));
|
||||
const expr = m[1]!.trim();
|
||||
if (expr.startsWith("t:")) {
|
||||
out += escLit(`<span data-t="${attrEscape(expr.slice(2).trim())}"></span>`);
|
||||
} else {
|
||||
out += "${__wrnexusEscapeHtml(" + expr + ")}";
|
||||
}
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
return out + escLit(raw.slice(last));
|
||||
}
|
||||
|
||||
/** Bake a loop-body attribute value (same rules as text; escapeHtml is attribute-safe). */
|
||||
function bakeLoopAttr(raw: string): string {
|
||||
if (!raw.includes("{")) return escLit(attrEscape(raw));
|
||||
let out = "";
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
const re = /\{([^{}]+)\}/g;
|
||||
while ((m = re.exec(raw))) {
|
||||
out += escLit(attrEscape(raw.slice(last, m.index)));
|
||||
out += "${__wrnexusEscapeHtml(" + m[1]!.trim() + ")}";
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
return out + escLit(attrEscape(raw.slice(last)));
|
||||
}
|
||||
|
||||
/** Render one loop-body node to template-literal source (nested loops inline). */
|
||||
function renderLoopBody(node: ViewNode): string {
|
||||
if (node.type === "text") return bakeLoopText(node.value);
|
||||
if (node.type === "each") return compileEachExpr(node);
|
||||
if (node.type === "if") return compileIfExpr(node);
|
||||
const attrs = node.attrs
|
||||
.map((a) => {
|
||||
const name = a.event ? eventAttribute(a.name) : a.name;
|
||||
if (a.boolean) return escLit(` ${name}`);
|
||||
return escLit(` ${name}="`) + bakeLoopAttr(a.value) + escLit(`"`);
|
||||
})
|
||||
.join("");
|
||||
if (VOID_ELEMENTS.has(node.tag.toLowerCase()))
|
||||
return escLit(`<${node.tag}`) + attrs + escLit(">");
|
||||
const inner = node.children.map(renderLoopBody).join("");
|
||||
return escLit(`<${node.tag}`) + attrs + escLit(">") + inner + escLit(`</${node.tag}>`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a `{#each list as item}` block to a `${…}` template-literal interpolation
|
||||
* that iterates the (server-evaluated) list and joins the per-item body. `list` is a
|
||||
* JS expression evaluated where `ssr` data bindings are in scope as raw named values.
|
||||
*/
|
||||
function compileEachExpr(node: EachNode): string {
|
||||
const item = node.item;
|
||||
const index = node.index ?? "__wi";
|
||||
const body = node.body.map(renderLoopBody).join("");
|
||||
const empty = node.empty.map(renderLoopBody).join("");
|
||||
return (
|
||||
"${(() => { const __wl = Array.isArray(" +
|
||||
node.list +
|
||||
") ? (" +
|
||||
node.list +
|
||||
") : []; return __wl.length ? __wl.map((" +
|
||||
item +
|
||||
", " +
|
||||
index +
|
||||
") => `" +
|
||||
body +
|
||||
'`).join("") : `' +
|
||||
empty +
|
||||
"`; })()}"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a `{#if}` block to a `${…}` template-literal interpolation: a nested ternary
|
||||
* that renders the first truthy branch's body (or the `{:else}` body, or "" when neither).
|
||||
* Conditions are JS expressions evaluated in the surrounding server scope.
|
||||
*/
|
||||
function compileIfExpr(node: IfNode): string {
|
||||
let expr = "``"; // no matching branch → empty string
|
||||
for (let k = node.branches.length - 1; k >= 0; k--) {
|
||||
const b = node.branches[k]!;
|
||||
const bodySrc = "`" + b.body.map(renderLoopBody).join("") + "`";
|
||||
expr = b.cond === null ? bodySrc : "(" + b.cond + ") ? " + bodySrc + " : " + expr;
|
||||
}
|
||||
return "${" + expr + "}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect every server-control expression in a view (recursively): `{#each}` list
|
||||
* expressions and `{#if}` conditions. Used to wire up raw SSR data consts.
|
||||
*/
|
||||
function collectControlExprs(nodes: ViewNode[], out: string[] = []): string[] {
|
||||
for (const node of nodes) {
|
||||
if (node.type === "each") {
|
||||
out.push(node.list);
|
||||
collectControlExprs(node.body, out);
|
||||
collectControlExprs(node.empty, out);
|
||||
} else if (node.type === "if") {
|
||||
for (const b of node.branches) {
|
||||
if (b.cond) out.push(b.cond);
|
||||
collectControlExprs(b.body, out);
|
||||
}
|
||||
} else if (node.type === "element") {
|
||||
collectControlExprs(node.children, out);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderNode(
|
||||
node: ViewNode,
|
||||
ssrBindings: SsrBinding[],
|
||||
csrBindings: CsrBinding[],
|
||||
apiBindings: Map<string, NamedDataBinding>,
|
||||
loops: string[],
|
||||
reactive: PageReactive | null = null,
|
||||
): string {
|
||||
if (node.type === "text") return substituteReactiveText(node.value, reactive); // {t:key} + state baking
|
||||
|
||||
// Server control block (loop / conditional) → a sentinel that survives
|
||||
// templateEscape, swapped for its real `${…}` code after escaping.
|
||||
if (node.type === "each" || node.type === "if") {
|
||||
loops.push(node.type === "each" ? compileEachExpr(node) : compileIfExpr(node));
|
||||
return `\x00WRNEACH${loops.length - 1}\x00`;
|
||||
}
|
||||
|
||||
const apiName = attrValue(node.attrs, "api");
|
||||
const apiBinding = apiName ? apiBindings.get(apiName) : undefined;
|
||||
if (apiName && !apiBinding) {
|
||||
throw new Error(`Unknown .wrn api binding "${apiName}"`);
|
||||
}
|
||||
|
||||
const ssrGet = attrValue(node.attrs, "ssrGet");
|
||||
const ssrText = attrValue(node.attrs, "ssrText");
|
||||
const csrGet = attrValue(node.attrs, "csrGet");
|
||||
const csrText = attrValue(node.attrs, "csrText");
|
||||
|
||||
const csrId =
|
||||
apiBinding?.mode === "client"
|
||||
? csrMarker(csrBindings, renderBinding(apiBinding))
|
||||
: csrGet && csrText
|
||||
? csrMarker(csrBindings, {
|
||||
method: "GET",
|
||||
path: apiRoutePath(csrGet),
|
||||
body: expressionBody(csrText),
|
||||
helpers: "",
|
||||
})
|
||||
: undefined;
|
||||
|
||||
// Void elements (<br>, <img>, …) have no closing tag and no children.
|
||||
if (VOID_ELEMENTS.has(node.tag.toLowerCase())) {
|
||||
return `<${node.tag}${renderAttrs(node.attrs, csrId)}>`;
|
||||
}
|
||||
|
||||
const inner =
|
||||
apiBinding?.mode === "ssr"
|
||||
? ssrMarker(ssrBindings, renderBinding(apiBinding))
|
||||
: ssrGet && ssrText
|
||||
? ssrMarker(ssrBindings, {
|
||||
method: "GET",
|
||||
path: apiRoutePath(ssrGet),
|
||||
body: expressionBody(ssrText),
|
||||
helpers: "",
|
||||
})
|
||||
: node.children
|
||||
.map((child) =>
|
||||
renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive),
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `<${node.tag}${renderAttrs(node.attrs, csrId)}>${inner}</${node.tag}>`;
|
||||
}
|
||||
|
||||
function ssrMarker(bindings: SsrBinding[], binding: RenderBinding): string {
|
||||
const marker = `<!--wrnexus-ssr:${bindings.length}-->`;
|
||||
bindings.push({ marker, ...binding });
|
||||
return marker;
|
||||
}
|
||||
|
||||
function csrMarker(bindings: CsrBinding[], binding: RenderBinding): string {
|
||||
const id = String(bindings.length);
|
||||
bindings.push({ id, ...binding });
|
||||
return id;
|
||||
}
|
||||
|
||||
function renderBinding(binding: NamedDataBinding): RenderBinding {
|
||||
return {
|
||||
method: binding.method,
|
||||
path: binding.path,
|
||||
body: binding.body,
|
||||
helpers: binding.helpers,
|
||||
};
|
||||
}
|
||||
|
||||
function hasClientBehavior(nodes: ViewNode[]): boolean {
|
||||
return nodes.some((node) => {
|
||||
// `{t:key}` is i18n sugar resolved server-side — not client reactivity.
|
||||
if (node.type === "text") return /\{(?!t:)[^{}]+\}/.test(node.value);
|
||||
// Server control blocks render on the server; they don't add client reactivity.
|
||||
if (node.type === "each" || node.type === "if") return false;
|
||||
return (
|
||||
node.attrs.some((attr) => attr.event || attr.name === "csrGet" || attr.name === "csrText") ||
|
||||
hasClientBehavior(node.children)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function apiRoutePath(path: string): string {
|
||||
const trimmed = path.trim();
|
||||
if (!trimmed.startsWith("/")) {
|
||||
throw new Error(`.wrn API paths must start with "/": ${path}`);
|
||||
}
|
||||
if (trimmed.includes("\0") || trimmed.includes("\\") || /(^|\/)\.\.(\/|$)/.test(trimmed)) {
|
||||
throw new Error(`Unsafe .wrn API path: ${path}`);
|
||||
}
|
||||
if (trimmed === "/api" || trimmed.startsWith("/api/")) return trimmed;
|
||||
return `/api${trimmed}`;
|
||||
}
|
||||
|
||||
function expressionBody(expr: string): string {
|
||||
return `return (${expr});`;
|
||||
}
|
||||
|
||||
function dataBody(source: string): string {
|
||||
const trimmed = source.trim();
|
||||
if (!trimmed) return "return undefined;";
|
||||
return /\breturn\b/.test(trimmed) ? trimmed : expressionBody(trimmed);
|
||||
}
|
||||
|
||||
function modeHelpers(ast: PageAst, mode: DataMode, sharedHelpers: string): string {
|
||||
return [
|
||||
sharedHelpers,
|
||||
...ast.modeFunctions
|
||||
.filter((block) => block.mode === mode)
|
||||
.map((block) => block.body.trim())
|
||||
.filter(Boolean),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
function apiBindingMap(ast: PageAst, sharedHelpers: string): Map<string, NamedDataBinding> {
|
||||
const bindings = new Map<string, NamedDataBinding>();
|
||||
|
||||
for (const block of ast.dataApis) {
|
||||
if (bindings.has(block.name)) {
|
||||
throw new Error(`Duplicate .wrn api binding "${block.name}"`);
|
||||
}
|
||||
bindings.set(block.name, {
|
||||
mode: block.mode,
|
||||
method: block.method,
|
||||
path: apiRoutePath(block.path),
|
||||
body: dataBody(block.body),
|
||||
helpers: modeHelpers(ast, block.mode, sharedHelpers),
|
||||
});
|
||||
}
|
||||
|
||||
return bindings;
|
||||
}
|
||||
|
||||
function ssrRuntimeSource(): string {
|
||||
return `const __wrnexusHtmlEscapes = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" };
|
||||
function __wrnexusEscapeHtml(value: unknown): string {
|
||||
return String(value).replace(/[&<>"']/g, (ch) => __wrnexusHtmlEscapes[ch] ?? ch);
|
||||
}
|
||||
|
||||
function __wrnexusEvalData(data: unknown, body: string, helpers = "", ctx: any): unknown {
|
||||
const adapters = {
|
||||
cookies: ctx.cookies,
|
||||
session: ctx.session,
|
||||
localStorage: ctx.localStorage,
|
||||
};
|
||||
return new Function("$data", "$adapters", "const cookies = $adapters.cookies;\\nconst session = $adapters.session;\\nconst localStorage = $adapters.localStorage;\\nwith ($data ?? {}) {\\n" + helpers + "\\n" + body + "\\n}")(data, adapters);
|
||||
}
|
||||
|
||||
async function __wrnexusCallApi(path: string, method: string, ctx: any): Promise<unknown> {
|
||||
if (typeof ctx.__wrnexusCallApi === "function") {
|
||||
return await ctx.__wrnexusCallApi(path, method);
|
||||
}
|
||||
|
||||
const url = new URL(path, ctx.req.url);
|
||||
const res = await fetch(new Request(url, { method, headers: ctx.req.headers }));
|
||||
if (!res.ok) {
|
||||
throw new Error(".wrn data API request failed with status " + res.status);
|
||||
}
|
||||
|
||||
const type = res.headers.get("content-type") || "";
|
||||
return type.includes("application/json") ? await res.json() : await res.text();
|
||||
}
|
||||
|
||||
async function __wrnexusRenderSsrBindings(html: string, ctx: any): Promise<string> {
|
||||
for (const binding of __wrnexusSsrBindings) {
|
||||
const data = await __wrnexusCallApi(binding.path, binding.method, ctx);
|
||||
const value = __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
|
||||
html = html.replace(binding.marker, __wrnexusEscapeHtml(value));
|
||||
}
|
||||
return html;
|
||||
}`;
|
||||
}
|
||||
|
||||
export function generate(ast: PageAst): string {
|
||||
if (ast.kind === "component") return generateComponent(ast);
|
||||
|
||||
const out: string[] = [];
|
||||
const ssrBindings: SsrBinding[] = [];
|
||||
const csrBindings: CsrBinding[] = [];
|
||||
const helpers = ast.functions
|
||||
.map((body) => body.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
const apiBindings = apiBindingMap(ast, helpers);
|
||||
|
||||
if (helpers) {
|
||||
out.push(`// --- .wrn functions ---\n${helpers}`);
|
||||
}
|
||||
|
||||
// --- Page metadata / SEO ---
|
||||
out.push(`export const meta = ${JSON.stringify({ title: ast.name, ...ast.seo }, null, 2)};`);
|
||||
if (ast.layout) out.push(`export const layout = ${JSON.stringify(ast.layout)};`);
|
||||
|
||||
// --- View -> default page component ---
|
||||
const reactive: PageReactive | null =
|
||||
ast.states.length > 0
|
||||
? { stateNames: new Set(ast.states.map((s) => s.name)), scope: evalStateSeeds(ast.states) }
|
||||
: null;
|
||||
const loops: string[] = [];
|
||||
let html = ast.view
|
||||
.map((node) => renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive))
|
||||
.join("");
|
||||
const styles = ast.styles.map((body) => body.trim()).filter(Boolean);
|
||||
const needsClientRuntime = ast.states.length > 0 || hasClientBehavior(ast.view);
|
||||
|
||||
if (needsClientRuntime) {
|
||||
const scope = ast.states.map((s) => `${s.name}: ${s.expr}`).join(", ");
|
||||
html = `<div data-scope="${attrEscape(scope)}">${html}</div>`;
|
||||
}
|
||||
|
||||
if (styles.length > 0) {
|
||||
const css = styles.map(styleEscape).join("\n");
|
||||
html = `<style data-wrnexus-style="${attrEscape(ast.name)}">\n${css}\n</style>${html}`;
|
||||
}
|
||||
if (csrBindings.length > 0) {
|
||||
out.push(`export const __wrnexusCsr = ${JSON.stringify(csrBindings, null, 2)};`);
|
||||
}
|
||||
|
||||
// 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);
|
||||
loops.forEach((code, idx) => {
|
||||
body = body.replace(`\x00WRNEACH${idx}\x00`, () => code);
|
||||
});
|
||||
|
||||
// Server loops iterate raw SSR data. Declare a named const for every `ssr` data
|
||||
// binding a loop references, so `{#each <name> as …}` can iterate the real value.
|
||||
const loopConsts: string[] = [];
|
||||
if (loops.length > 0) {
|
||||
const lists = collectControlExprs(ast.view);
|
||||
for (const [name, binding] of apiBindings) {
|
||||
if (binding.mode !== "ssr") continue;
|
||||
if (!lists.some((expr) => new RegExp(`\\b${name}\\b`).test(expr))) continue;
|
||||
loopConsts.push(
|
||||
` const ${name} = __wrnexusEvalData(await __wrnexusCallApi(${JSON.stringify(binding.path)}, ${JSON.stringify(binding.method)}, ctx), ${JSON.stringify(binding.body)}, ${JSON.stringify(binding.helpers)}, ctx);`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0;
|
||||
if (needsSsrRuntime) {
|
||||
out.push(ssrRuntimeSource());
|
||||
out.push(`const __wrnexusSsrBindings = ${JSON.stringify(ssrBindings, null, 2)};`);
|
||||
const decls = loopConsts.length > 0 ? loopConsts.join("\n") + "\n" : "";
|
||||
out.push(
|
||||
`export default async function ${ast.name}(ctx: any) {\n${decls} const html = \`${body}\`;\n return await __wrnexusRenderSsrBindings(html, ctx);\n}`,
|
||||
);
|
||||
} else {
|
||||
out.push(`export default function ${ast.name}() {\n return \`${body}\`;\n}`);
|
||||
}
|
||||
|
||||
// --- API blocks -> method handlers ---
|
||||
if (ast.apis.length > 0) {
|
||||
ast.apis.forEach((api, index) => {
|
||||
const name = `__wrnexusApi_${api.method}_${index}`;
|
||||
out.push(`// ${api.method} ${apiRoutePath(api.path)}
|
||||
const ${name} = async (ctx: any) => {${api.body}};`);
|
||||
});
|
||||
|
||||
const entries = ast.apis.map(
|
||||
(api, index) =>
|
||||
` ${JSON.stringify(`${api.method} ${apiRoutePath(api.path)}`)}: __wrnexusApi_${api.method}_${index},`,
|
||||
);
|
||||
out.push(`export const __wrnexusApi = {\n${entries.join("\n")}\n};`);
|
||||
|
||||
const exported = new Set<string>();
|
||||
ast.apis.forEach((api, index) => {
|
||||
if (exported.has(api.method)) return;
|
||||
exported.add(api.method);
|
||||
out.push(`export const ${api.method} = __wrnexusApi_${api.method}_${index};`);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Realtime blocks -> a websocket export ---
|
||||
if (ast.realtimes.length > 0) {
|
||||
const handlers = ast.realtimes.flatMap((rt) =>
|
||||
rt.handlers.map((h) => {
|
||||
const params = ["ws", ...h.args].join(", ");
|
||||
return ` ${h.event}(${params}: any) {${h.body}},`;
|
||||
}),
|
||||
);
|
||||
out.push(`export const websocket = {\n${handlers.join("\n")}\n};`);
|
||||
}
|
||||
|
||||
return out.join("\n\n") + "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Lower a `component` AST to a module exporting `render(props)`.
|
||||
*
|
||||
* A component is server-rendered on demand at each `data-component` mount and
|
||||
* hydrated on the browser by the generic reactive runtime — it ships no JS of
|
||||
* its own. Declared props are coerced to the type of their default value, then
|
||||
* seeded (with any `state`) into the `data-scope` the reactive runtime reads.
|
||||
*/
|
||||
interface CompCtx {
|
||||
/** State names — text referencing any of them stays a reactive client mustache. */
|
||||
stateNames: Set<string>;
|
||||
/** Rewrite reserved-word prop/state identifiers to their safe const names. */
|
||||
resolveExpr: (expr: string) => string;
|
||||
/** `data-for` loop variables in scope — their mustaches stay literal for the
|
||||
* client's list renderer (never baked server-side, since they have no value). */
|
||||
loopVars?: Set<string>;
|
||||
}
|
||||
|
||||
/** Parse a `data-for="item in list"` / `"item, i in list"` directive value. */
|
||||
export function parseForExpr(value: string): { item: string; index?: string; list: string } | null {
|
||||
const m = /^\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*)\s*)?\s+in\s+([\s\S]+?)\s*$/.exec(
|
||||
value,
|
||||
);
|
||||
if (!m) return null;
|
||||
return { item: m[1]!, index: m[2], list: m[3]! };
|
||||
}
|
||||
|
||||
/** The loop variables a node introduces via `data-for`, if any. */
|
||||
function loopVarsOf(node: ViewNode): string[] {
|
||||
if (node.type !== "element") return [];
|
||||
const attr = node.attrs.find((a) => !a.event && a.name === "data-for");
|
||||
if (!attr) return [];
|
||||
const parsed = parseForExpr(attr.value);
|
||||
return parsed ? [parsed.item, ...(parsed.index ? [parsed.index] : [])] : [];
|
||||
}
|
||||
|
||||
/** JS reserved words that cannot be used as a plain `const` name. */
|
||||
const JS_RESERVED = new Set([
|
||||
"class",
|
||||
"for",
|
||||
"default",
|
||||
"function",
|
||||
"return",
|
||||
"if",
|
||||
"else",
|
||||
"new",
|
||||
"delete",
|
||||
"typeof",
|
||||
"in",
|
||||
"instanceof",
|
||||
"void",
|
||||
"do",
|
||||
"while",
|
||||
"switch",
|
||||
"case",
|
||||
"break",
|
||||
"continue",
|
||||
"this",
|
||||
"super",
|
||||
"import",
|
||||
"export",
|
||||
"extends",
|
||||
"var",
|
||||
"let",
|
||||
"const",
|
||||
"null",
|
||||
"true",
|
||||
"false",
|
||||
"try",
|
||||
"catch",
|
||||
"finally",
|
||||
"throw",
|
||||
"yield",
|
||||
"await",
|
||||
"enum",
|
||||
"with",
|
||||
"debugger",
|
||||
]);
|
||||
|
||||
/** A JS reference for a prop/state name (reserved words get a `__p_` prefix). */
|
||||
function safeRef(name: string): string {
|
||||
return JS_RESERVED.has(name) ? `__p_${name}` : name;
|
||||
}
|
||||
|
||||
/** Escape a literal segment so it is safe inside a JS template literal. */
|
||||
function escLit(s: string): string {
|
||||
return s.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
|
||||
}
|
||||
|
||||
const INTERP_RE = /\{([^{}]+)\}/g;
|
||||
|
||||
function exprRefsState(expr: string, stateNames: Set<string>): boolean {
|
||||
for (const name of stateNames) {
|
||||
if (new RegExp(`\\b${name}\\b`).test(expr)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function viewHasEvents(nodes: ViewNode[]): boolean {
|
||||
return nodes.some(
|
||||
(n) => n.type === "element" && (n.attrs.some((a) => a.event) || viewHasEvents(n.children)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a text node. Interpolations that reference state stay as client
|
||||
* mustaches (`{expr}`, hydrated by the reactive runtime); interpolations of
|
||||
* props/constants are baked server-side (`${__wireHtml(expr)}`), so static
|
||||
* components render correct HTML with zero JavaScript.
|
||||
*/
|
||||
function compileText(raw: string, ctx: CompCtx): string {
|
||||
let out = "";
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
INTERP_RE.lastIndex = 0;
|
||||
while ((m = INTERP_RE.exec(raw))) {
|
||||
out += escLit(raw.slice(last, m.index));
|
||||
const expr = m[1]!.trim();
|
||||
if (expr.startsWith("t:")) {
|
||||
// i18n sugar: {t:key} → a marker resolved server-side by translateHtml.
|
||||
out += escLit(`<span data-t="${attrEscape(expr.slice(2).trim())}"></span>`);
|
||||
} else if (ctx.loopVars && exprRefsState(expr, ctx.loopVars)) {
|
||||
// Loop variable (from data-for): leave a literal client mustache — the
|
||||
// list renderer fills it per item; it has no server-side value.
|
||||
out += escLit(`{${expr}}`);
|
||||
} else if (exprRefsState(expr, ctx.stateNames)) {
|
||||
// State interpolation: bake the initial value AND keep it reactive via a
|
||||
// data-text span, so no-JS clients see the real value and hydration
|
||||
// updates it in place. `count` → `<span data-text="count">0</span>`.
|
||||
out +=
|
||||
escLit(`<span data-text="${attrEscape(expr)}">`) +
|
||||
`\${__wireHtml(${ctx.resolveExpr(expr)})}` +
|
||||
escLit(`</span>`);
|
||||
} else {
|
||||
out += `\${__wireHtml(${ctx.resolveExpr(expr)})}`;
|
||||
}
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
return out + escLit(raw.slice(last));
|
||||
}
|
||||
|
||||
/** Compile an attribute value; `{expr}` is baked server-side (loop vars stay literal). */
|
||||
function compileAttrValue(raw: string, ctx: CompCtx): string {
|
||||
if (!raw.includes("{")) return escLit(attrEscape(raw));
|
||||
let out = "";
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
INTERP_RE.lastIndex = 0;
|
||||
while ((m = INTERP_RE.exec(raw))) {
|
||||
out += escLit(attrEscape(raw.slice(last, m.index)));
|
||||
const expr = m[1]!.trim();
|
||||
if (ctx.loopVars && exprRefsState(expr, ctx.loopVars)) {
|
||||
out += escLit(`{${expr}}`); // hydrated per-item by the list renderer
|
||||
} else {
|
||||
out += `\${__wireAttr(${ctx.resolveExpr(expr)})}`;
|
||||
}
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
return out + escLit(attrEscape(raw.slice(last)));
|
||||
}
|
||||
|
||||
/** Render a component view node into template-literal-ready source. */
|
||||
function renderComponentNode(node: ViewNode, ctx: CompCtx): string {
|
||||
if (node.type === "text") return compileText(node.value, ctx);
|
||||
if (node.type === "each" || node.type === "if") {
|
||||
throw new Error(
|
||||
"Server `{#each}` / `{#if}` blocks are supported in pages, not components. Move them into a page (or use data-for / data-show on the client).",
|
||||
);
|
||||
}
|
||||
|
||||
const attrs = node.attrs
|
||||
.map((a) =>
|
||||
a.event
|
||||
? ` ${eventAttribute(a.name)}="${compileAttrValue(a.value, ctx)}"`
|
||||
: a.boolean
|
||||
? ` ${a.name}`
|
||||
: ` ${a.name}="${compileAttrValue(a.value, ctx)}"`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
// A `data-for` element introduces loop variables for its subtree.
|
||||
const loops = loopVarsOf(node);
|
||||
const childCtx =
|
||||
loops.length > 0 ? { ...ctx, loopVars: new Set([...(ctx.loopVars ?? []), ...loops]) } : ctx;
|
||||
|
||||
if (VOID_ELEMENTS.has(node.tag.toLowerCase())) return `<${node.tag}${attrs}>`;
|
||||
const inner = node.children.map((c) => renderComponentNode(c, childCtx)).join("");
|
||||
return `<${node.tag}${attrs}>${inner}</${node.tag}>`;
|
||||
}
|
||||
|
||||
function generateComponent(ast: PageAst): string {
|
||||
const out: string[] = [];
|
||||
|
||||
const stateNames = new Set(ast.states.map((s) => s.name));
|
||||
const nameRefs = new Map<string, string>();
|
||||
for (const p of ast.props) nameRefs.set(p.name, safeRef(p.name));
|
||||
for (const s of ast.states) nameRefs.set(s.name, safeRef(s.name));
|
||||
const resolveExpr = (expr: string): string => {
|
||||
let result = expr;
|
||||
for (const [name, ref] of nameRefs) {
|
||||
if (name !== ref) result = result.replace(new RegExp(`\\b${name}\\b`, "g"), ref);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const ctx: CompCtx = { stateNames, resolveExpr };
|
||||
|
||||
const viewCode = ast.view.map((node) => renderComponentNode(node, ctx)).join("");
|
||||
const styles = ast.styles.map((body) => body.trim()).filter(Boolean);
|
||||
const styleTag =
|
||||
styles.length > 0
|
||||
? escLit(
|
||||
`<style data-wrnexus-style="${attrEscape(ast.name)}">\n${styles.map(styleEscape).join("\n")}\n</style>`,
|
||||
)
|
||||
: "";
|
||||
|
||||
// A component needs a reactive scope only when it has state or event handlers.
|
||||
// Prop-driven text/attributes are baked server-side, so static components ship
|
||||
// no JavaScript at all.
|
||||
const needsScope = ast.states.length > 0 || viewHasEvents(ast.view);
|
||||
const scopeKeys = [...ast.props.map((p) => p.name), ...ast.states.map((s) => s.name)];
|
||||
|
||||
const decls: string[] = [];
|
||||
for (const prop of ast.props) {
|
||||
decls.push(
|
||||
` const ${nameRefs.get(prop.name)} = __coerce(__p[${JSON.stringify(prop.name)}], (${resolveExpr(prop.default)}));`,
|
||||
);
|
||||
}
|
||||
for (const state of ast.states) {
|
||||
decls.push(` const ${nameRefs.get(state.name)} = (${resolveExpr(state.expr)});`);
|
||||
}
|
||||
|
||||
const returnExpr = needsScope
|
||||
? "`" + styleTag + '<div data-scope="${__scope}">' + viewCode + "</div>`"
|
||||
: "`" + styleTag + viewCode + "`";
|
||||
|
||||
const scopeLine =
|
||||
needsScope && scopeKeys.length > 0
|
||||
? ` const __scope = __wrnexusScopeDecl({ ${scopeKeys.map((k) => `${JSON.stringify(k)}: ${nameRefs.get(k)}`).join(", ")} });\n`
|
||||
: needsScope
|
||||
? ` const __scope = "";\n`
|
||||
: "";
|
||||
|
||||
out.push(`export const __wrnexusComponent = ${JSON.stringify(ast.name)};`);
|
||||
out.push(`function __coerce(v: any, def: any): any {
|
||||
if (v === undefined || v === null) return def;
|
||||
if (typeof def === "number") return Number(v);
|
||||
if (typeof def === "boolean") return v === true || v === "" || v === "true";
|
||||
return String(v);
|
||||
}
|
||||
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 === ">" ? ">" : """,
|
||||
);
|
||||
}`);
|
||||
|
||||
if (needsScope) {
|
||||
out.push(`function __wrnexusScopeDecl(obj: Record<string, any>): string {
|
||||
const lit = (v: any) =>
|
||||
typeof v === "number" || typeof v === "boolean"
|
||||
? String(v)
|
||||
: "'" + String(v).replace(/\\\\/g, "\\\\\\\\").replace(/'/g, "\\\\'").replace(/\\n/g, "\\\\n") + "'";
|
||||
return Object.keys(obj)
|
||||
.map((k) => k + ": " + lit(obj[k]))
|
||||
.join(", ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}`);
|
||||
}
|
||||
|
||||
out.push(
|
||||
`export function render(props: Record<string, any> = {}): string {\n` +
|
||||
` const __p = props || {};\n` +
|
||||
(decls.length > 0 ? decls.join("\n") + "\n" : "") +
|
||||
scopeLine +
|
||||
` return ${returnExpr};\n` +
|
||||
`}`,
|
||||
);
|
||||
|
||||
return out.join("\n\n") + "\n";
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @wrnexus/compiler — the `.wrn` language compiler.
|
||||
*
|
||||
* Pipeline: source ──▶ Lexer ──▶ parse() ──▶ AST ──▶ generate() ──▶ TypeScript
|
||||
*
|
||||
* See VISION.md for the language design. The MVP supports `page` with `state`,
|
||||
* `view`, `api`, and `realtime` blocks, lowering to the framework's primitives.
|
||||
*/
|
||||
|
||||
import { parse, ParseError, type PageAst } from "./parser.ts";
|
||||
import { generate } from "./codegen.ts";
|
||||
import { generateNative } from "./native-codegen.ts";
|
||||
|
||||
export { parse, ParseError } from "./parser.ts";
|
||||
export { generate } from "./codegen.ts";
|
||||
export { generateNative, NativeCompileError } from "./native-codegen.ts";
|
||||
export { Lexer, LexError } from "./tokenizer.ts";
|
||||
export type {
|
||||
PageAst,
|
||||
SeoBlock,
|
||||
ViewNode,
|
||||
Attr,
|
||||
StateDecl,
|
||||
ApiBlock,
|
||||
DataApiBlock,
|
||||
DataMode,
|
||||
ModeFunctionsBlock,
|
||||
RealtimeBlock,
|
||||
} from "./parser.ts";
|
||||
|
||||
export interface CompileResult {
|
||||
code: string;
|
||||
ast: PageAst;
|
||||
diagnostics: string[];
|
||||
}
|
||||
|
||||
/** Compile `.wrn` source into an Expo Router React Native screen. */
|
||||
export function compileNativeWireFile(source: string): string {
|
||||
return generateNative(parse(source));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile `.wrn` source into TypeScript source. Throws `ParseError` on invalid
|
||||
* input (the dev loader surfaces this as a readable error page).
|
||||
*/
|
||||
export function compileWireFile(source: string): string {
|
||||
const ast = parse(source);
|
||||
return `// compiled from .wrn\n${generate(ast)}`;
|
||||
}
|
||||
|
||||
/** Richer entry point returning the AST and diagnostics alongside the code. */
|
||||
export function compile(source: string): CompileResult {
|
||||
const diagnostics: string[] = [];
|
||||
try {
|
||||
const ast = parse(source);
|
||||
return { code: `// compiled from .wrn\n${generate(ast)}`, ast, diagnostics };
|
||||
} catch (err) {
|
||||
if (err instanceof ParseError) diagnostics.push(err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import type { Attr, PageAst, ViewNode } from "./parser.ts";
|
||||
|
||||
export class NativeCompileError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "NativeCompileError";
|
||||
}
|
||||
}
|
||||
|
||||
const tagMap: Record<string, string> = {
|
||||
div: "View",
|
||||
main: "View",
|
||||
section: "View",
|
||||
article: "View",
|
||||
nav: "View",
|
||||
header: "View",
|
||||
footer: "View",
|
||||
aside: "View",
|
||||
form: "View",
|
||||
ul: "View",
|
||||
ol: "View",
|
||||
li: "View",
|
||||
p: "Text",
|
||||
span: "Text",
|
||||
strong: "Text",
|
||||
em: "Text",
|
||||
small: "Text",
|
||||
label: "Text",
|
||||
h1: "Text",
|
||||
h2: "Text",
|
||||
h3: "Text",
|
||||
h4: "Text",
|
||||
h5: "Text",
|
||||
h6: "Text",
|
||||
button: "Pressable",
|
||||
a: "Pressable",
|
||||
input: "TextInput",
|
||||
textarea: "TextInput",
|
||||
img: "Image",
|
||||
view: "View",
|
||||
text: "Text",
|
||||
pressable: "Pressable",
|
||||
textinput: "TextInput",
|
||||
image: "Image",
|
||||
scrollview: "ScrollView",
|
||||
safeareaview: "SafeAreaView",
|
||||
flatlist: "FlatList",
|
||||
activityindicator: "ActivityIndicator",
|
||||
};
|
||||
|
||||
const attrMap: Record<string, string> = {
|
||||
class: "style",
|
||||
className: "style",
|
||||
src: "source",
|
||||
alt: "accessibilityLabel",
|
||||
placeholder: "placeholder",
|
||||
disabled: "disabled",
|
||||
value: "value",
|
||||
href: "__href",
|
||||
"aria-label": "accessibilityLabel",
|
||||
};
|
||||
|
||||
function expression(value: string): string | null {
|
||||
const exact = /^\{([\s\S]+)\}$/.exec(value.trim());
|
||||
return exact?.[1]?.trim() ?? null;
|
||||
}
|
||||
|
||||
function textJsx(value: string): string {
|
||||
const pieces: string[] = [];
|
||||
let last = 0;
|
||||
for (const match of value.matchAll(/\{([^{}]+)\}/g)) {
|
||||
if (match.index! > last) pieces.push(value.slice(last, match.index));
|
||||
const expr = match[1]!.trim();
|
||||
pieces.push(expr.startsWith("t:") ? `{${JSON.stringify(expr.slice(2).trim())}}` : `{${expr}}`);
|
||||
last = match.index! + match[0].length;
|
||||
}
|
||||
pieces.push(value.slice(last));
|
||||
return pieces.join("").replace(/([<>])/g, (char) => (char === "<" ? "<" : ">"));
|
||||
}
|
||||
|
||||
function eventBody(value: string, states: Set<string>): string {
|
||||
let body = expression(value) ?? value;
|
||||
for (const state of states) {
|
||||
const cap = state[0]!.toUpperCase() + state.slice(1);
|
||||
body = body
|
||||
.replace(new RegExp(`\\b${state}\\+\\+`, "g"), `set${cap}(value => value + 1)`)
|
||||
.replace(new RegExp(`\\b${state}--`, "g"), `set${cap}(value => value - 1)`)
|
||||
.replace(new RegExp(`\\b${state}\\s*=\\s*([^;]+)`, "g"), `set${cap}($1)`);
|
||||
}
|
||||
return `() => { ${body} }`;
|
||||
}
|
||||
|
||||
function renderAttrs(attrs: Attr[], states: Set<string>): string {
|
||||
return attrs
|
||||
.map((attr) => {
|
||||
if (attr.event) {
|
||||
if (attr.name.startsWith("browser-")) return "";
|
||||
const eventName = attr.name.startsWith("mobile-") ? attr.name.slice(7) : attr.name;
|
||||
const event =
|
||||
eventName === "click" || eventName === "press"
|
||||
? "onPress"
|
||||
: eventName === "input" || eventName === "change"
|
||||
? "onChangeText"
|
||||
: `on${eventName[0]!.toUpperCase()}${eventName.slice(1)}`;
|
||||
return ` ${event}={${eventBody(attr.value, states)}}`;
|
||||
}
|
||||
if (attr.name === "data-native-browser" || attr.name.startsWith("data-native-on-browser-"))
|
||||
return "";
|
||||
if (
|
||||
attr.name === "data-native-options" ||
|
||||
attr.name === "data-native-only" ||
|
||||
attr.name === "data-native-requires" ||
|
||||
attr.name === "data-native-unsupported"
|
||||
)
|
||||
return "";
|
||||
if (attr.name === "data-native-mobile") {
|
||||
throw new NativeCompileError(
|
||||
`Declarative native capability "${attr.value}" currently targets browser/Capacitor pages. In Expo output, call the installed Expo package from an @mobile-event handler.`,
|
||||
);
|
||||
}
|
||||
const name = attrMap[attr.name] ?? attr.name;
|
||||
if (name === "__href") return ` onPress={() => router.push(${JSON.stringify(attr.value)})}`;
|
||||
if (name === "source") {
|
||||
const expr = expression(attr.value);
|
||||
return ` source={${expr ? `{ uri: ${expr} }` : `{ uri: ${JSON.stringify(attr.value)} }`}}`;
|
||||
}
|
||||
if (name === "style" && attr.name !== "style") {
|
||||
return ` style={[${attr.value
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((value) => `styles[${JSON.stringify(value)}]`)
|
||||
.join(", ")} ]}`;
|
||||
}
|
||||
if (name === "style") {
|
||||
const inlineExpression = expression(attr.value);
|
||||
if (inlineExpression) return ` style={${inlineExpression}}`;
|
||||
throw new NativeCompileError(
|
||||
'Inline CSS strings are not portable to native; use class="name" and a page style block',
|
||||
);
|
||||
}
|
||||
if (attr.boolean) return ` ${name}`;
|
||||
const expr = expression(attr.value);
|
||||
return expr ? ` ${name}={${expr}}` : ` ${name}=${JSON.stringify(attr.value)}`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderNode(node: ViewNode, states: Set<string>, key?: string): string {
|
||||
if (node.type === "text") return textJsx(node.value);
|
||||
if (node.type === "each") {
|
||||
const params = node.index ? `${node.item}, ${node.index}` : `${node.item}, __index`;
|
||||
const body = node.body
|
||||
.map((child, index) =>
|
||||
renderNode(child, states, index === 0 ? (node.index ?? "__index") : undefined),
|
||||
)
|
||||
.join("");
|
||||
const empty = node.empty.map((child) => renderNode(child, states)).join("");
|
||||
return `{(${node.list})?.length ? (${node.list}).map((${params}) => <>${body}</>) : <>${empty}</>}`;
|
||||
}
|
||||
if (node.type === "if") {
|
||||
const result = node.branches.reduceRight(
|
||||
(fallback, branch) =>
|
||||
branch.cond === null
|
||||
? `<>${branch.body.map((child) => renderNode(child, states)).join("")}</>`
|
||||
: `(${branch.cond}) ? <>${branch.body.map((child) => renderNode(child, states)).join("")}</> : ${fallback}`,
|
||||
"null",
|
||||
);
|
||||
return `{${result}}`;
|
||||
}
|
||||
const nativeOnly = node.attrs.find(
|
||||
(attr) => !attr.event && attr.name === "data-native-only",
|
||||
)?.value;
|
||||
if (nativeOnly === "browser" || nativeOnly === "web") return "";
|
||||
const nativeTag =
|
||||
tagMap[node.tag.toLowerCase()] ?? (/^[A-Z]/.test(node.tag) ? node.tag : undefined);
|
||||
if (!nativeTag)
|
||||
throw new NativeCompileError(`HTML element <${node.tag}> has no native equivalent`);
|
||||
const attrs = renderAttrs(node.attrs, states) + (key ? ` key={${key}}` : "");
|
||||
if (nativeTag === "TextInput" || nativeTag === "Image" || nativeTag === "ActivityIndicator")
|
||||
return `<${nativeTag}${attrs} />`;
|
||||
const children = node.children
|
||||
.map((child) => {
|
||||
if (child.type !== "text") return renderNode(child, states);
|
||||
if (!child.value.trim()) return "";
|
||||
const text = textJsx(child.value);
|
||||
return nativeTag === "Text" ? text : `<Text>${text}</Text>`;
|
||||
})
|
||||
.join("");
|
||||
return `<${nativeTag}${attrs}>${children}</${nativeTag}>`;
|
||||
}
|
||||
|
||||
function nativeStyles(blocks: string[]): string {
|
||||
const entries: string[] = [];
|
||||
for (const block of blocks) {
|
||||
for (const match of block.matchAll(/\.([A-Za-z_][\w-]*)\s*\{([^}]*)\}/g)) {
|
||||
const props: string[] = [];
|
||||
for (const declaration of match[2]!.split(";")) {
|
||||
const colon = declaration.indexOf(":");
|
||||
if (colon < 0) continue;
|
||||
const name = declaration
|
||||
.slice(0, colon)
|
||||
.trim()
|
||||
.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
|
||||
let value: string | number = declaration.slice(colon + 1).trim();
|
||||
if (/^-?\d+(?:\.\d+)?px$/.test(value)) value = Number(value.slice(0, -2));
|
||||
props.push(
|
||||
`${JSON.stringify(name)}: ${typeof value === "number" ? value : JSON.stringify(value)}`,
|
||||
);
|
||||
}
|
||||
entries.push(`${JSON.stringify(match[1])}: { ${props.join(", ")} }`);
|
||||
}
|
||||
}
|
||||
return `const styles = StyleSheet.create({ ${entries.join(",\n")} });`;
|
||||
}
|
||||
|
||||
/** Compile a parsed `.wrn` page to an Expo Router React Native screen. */
|
||||
export function generateNative(ast: PageAst): string {
|
||||
if (ast.kind !== "page")
|
||||
throw new NativeCompileError("Native route compilation currently accepts page files only");
|
||||
if (ast.dataApis.length)
|
||||
throw new NativeCompileError(
|
||||
"Data API blocks are not yet portable to native screens; fetch through the generated native backend helper",
|
||||
);
|
||||
const states = new Set(ast.states.map((state) => state.name));
|
||||
const hooks = ast.states
|
||||
.map((state) => {
|
||||
const cap = state.name[0]!.toUpperCase() + state.name.slice(1);
|
||||
return ` const [${state.name}, set${cap}] = useState(${state.expr});`;
|
||||
})
|
||||
.join("\n");
|
||||
const body = ast.view.map((node) => renderNode(node, states)).join("");
|
||||
return `// generated from .wrn for Expo/React Native\nimport React, { useState } from "react";\nimport { ActivityIndicator, FlatList, Image, Pressable, SafeAreaView, ScrollView, StyleSheet, Text, TextInput, View } from "react-native";\nimport { useRouter } from "expo-router";\n\nexport default function ${ast.name}() {\n const router = useRouter();\n${hooks}\n return <>${body}</>;\n}\n\n${nativeStyles(ast.styles)}\n`;
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
/**
|
||||
* Recursive-descent parser for `.wrn`, producing a small AST.
|
||||
*
|
||||
* Grammar (subset of the vision, but real):
|
||||
*
|
||||
* page <Name> {
|
||||
* state <ident> = <expr> // zero or more
|
||||
* view { <html> } // plain HTML (see parseHtmlView)
|
||||
* seo { title = "Home" description = "..." }
|
||||
* ssr { api <name> <METHOD> <path> { <render js> } functions { <raw js> } }
|
||||
* client { api <name> <METHOD> <path> { <render js> } functions { <raw js> } }
|
||||
* style { <raw css> } // zero or more, inlined with the page
|
||||
* functions { <raw js> } // zero or more, shared helpers
|
||||
* api <METHOD> <path> { <raw js> } // zero or more
|
||||
* realtime <name> { on <evt>(<args>) { <raw js> } * } // zero or more
|
||||
* }
|
||||
*
|
||||
* The `view` block is written as ordinary HTML — nothing new to learn. Text may
|
||||
* contain `{expr}` interpolation, attributes may be hyphenated (`data-*`), and
|
||||
* `@event="..."` declares a client event binding. See `parseHtmlView`.
|
||||
*/
|
||||
|
||||
import { Lexer, LexError, type Token } from "./tokenizer.ts";
|
||||
|
||||
export interface StateDecl {
|
||||
name: string;
|
||||
/** Raw JS initializer expression, e.g. `0` or `'x'`. */
|
||||
expr: string;
|
||||
}
|
||||
|
||||
export interface Attr {
|
||||
name: string;
|
||||
value: string;
|
||||
/** True for `@event` bindings (vs. plain HTML attributes). */
|
||||
event: boolean;
|
||||
/** True for a valueless boolean attribute, e.g. `<button disabled>`. */
|
||||
boolean?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML void elements: they have no children and no closing tag.
|
||||
* @see https://html.spec.whatwg.org/multipage/syntax.html#void-elements
|
||||
*/
|
||||
export const VOID_ELEMENTS = new Set([
|
||||
"area",
|
||||
"base",
|
||||
"br",
|
||||
"col",
|
||||
"embed",
|
||||
"hr",
|
||||
"img",
|
||||
"input",
|
||||
"link",
|
||||
"meta",
|
||||
"param",
|
||||
"source",
|
||||
"track",
|
||||
"wbr",
|
||||
]);
|
||||
|
||||
export type ViewNode =
|
||||
| { type: "text"; value: string }
|
||||
| { type: "element"; tag: string; attrs: Attr[]; children: ViewNode[] }
|
||||
/**
|
||||
* A server-side loop: `{#each <list> as <item>[, <index>]} …body… {:empty} …empty… {/each}`.
|
||||
* `list` is a JS expression (evaluated on the server, may reference an `ssr` data
|
||||
* binding). The `body` is rendered once per item with `{item.field}` interpolation;
|
||||
* `empty` renders when the list is empty. See codegen `compileEach`.
|
||||
*/
|
||||
| {
|
||||
type: "each";
|
||||
list: string;
|
||||
item: string;
|
||||
index?: string;
|
||||
body: ViewNode[];
|
||||
empty: ViewNode[];
|
||||
}
|
||||
/**
|
||||
* A server-side conditional: `{#if <expr>} … {:else if <expr>} … {:else} … {/if}`.
|
||||
* Rendered branches are chosen on the server. Each branch's `cond` is a JS expression
|
||||
* (`null` for the final `{:else}`); the first truthy branch renders. See `compileIfExpr`.
|
||||
*/
|
||||
| { type: "if"; branches: { cond: string | null; body: ViewNode[] }[] };
|
||||
|
||||
export interface ApiBlock {
|
||||
method: string;
|
||||
path: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export type SeoBlock = Record<string, string>;
|
||||
|
||||
export type DataMode = "ssr" | "client";
|
||||
|
||||
export interface DataApiBlock {
|
||||
mode: DataMode;
|
||||
name: string;
|
||||
method: string;
|
||||
path: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface ModeFunctionsBlock {
|
||||
mode: DataMode;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface RealtimeHandler {
|
||||
event: string;
|
||||
args: string[];
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface RealtimeBlock {
|
||||
name: string;
|
||||
handlers: RealtimeHandler[];
|
||||
}
|
||||
|
||||
export interface PropDecl {
|
||||
name: string;
|
||||
/** Raw JS default expression, e.g. `0` or `'Count'`. Its type drives coercion. */
|
||||
default: string;
|
||||
}
|
||||
|
||||
export interface PageAst {
|
||||
type: "page";
|
||||
/** `page` (a route) or `component` (a reusable, prop-driven fragment). */
|
||||
kind: "page" | "component";
|
||||
name: string;
|
||||
/** Name of the page layout (`app/layouts/<layout>.wrn`), if the page sets one. */
|
||||
layout?: string;
|
||||
/** Declared component props (empty for pages). */
|
||||
props: PropDecl[];
|
||||
states: StateDecl[];
|
||||
seo: SeoBlock;
|
||||
view: ViewNode[];
|
||||
styles: string[];
|
||||
functions: string[];
|
||||
dataApis: DataApiBlock[];
|
||||
modeFunctions: ModeFunctionsBlock[];
|
||||
apis: ApiBlock[];
|
||||
realtimes: RealtimeBlock[];
|
||||
}
|
||||
|
||||
export class ParseError extends Error {}
|
||||
|
||||
function parseSeoBlock(body: string): SeoBlock {
|
||||
const out: SeoBlock = {};
|
||||
const pair =
|
||||
/([A-Za-z][A-Za-z0-9_-]*)\s*=\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|([^\n;]+))/g;
|
||||
for (const match of body.matchAll(pair)) {
|
||||
const key = match[1]!;
|
||||
const rawValue = match[2] ?? match[3] ?? match[4] ?? "";
|
||||
out[key] = unescapeSeoValue(rawValue.trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function unescapeSeoValue(value: string): string {
|
||||
return value.replace(/\\(["'\\nrt])/g, (_match, ch: string) => {
|
||||
if (ch === "n") return "\n";
|
||||
if (ch === "r") return "\r";
|
||||
if (ch === "t") return "\t";
|
||||
return ch;
|
||||
});
|
||||
}
|
||||
|
||||
export function parse(source: string): PageAst {
|
||||
const lx = new Lexer(source);
|
||||
|
||||
const expect = (type: Token["type"]): Token => {
|
||||
const t = lx.next();
|
||||
if (t.type !== type) {
|
||||
throw new ParseError(`Expected ${type} but got '${t.value || t.type}' at offset ${t.pos}`);
|
||||
}
|
||||
return t;
|
||||
};
|
||||
const expectKeyword = (kw: string): void => {
|
||||
const t = lx.next();
|
||||
if (t.type !== "ident" || t.value !== kw) {
|
||||
throw new ParseError(`Expected '${kw}' but got '${t.value || t.type}' at offset ${t.pos}`);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// A file is either a `page` (a route) or a `component` (a reusable fragment).
|
||||
const opener = lx.next();
|
||||
if (opener.type !== "ident" || (opener.value !== "page" && opener.value !== "component")) {
|
||||
throw new ParseError(
|
||||
`Expected 'page' or 'component' but got '${opener.value || opener.type}' at offset ${opener.pos}`,
|
||||
);
|
||||
}
|
||||
const kind: "page" | "component" = opener.value === "component" ? "component" : "page";
|
||||
const name = expect("ident").value;
|
||||
expect("lbrace");
|
||||
|
||||
let layout: string | undefined;
|
||||
const props: PropDecl[] = [];
|
||||
const states: StateDecl[] = [];
|
||||
const seo: SeoBlock = {};
|
||||
const view: ViewNode[] = [];
|
||||
const styles: string[] = [];
|
||||
const functions: string[] = [];
|
||||
const dataApis: DataApiBlock[] = [];
|
||||
const modeFunctions: ModeFunctionsBlock[] = [];
|
||||
const apis: ApiBlock[] = [];
|
||||
const realtimes: RealtimeBlock[] = [];
|
||||
|
||||
while (lx.peek().type !== "rbrace") {
|
||||
const kw = lx.peek();
|
||||
if (kw.type === "eof") throw new ParseError(`Unexpected end of input inside ${kind}`);
|
||||
if (kw.type !== "ident") {
|
||||
throw new ParseError(`Expected a ${kind} member keyword at offset ${kw.pos}`);
|
||||
}
|
||||
switch (kw.value) {
|
||||
case "layout": {
|
||||
// layout = "public" — selects app/layouts/<name>.wrn for this page.
|
||||
lx.next();
|
||||
expect("eq");
|
||||
layout = expect("string").value;
|
||||
break;
|
||||
}
|
||||
case "props": {
|
||||
// props { name = <default> ... } — one declaration per line.
|
||||
lx.next();
|
||||
expect("lbrace");
|
||||
while (lx.peek().type !== "rbrace") {
|
||||
const t = lx.peek();
|
||||
if (t.type === "eof") throw new ParseError("Unexpected end of input inside props");
|
||||
if (t.type !== "ident") {
|
||||
throw new ParseError(`Expected a prop name at offset ${t.pos}`);
|
||||
}
|
||||
const pName = expect("ident").value;
|
||||
expect("eq");
|
||||
props.push({ name: pName, default: lx.readToLineEnd() });
|
||||
}
|
||||
expect("rbrace");
|
||||
break;
|
||||
}
|
||||
case "state": {
|
||||
lx.next();
|
||||
const sName = expect("ident").value;
|
||||
expect("eq");
|
||||
states.push({ name: sName, expr: lx.readToLineEnd() });
|
||||
break;
|
||||
}
|
||||
case "view": {
|
||||
lx.next();
|
||||
expect("lbrace");
|
||||
// The view body is plain HTML. Parse it straight off the source
|
||||
// (the token lexer isn't used for markup), then resume after the
|
||||
// block's closing `}`.
|
||||
const { nodes, endPos } = parseHtmlView(lx.src, lx.pos);
|
||||
view.push(...nodes);
|
||||
lx.pos = endPos;
|
||||
expect("rbrace");
|
||||
break;
|
||||
}
|
||||
case "seo": {
|
||||
lx.next();
|
||||
Object.assign(seo, parseSeoBlock(lx.readBalancedBraces()));
|
||||
break;
|
||||
}
|
||||
case "api": {
|
||||
lx.next();
|
||||
const method = expect("ident").value.toUpperCase();
|
||||
const path = lx.readPath();
|
||||
const body = lx.readBalancedBraces();
|
||||
apis.push({ method, path, body });
|
||||
break;
|
||||
}
|
||||
case "ssr":
|
||||
case "client": {
|
||||
const mode: DataMode = kw.value === "ssr" ? "ssr" : "client";
|
||||
lx.next();
|
||||
expect("lbrace");
|
||||
while (lx.peek().type !== "rbrace") {
|
||||
const member = lx.peek();
|
||||
if (member.type === "eof") {
|
||||
throw new ParseError(`Unexpected end of input inside ${mode} block`);
|
||||
}
|
||||
if (member.type !== "ident") {
|
||||
throw new ParseError(`Expected a ${mode} member keyword at offset ${member.pos}`);
|
||||
}
|
||||
switch (member.value) {
|
||||
case "api": {
|
||||
lx.next();
|
||||
const name = expect("ident").value;
|
||||
const method = expect("ident").value.toUpperCase();
|
||||
const path = lx.readPath();
|
||||
const body = lx.readBalancedBraces();
|
||||
dataApis.push({ mode, name, method, path, body });
|
||||
break;
|
||||
}
|
||||
case "functions": {
|
||||
lx.next();
|
||||
modeFunctions.push({ mode, body: lx.readBalancedBraces() });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new ParseError(
|
||||
`Unknown ${mode} member '${member.value}' at offset ${member.pos}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
expect("rbrace");
|
||||
break;
|
||||
}
|
||||
case "realtime": {
|
||||
lx.next();
|
||||
const rName = expect("ident").value;
|
||||
expect("lbrace");
|
||||
const handlers: RealtimeHandler[] = [];
|
||||
while (lx.peek().type !== "rbrace") {
|
||||
expectKeyword("on");
|
||||
const event = expect("ident").value;
|
||||
expect("lparen");
|
||||
const args: string[] = [];
|
||||
while (lx.peek().type !== "rparen") {
|
||||
args.push(expect("ident").value);
|
||||
if (lx.peek().type === "comma") lx.next();
|
||||
}
|
||||
expect("rparen");
|
||||
handlers.push({ event, args, body: lx.readBalancedBraces() });
|
||||
}
|
||||
expect("rbrace");
|
||||
realtimes.push({ name: rName, handlers });
|
||||
break;
|
||||
}
|
||||
case "style": {
|
||||
lx.next();
|
||||
styles.push(lx.readBalancedBraces());
|
||||
break;
|
||||
}
|
||||
case "functions": {
|
||||
lx.next();
|
||||
functions.push(lx.readBalancedBraces());
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new ParseError(`Unknown page member '${kw.value}' at offset ${kw.pos}`);
|
||||
}
|
||||
}
|
||||
expect("rbrace");
|
||||
|
||||
return {
|
||||
type: "page",
|
||||
kind,
|
||||
name,
|
||||
layout,
|
||||
props,
|
||||
states,
|
||||
seo,
|
||||
view,
|
||||
styles,
|
||||
functions,
|
||||
dataApis,
|
||||
modeFunctions,
|
||||
apis,
|
||||
realtimes,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof LexError) throw new ParseError(err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the body of a `view { ... }` block as plain HTML.
|
||||
*
|
||||
* `src` is the whole `.wrn` source; `pos` points just past the view block's
|
||||
* opening `{`. Returns the parsed nodes plus the index of the block's closing
|
||||
* `}` (left for the caller to consume). It is intentionally lenient — you write
|
||||
* markup the way you already know:
|
||||
*
|
||||
* - `<tag attr="v" @event="expr">children</tag>` — elements with attributes
|
||||
* - `<tag/>` and HTML void elements (`<br>`, `<img>`, …) — no closing tag
|
||||
* - text may contain `{expr}` interpolation, kept verbatim for the runtime
|
||||
* - `@event="..."` becomes a client event binding; hyphenated names are fine
|
||||
* - `<!-- comments -->` are dropped
|
||||
*
|
||||
* `{` and `}` in text are reserved for interpolation; a lone `<` that isn't a
|
||||
* tag is treated as literal text.
|
||||
*/
|
||||
export function parseHtmlView(src: string, pos: number): { nodes: ViewNode[]; endPos: number } {
|
||||
let i = pos;
|
||||
|
||||
const isNameStart = (c: string): boolean => /[A-Za-z_]/.test(c);
|
||||
const isNamePart = (c: string): boolean => /[A-Za-z0-9_:-]/.test(c);
|
||||
const isWs = (c: string): boolean => c === " " || c === "\t" || c === "\n" || c === "\r";
|
||||
|
||||
const fail = (msg: string): never => {
|
||||
throw new ParseError(`${msg} at offset ${i}`);
|
||||
};
|
||||
const skipWs = (): void => {
|
||||
while (i < src.length && isWs(src[i]!)) i++;
|
||||
};
|
||||
|
||||
/** Read a `{...}` interpolation (brace-balanced), braces included. */
|
||||
const readInterpolation = (): string => {
|
||||
const start = i;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === "{") depth++;
|
||||
else if (src[i] === "}" && --depth === 0) {
|
||||
i++;
|
||||
return src.slice(start, i);
|
||||
}
|
||||
}
|
||||
return fail("Unterminated `{` interpolation in view");
|
||||
};
|
||||
|
||||
const readQuoted = (): string => {
|
||||
const quote = src[i];
|
||||
if (quote !== '"' && quote !== "'") return fail("Expected a quoted attribute value");
|
||||
i++;
|
||||
const start = i;
|
||||
while (i < src.length && src[i] !== quote) i++;
|
||||
if (i >= src.length) return fail("Unterminated attribute value");
|
||||
const value = src.slice(start, i);
|
||||
i++; // closing quote
|
||||
return value;
|
||||
};
|
||||
|
||||
const readName = (): string => {
|
||||
if (i >= src.length || !isNameStart(src[i]!)) return fail("Expected a tag or attribute name");
|
||||
const start = i++;
|
||||
while (i < src.length && isNamePart(src[i]!)) i++;
|
||||
return src.slice(start, i);
|
||||
};
|
||||
|
||||
const parseTag = (): ViewNode => {
|
||||
i++; // consume '<'
|
||||
const tag = readName();
|
||||
const attrs: Attr[] = [];
|
||||
|
||||
for (;;) {
|
||||
skipWs();
|
||||
const c = src[i];
|
||||
if (c === undefined) return fail(`Unterminated <${tag}> tag`);
|
||||
if (c === ">") {
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
if (c === "/" && src[i + 1] === ">") {
|
||||
i += 2;
|
||||
return { type: "element", tag, attrs, children: [] };
|
||||
}
|
||||
if (c === "@") {
|
||||
i++;
|
||||
const name = readName();
|
||||
skipWs();
|
||||
if (src[i] !== "=") return fail(`Expected '=' after @${name}`);
|
||||
i++;
|
||||
skipWs();
|
||||
attrs.push({ name, value: readQuoted(), event: true });
|
||||
continue;
|
||||
}
|
||||
const name = readName();
|
||||
skipWs();
|
||||
if (src[i] === "=") {
|
||||
i++;
|
||||
skipWs();
|
||||
attrs.push({ name, value: readQuoted(), event: false });
|
||||
} else {
|
||||
attrs.push({ name, value: "", event: false, boolean: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (VOID_ELEMENTS.has(tag.toLowerCase())) {
|
||||
return { type: "element", tag, attrs, children: [] };
|
||||
}
|
||||
|
||||
const children = parseNodeList("element");
|
||||
// parseNodeList stops at the parent's closing tag `</`.
|
||||
if (src[i] !== "<" || src[i + 1] !== "/") return fail(`Expected </${tag}>`);
|
||||
i += 2;
|
||||
skipWs();
|
||||
const close = readName();
|
||||
if (close !== tag) return fail(`Mismatched </${close}>, expected </${tag}>`);
|
||||
skipWs();
|
||||
if (src[i] !== ">") return fail(`Expected '>' to close </${tag}>`);
|
||||
i++;
|
||||
return { type: "element", tag, attrs, children };
|
||||
};
|
||||
|
||||
const EACH_HEADER =
|
||||
/^\{#each\s+([\s\S]+?)\s+as\s+([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*)\s*)?\}$/;
|
||||
|
||||
/** Parse `{#each <list> as <item>[, <index>]} …body… {:empty} …empty… {/each}`. */
|
||||
function parseEach(): ViewNode {
|
||||
const header = readInterpolation(); // reads the full `{#each …}`
|
||||
const m = EACH_HEADER.exec(header);
|
||||
if (!m) return fail(`Invalid {#each …} header: ${header}`);
|
||||
const list = m[1]!.trim();
|
||||
const item = m[2]!;
|
||||
const index = m[3];
|
||||
const body = parseNodeList("each"); // stops at {:empty} or {/each}
|
||||
let empty: ViewNode[] = [];
|
||||
if (src.startsWith("{:empty}", i)) {
|
||||
i += "{:empty}".length;
|
||||
empty = parseNodeList("each"); // stops at {/each}
|
||||
}
|
||||
if (!src.startsWith("{/each}", i)) return fail("Expected `{/each}` to close `{#each}`");
|
||||
i += "{/each}".length;
|
||||
return { type: "each", list, item, index, body, empty };
|
||||
}
|
||||
|
||||
/** Parse `{#if <expr>} … {:else if <expr>} … {:else} … {/if}`. */
|
||||
function parseIf(): ViewNode {
|
||||
const header = readInterpolation(); // reads the full `{#if …}`
|
||||
const m = /^\{#if\s+([\s\S]+?)\s*\}$/.exec(header);
|
||||
if (!m) return fail(`Invalid {#if …} header: ${header}`);
|
||||
const branches: { cond: string | null; body: ViewNode[] }[] = [
|
||||
{ cond: m[1]!.trim(), body: parseNodeList("if") },
|
||||
];
|
||||
for (;;) {
|
||||
if (src.startsWith("{:else if", i)) {
|
||||
const h = readInterpolation();
|
||||
const mm = /^\{:else if\s+([\s\S]+?)\s*\}$/.exec(h);
|
||||
if (!mm) return fail(`Invalid {:else if …}: ${h}`);
|
||||
branches.push({ cond: mm[1]!.trim(), body: parseNodeList("if") });
|
||||
continue;
|
||||
}
|
||||
if (src.startsWith("{:else}", i)) {
|
||||
i += "{:else}".length;
|
||||
branches.push({ cond: null, body: parseNodeList("if") });
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (!src.startsWith("{/if}", i)) return fail("Expected `{/if}` to close `{#if}`");
|
||||
i += "{/if}".length;
|
||||
return { type: "if", branches };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a run of nodes. `mode` sets the terminator:
|
||||
* - "root": stops at the view block's closing `}`
|
||||
* - "element": stops at the parent element's closing tag (`</`)
|
||||
* - "each": stops (without consuming) at `{:empty}` or `{/each}`
|
||||
* - "if": stops (without consuming) at `{:else …}` or `{/if}`
|
||||
* `{#each …}` and `{#if …}` start nested blocks in any mode.
|
||||
*/
|
||||
function parseNodeList(mode: "root" | "element" | "each" | "if"): ViewNode[] {
|
||||
const nodes: ViewNode[] = [];
|
||||
let text = "";
|
||||
const flush = (): void => {
|
||||
if (text.length > 0) {
|
||||
nodes.push({ type: "text", value: text });
|
||||
text = "";
|
||||
}
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
if (i >= src.length) {
|
||||
return mode === "root"
|
||||
? fail("Unexpected end of view (missing `}`)")
|
||||
: fail("Unclosed block");
|
||||
}
|
||||
const c = src[i]!;
|
||||
|
||||
if (c === "<") {
|
||||
const next = src[i + 1];
|
||||
if (next === "/") {
|
||||
flush();
|
||||
break; // parent's closing tag
|
||||
}
|
||||
if (src.startsWith("<!--", i)) {
|
||||
const end = src.indexOf("-->", i + 4);
|
||||
i = end === -1 ? src.length : end + 3;
|
||||
continue;
|
||||
}
|
||||
if (next !== undefined && (isNameStart(next) || next === "!")) {
|
||||
flush();
|
||||
nodes.push(parseTag());
|
||||
continue;
|
||||
}
|
||||
// A lone `<` that doesn't start a tag: treat as literal text.
|
||||
text += c;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c === "{") {
|
||||
if (src.startsWith("{#each", i)) {
|
||||
flush();
|
||||
nodes.push(parseEach());
|
||||
continue;
|
||||
}
|
||||
if (src.startsWith("{#if", i)) {
|
||||
flush();
|
||||
nodes.push(parseIf());
|
||||
continue;
|
||||
}
|
||||
if (mode === "each" && (src.startsWith("{:empty}", i) || src.startsWith("{/each}", i))) {
|
||||
flush();
|
||||
break; // loop-section terminator; left for parseEach
|
||||
}
|
||||
if (mode === "if" && (src.startsWith("{:else", i) || src.startsWith("{/if}", i))) {
|
||||
flush();
|
||||
break; // conditional-section terminator; left for parseIf
|
||||
}
|
||||
text += readInterpolation();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c === "}" && mode === "root") {
|
||||
flush();
|
||||
break; // view terminator; leave `}` for the caller
|
||||
}
|
||||
|
||||
text += c;
|
||||
i++;
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
const nodes = parseNodeList("root");
|
||||
return { nodes, endPos: i };
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Lexer for the `.wrn` language.
|
||||
*
|
||||
* `.wrn` mixes a small structural grammar (page/state/view/api/realtime) with
|
||||
* raw JavaScript bodies. A pure token stream can't represent the raw JS, so the
|
||||
* lexer is driven on demand by the parser: it yields structural tokens via
|
||||
* `next()`/`peek()`, and exposes `readBalancedBraces()`, `readPath()` and
|
||||
* `readToLineEnd()` for the parser to grab raw spans when grammar demands it.
|
||||
*/
|
||||
|
||||
export type TokenType =
|
||||
"ident" | "string" | "lbrace" | "rbrace" | "lparen" | "rparen" | "at" | "eq" | "comma" | "eof";
|
||||
|
||||
export interface Token {
|
||||
type: TokenType;
|
||||
value: string;
|
||||
pos: number;
|
||||
}
|
||||
|
||||
export class LexError extends Error {}
|
||||
|
||||
const isWs = (c: string) => c === " " || c === "\t" || c === "\n" || c === "\r";
|
||||
const isIdentStart = (c: string) => /[A-Za-z_]/.test(c);
|
||||
const isIdentPart = (c: string) => /[A-Za-z0-9_]/.test(c);
|
||||
|
||||
export class Lexer {
|
||||
pos = 0;
|
||||
constructor(public readonly src: string) {}
|
||||
|
||||
/** Skip whitespace and `// line comments`. */
|
||||
private skipTrivia(): void {
|
||||
const { src } = this;
|
||||
while (this.pos < src.length) {
|
||||
const c = src[this.pos]!;
|
||||
if (isWs(c)) {
|
||||
this.pos++;
|
||||
continue;
|
||||
}
|
||||
if (c === "/" && src[this.pos + 1] === "/") {
|
||||
while (this.pos < src.length && src[this.pos] !== "\n") this.pos++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read and consume the next structural token. */
|
||||
next(): Token {
|
||||
this.skipTrivia();
|
||||
const { src } = this;
|
||||
const pos = this.pos;
|
||||
if (pos >= src.length) return { type: "eof", value: "", pos };
|
||||
|
||||
const c = src[pos]!;
|
||||
switch (c) {
|
||||
case "{":
|
||||
this.pos++;
|
||||
return { type: "lbrace", value: c, pos };
|
||||
case "}":
|
||||
this.pos++;
|
||||
return { type: "rbrace", value: c, pos };
|
||||
case "(":
|
||||
this.pos++;
|
||||
return { type: "lparen", value: c, pos };
|
||||
case ")":
|
||||
this.pos++;
|
||||
return { type: "rparen", value: c, pos };
|
||||
case "@":
|
||||
this.pos++;
|
||||
return { type: "at", value: c, pos };
|
||||
case "=":
|
||||
this.pos++;
|
||||
return { type: "eq", value: c, pos };
|
||||
case ",":
|
||||
this.pos++;
|
||||
return { type: "comma", value: c, pos };
|
||||
case '"':
|
||||
case "'":
|
||||
return this.readString(c, pos);
|
||||
}
|
||||
|
||||
if (isIdentStart(c)) {
|
||||
let v = "";
|
||||
while (this.pos < src.length && isIdentPart(src[this.pos]!)) v += src[this.pos++];
|
||||
return { type: "ident", value: v, pos };
|
||||
}
|
||||
|
||||
throw new LexError(`Unexpected character '${c}' at offset ${pos} (line ${this.lineAt(pos)})`);
|
||||
}
|
||||
|
||||
/** Look at the next token without consuming it. */
|
||||
peek(): Token {
|
||||
const save = this.pos;
|
||||
const t = this.next();
|
||||
this.pos = save;
|
||||
return t;
|
||||
}
|
||||
|
||||
private readString(quote: string, pos: number): Token {
|
||||
const { src } = this;
|
||||
let v = "";
|
||||
this.pos++; // opening quote
|
||||
while (this.pos < src.length) {
|
||||
const c = src[this.pos++]!;
|
||||
if (c === "\\") {
|
||||
const n = src[this.pos++]!;
|
||||
v += n === "n" ? "\n" : n === "t" ? "\t" : n;
|
||||
continue;
|
||||
}
|
||||
if (c === quote) return { type: "string", value: v, pos };
|
||||
v += c;
|
||||
}
|
||||
throw new LexError(`Unterminated string at offset ${pos}`);
|
||||
}
|
||||
|
||||
/** Read a route path like `/users/[id]` up to whitespace or `{`. */
|
||||
readPath(): string {
|
||||
this.skipTrivia();
|
||||
const { src } = this;
|
||||
let v = "";
|
||||
while (this.pos < src.length && !isWs(src[this.pos]!) && src[this.pos] !== "{") {
|
||||
v += src[this.pos++];
|
||||
}
|
||||
if (!v) throw new LexError(`Expected a path at offset ${this.pos}`);
|
||||
return v;
|
||||
}
|
||||
|
||||
/** Read the rest of the current line (used for `state x = <expr>`). */
|
||||
readToLineEnd(): string {
|
||||
const { src } = this;
|
||||
let v = "";
|
||||
while (this.pos < src.length && src[this.pos] !== "\n") v += src[this.pos++];
|
||||
return v.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a `{ ... }` block and return its INNER text (no outer braces), with
|
||||
* brace counting that respects string and template literals so a `}` inside a
|
||||
* string doesn't end the block early.
|
||||
*/
|
||||
readBalancedBraces(): string {
|
||||
this.skipTrivia();
|
||||
const { src } = this;
|
||||
if (src[this.pos] !== "{") {
|
||||
throw new LexError(`Expected '{' at offset ${this.pos}`);
|
||||
}
|
||||
const start = this.pos + 1;
|
||||
let depth = 0;
|
||||
let i = this.pos;
|
||||
let str: string | null = null;
|
||||
for (; i < src.length; i++) {
|
||||
const c = src[i]!;
|
||||
if (str) {
|
||||
if (c === "\\") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === str) str = null;
|
||||
continue;
|
||||
}
|
||||
if (c === '"' || c === "'" || c === "`") {
|
||||
str = c;
|
||||
continue;
|
||||
}
|
||||
if (c === "{") depth++;
|
||||
else if (c === "}") {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
this.pos = i + 1;
|
||||
return src.slice(start, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new LexError(`Unbalanced braces starting at offset ${this.pos}`);
|
||||
}
|
||||
|
||||
private lineAt(pos: number): number {
|
||||
let line = 1;
|
||||
for (let i = 0; i < pos && i < this.src.length; i++) {
|
||||
if (this.src[i] === "\n") line++;
|
||||
}
|
||||
return line;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { parse } from "../src/index.ts";
|
||||
import { compileWireFile } from "../src/index.ts";
|
||||
|
||||
let seq = 0;
|
||||
/** Compile a `.wrn` source and import the resulting module. */
|
||||
async function compileAndImport(src: string): Promise<Record<string, unknown>> {
|
||||
const dir = join(tmpdir(), "wire-compiler-test");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const file = join(dir, `m${seq++}.ts`);
|
||||
writeFileSync(file, compileWireFile(src));
|
||||
return import(pathToFileURL(file).href);
|
||||
}
|
||||
|
||||
test("parses a page with a layout member", () => {
|
||||
const ast = parse(`page Home {\n layout = "public"\n view { <h1>Hi</h1> }\n}`);
|
||||
expect(ast.kind).toBe("page");
|
||||
expect(ast.name).toBe("Home");
|
||||
expect(ast.layout).toBe("public");
|
||||
});
|
||||
|
||||
test("parses a component with props and state", () => {
|
||||
const ast = parse(
|
||||
`component C {\n props {\n n = 0\n }\n state count = n\n view { <b>{count}</b> }\n}`,
|
||||
);
|
||||
expect(ast.kind).toBe("component");
|
||||
expect(ast.props.map((p) => p.name)).toEqual(["n"]);
|
||||
expect(ast.states.map((s) => s.name)).toEqual(["count"]);
|
||||
});
|
||||
|
||||
test("HTML view: void elements, boolean attrs, comments, lone <", () => {
|
||||
const ast = parse(
|
||||
`component T {\n view {\n <input type="text" disabled>\n <br/>\n <!-- comment -->\n <p>a < b</p>\n }\n}`,
|
||||
);
|
||||
const html = compileWireFile(
|
||||
`component T {\n view {\n <input type="text" disabled>\n <br/>\n <!-- comment -->\n <p>a < b</p>\n }\n}`,
|
||||
);
|
||||
expect(html).toContain("<input");
|
||||
expect(html).toContain(" disabled");
|
||||
expect(html).toContain("<br>");
|
||||
expect(html).not.toContain("comment");
|
||||
expect(html).toContain("a < b");
|
||||
void ast;
|
||||
});
|
||||
|
||||
test("stateless component bakes props into server HTML (zero JS)", async () => {
|
||||
const mod = await compileAndImport(
|
||||
`component Button {\n props {\n label = "Button"\n variant = "default"\n class = ""\n }\n view { <button class="wire-btn wire-btn--{variant} {class}">{label}</button> }\n}`,
|
||||
);
|
||||
const render = mod.render as (p: Record<string, string>) => string;
|
||||
const out = render({ label: "Save <b>", variant: "primary", class: "mt-2" });
|
||||
expect(out).toContain('class="wire-btn wire-btn--primary mt-2"');
|
||||
expect(out).toContain("Save <b>"); // html-escaped
|
||||
expect(out).not.toContain("data-scope"); // no reactivity → no scope
|
||||
});
|
||||
|
||||
test("stateful component: state text baked into a reactive data-text span, prop text baked", async () => {
|
||||
const mod = await compileAndImport(
|
||||
`component Counter {\n props {\n start = 0\n label = "Count"\n }\n state count = start\n view { <button @click="count++">{label}: {count}</button> }\n}`,
|
||||
);
|
||||
const render = mod.render as (p: Record<string, string>) => string;
|
||||
const out = render({ start: "10", label: "Score" });
|
||||
expect(out).toContain("data-scope=\"start: 10, label: 'Score', count: 10\"");
|
||||
expect(out).toContain('data-on-click="count++"');
|
||||
// label baked as static text; count baked as its initial value AND kept live.
|
||||
expect(out).toContain('Score: <span data-text="count">10</span>');
|
||||
});
|
||||
|
||||
test("prop type coercion follows the default value's type", async () => {
|
||||
const mod = await compileAndImport(
|
||||
`component X {\n props {\n n = 0\n s = "x"\n b = false\n }\n view { <i>{n}{s}{b}</i> }\n}`,
|
||||
);
|
||||
// needsScope=false → seeds nothing; verify via a stateful variant instead:
|
||||
const mod2 = await compileAndImport(
|
||||
`component Y {\n props {\n n = 0\n }\n state v = n\n view { <i @click="v++">{v}</i> }\n}`,
|
||||
);
|
||||
const out = (mod2.render as (p: Record<string, string>) => string)({ n: "42" });
|
||||
expect(out).toContain("v: 42"); // "42" coerced to number 42 (not '42')
|
||||
void mod;
|
||||
});
|
||||
|
||||
test("platform events compile through the native runtime bridge", () => {
|
||||
const out = compileWireFile(`page Platform {
|
||||
state count = 0
|
||||
view {
|
||||
<button @browser-click="count++" @mobile-click="count = count + 2">Run</button>
|
||||
}
|
||||
}`);
|
||||
expect(out).toContain('data-on-wrnexus-browser-click="count++"');
|
||||
expect(out).toContain('data-on-wrnexus-mobile-click="count = count + 2"');
|
||||
});
|
||||
|
||||
test("{t:key} compiles to a data-t marker (both pages and components)", () => {
|
||||
const page = compileWireFile(`page P {\n view { <h1>{t:home.title}</h1> }\n}`);
|
||||
expect(page).toContain('<span data-t="home.title"></span>');
|
||||
const comp = compileWireFile(`component C {\n view { <h1>{t:x}</h1> }\n}`);
|
||||
expect(comp).toContain('<span data-t="x"></span>');
|
||||
});
|
||||
|
||||
test("{t:} does NOT wrap a page in a data-scope (regression)", () => {
|
||||
// Only state / events force a reactive scope, not i18n markers.
|
||||
const page = compileWireFile(`page P {\n view { <h1>{t:a}</h1> <p>{t:b}</p> }\n}`);
|
||||
expect(page).not.toContain("data-scope");
|
||||
});
|
||||
|
||||
test("page state text bakes its initial value into a reactive data-text span", () => {
|
||||
const page = compileWireFile(
|
||||
`page Reactive {\n state count = 3\n view { <p>Count is {count}, doubled {count * 2}</p> }\n}`,
|
||||
);
|
||||
expect(page).toContain('<span data-text="count">3</span>'); // no-JS sees "3"
|
||||
expect(page).toContain('<span data-text="count * 2">6</span>'); // expression evaluated
|
||||
expect(page).toContain("data-scope"); // state still forces a reactive scope
|
||||
});
|
||||
|
||||
test("data-for: loop-variable mustaches stay literal (not baked server-side)", () => {
|
||||
const comp = compileWireFile(
|
||||
`component TodoList {\n state todos = []\n view { <ul><li data-for="t in todos">{t.text}</li></ul> }\n}`,
|
||||
);
|
||||
// The <li> template keeps `{t.text}` for the client list renderer, and the
|
||||
// loop variable is never baked (which would be a server-side ReferenceError).
|
||||
expect(comp).toContain('data-for="t in todos"');
|
||||
expect(comp).toContain("{t.text}");
|
||||
expect(comp).not.toContain("__wireHtml(t.text)");
|
||||
});
|
||||
|
||||
test("named slots pass through to the component output", () => {
|
||||
const out = compileWireFile(
|
||||
`component Card {\n view { <div><slot name="header"></slot><slot></slot></div> }\n}`,
|
||||
);
|
||||
expect(out).toContain('<slot name="header">');
|
||||
expect(out).toContain("<slot></slot>");
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { NativeCompileError, compileNativeWireFile } from "../src/index.ts";
|
||||
|
||||
test("compiles portable wrn markup to React Native components", () => {
|
||||
const code = compileNativeWireFile(`page Home {
|
||||
state count = 0
|
||||
view {
|
||||
<main class="screen">
|
||||
<h1>Count {count}</h1>
|
||||
<button @click="count++">Add</button>
|
||||
{#if count > 0}<p>Started</p>{:else}<p>Ready</p>{/if}
|
||||
</main>
|
||||
}
|
||||
style { .screen { padding: 24px; background-color: white; } }
|
||||
}`);
|
||||
expect(code).toContain("const [count, setCount] = useState(0)");
|
||||
expect(code).toContain('<View style={[styles["screen"] ]}>');
|
||||
expect(code).toContain("<Text>Count {count}</Text>");
|
||||
expect(code).toContain("onPress={() => { setCount(value => value + 1) }}");
|
||||
expect(code).toContain('"padding": 24');
|
||||
expect(() => new Bun.Transpiler({ loader: "tsx" }).transformSync(code)).not.toThrow();
|
||||
});
|
||||
|
||||
test("rejects browser-only elements with an actionable error", () => {
|
||||
expect(() => compileNativeWireFile("page Data { view { <table></table> } }")).toThrow(
|
||||
NativeCompileError,
|
||||
);
|
||||
});
|
||||
|
||||
test("compiles loops to native JSX", () => {
|
||||
const code = compileNativeWireFile(
|
||||
"page List { view { <ul>{#each items as item, i}<li>{item.name}</li>{:empty}<li>Empty</li>{/each}</ul> } }",
|
||||
);
|
||||
expect(code).toContain("(items).map((item, i)");
|
||||
expect(code).toContain("<Text>{item.name}</Text>");
|
||||
});
|
||||
|
||||
test("selects mobile-only markup and events for native output", () => {
|
||||
const code = compileNativeWireFile(`page Platforms { view {
|
||||
<button data-native-only="mobile" @mobile-click="save()" @browser-click="copy()">Save</button>
|
||||
<p data-native-only="browser">Browser help</p>
|
||||
} }`);
|
||||
expect(code).toContain("onPress={() => { save() }}");
|
||||
expect(code).not.toContain("copy()");
|
||||
expect(code).not.toContain("Browser help");
|
||||
});
|
||||
|
||||
test("rejects declarative Capacitor actions instead of silently dropping them in Expo", () => {
|
||||
expect(() =>
|
||||
compileNativeWireFile(
|
||||
`page Share { view { <button data-native-mobile="share">Share</button> } }`,
|
||||
),
|
||||
).toThrow("currently targets browser/Capacitor pages");
|
||||
});
|
||||
|
||||
test("does not emit native visibility directives as React Native props", () => {
|
||||
const code = compileNativeWireFile(
|
||||
`page Support { view { <p data-native-requires="camera">Camera</p> } }`,
|
||||
);
|
||||
expect(code).not.toContain("data-native-requires");
|
||||
});
|
||||
@@ -0,0 +1,381 @@
|
||||
# @wrnexus/core
|
||||
|
||||
> The framework core: the request `Context`, middleware contract, and the security, session, caching, streaming, realtime, and JSX primitives every other WrNexus package builds on.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
|
||||
## Overview
|
||||
|
||||
`@wrnexus/core` is the shared foundation of WrNexus. It defines the `Context`
|
||||
object that flows through every middleware, page, and API route, plus the
|
||||
`Middleware`/`Next` contract they implement. On top of that it ships the
|
||||
building blocks a real app needs: cookie-backed sessions, password auth, CSRF
|
||||
protection, rate limiting, request logging, HTTP + in-memory caching, file
|
||||
uploads, streaming/SSE responses, WebSocket "rooms", security headers/CORS, and
|
||||
a server-side JSX runtime that renders to HTML strings. Everything here is
|
||||
**server-side** and Bun-native (it uses `Bun.password`, `Bun.write`, the
|
||||
web-standard `Request`/`Response`, and `crypto`). You depend on it directly and
|
||||
transitively through the rest of the framework.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @wrnexus/core
|
||||
```
|
||||
|
||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
|
||||
|
||||
## API
|
||||
|
||||
### Context & middleware — `@wrnexus/core`
|
||||
|
||||
The `Context` (`ctx`) is the single value passed to middleware and handlers.
|
||||
|
||||
| Export | Kind | Description |
|
||||
| ------------------------------ | ---- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `Context` | type | Per-request object: `req`, `url`, `lang`, `t`, `params`, `locals`, `user?`, `ip?`, `cookies`, `session`, `localStorage`. |
|
||||
| `Next` | type | `() => Promise<Response> \| Response` — invokes the next middleware/handler. |
|
||||
| `Middleware` | type | `(ctx, next) => Promise<Response> \| Response`. Return `next()` to continue, or a `Response` to short-circuit. |
|
||||
| `createContext(req, url)` | fn | Build a fresh `Context` for an incoming request (wires up cookies, session, localStorage snapshot). |
|
||||
| `withContextHeaders(ctx, res)` | fn | Apply accumulated headers (e.g. `Set-Cookie`) from the context onto a response. |
|
||||
| `PageComponent` | type | `(ctx) => string \| Promise<string>` — a page module's default export. |
|
||||
| `PageMeta` / `SeoConfig` | type | `<head>` metadata: `title`, `description`, `canonical`, `robots`, `image`, `twitterCard`, `themeColor`, … |
|
||||
| `TFunction` | type | `(key, params?) => string` — translate a key for `ctx.lang`, interpolating `{param}` placeholders. |
|
||||
|
||||
Key `Context` fields:
|
||||
|
||||
- `ctx.locals` — per-request scratch space for passing values between middleware.
|
||||
- `ctx.user` — the authenticated user (populated by `sessionAuth`/`logIn`), or `null`.
|
||||
- `ctx.ip` — the direct socket peer IP (not spoofable via headers).
|
||||
- `ctx.cookies` / `ctx.session` / `ctx.localStorage` — see **Storage** below.
|
||||
|
||||
### Authentication — `@wrnexus/core`
|
||||
|
||||
Passwords are hashed with argon2id via `Bun.password`; sessions ride the
|
||||
cookie-backed `SessionStore`.
|
||||
|
||||
| Export | Signature | Notes |
|
||||
| -------------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| `hashPassword(password)` | `(string) => Promise<string>` | argon2id hash to store. |
|
||||
| `verifyPassword(password, hash)` | `(string, string) => Promise<boolean>` | Constant-safe; returns `false` on bad/empty hash. |
|
||||
| `logIn(ctx, user)` | `(Context, U) => void` | Regenerates the session id (fixation defense), stores the user, sets `ctx.user`. |
|
||||
| `logOut(ctx)` | `(Context) => void` | Clears the session and `ctx.user`. |
|
||||
| `getUser(ctx)` | `(Context) => U \| null` | Current user from `ctx.user`, falling back to the session. |
|
||||
| `sessionAuth()` | `() => Middleware` | Hydrates `ctx.user` from the session each request. Register early. |
|
||||
| `requireAuth(options?)` | `(RequireAuthOptions?) => Middleware` | Guard: API/fetch requests get `401 JSON`, page navigations get `302` to `loginPath` (default `/login`) with `?next=`. |
|
||||
| `SESSION_USER_KEY` | `"user"` | Session key holding the user. |
|
||||
|
||||
`RequireAuthOptions`: `{ loginPath?: string }`.
|
||||
|
||||
### CSRF — `@wrnexus/core`
|
||||
|
||||
Double-submit cookie pattern: a readable `wire-csrf` cookie is echoed in an
|
||||
`x-csrf-token` header on unsafe requests.
|
||||
|
||||
| Export | Signature | Notes |
|
||||
| ----------------------------- | -------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| `csrfToken(ctx)` | `(Context) => string` | Ensures the CSRF cookie exists and returns its token. |
|
||||
| `verifyCsrf(ctx)` | `(Context) => boolean` | Safe methods (GET/HEAD/OPTIONS) pass; otherwise header/`ctx.locals._csrf` must match the cookie (constant-time). |
|
||||
| `csrfProtection()` | `() => Middleware` | 403s unsafe requests with a missing/mismatched token. |
|
||||
| `CSRF_COOKIE` / `CSRF_HEADER` | `"wire-csrf"` / `"x-csrf-token"` | Cookie & header names. |
|
||||
|
||||
### Rate limiting — `@wrnexus/core`
|
||||
|
||||
Fixed-window limiter that returns `429` with `Retry-After` and emits
|
||||
`RateLimit-Limit`/`-Remaining`/`-Reset` headers.
|
||||
|
||||
| Export | Signature | Notes |
|
||||
| --------------------- | ----------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `rateLimit(options?)` | `(RateLimitOptions?) => Middleware` | Main middleware. |
|
||||
| `peerKey(ctx)` | `(Context) => string` | Non-spoofable key from `ctx.ip` (default). |
|
||||
| `proxyKey(ctx)` | `(Context) => string` | Trusts `x-forwarded-for`/`x-real-ip`. Use only behind a trusted proxy. |
|
||||
| `defaultKey` | — | **Deprecated** alias of `proxyKey`. |
|
||||
|
||||
`RateLimitOptions`: `windowMs` (default `60_000`), `max` (default `60`),
|
||||
`key`, `trustProxy` (default `false` → keys on `peerKey`; `true` → `proxyKey`),
|
||||
`message`, `headers` (default `true`), `store`.
|
||||
|
||||
`RateLimitStore` is pluggable — implement `hit(key, windowMs, now) => Bucket | Promise<Bucket>`
|
||||
(a `Bucket` is `{ count, resetAt }`) to back limits with Redis/SQL across
|
||||
instances. The default store is process-local memory.
|
||||
|
||||
### Request logging — `@wrnexus/core`
|
||||
|
||||
| Export | Signature | Notes |
|
||||
| ------------------------- | --------------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| `requestLogger(options?)` | `(RequestLoggerOptions?) => Middleware` | One record per request with a request id (stored on `ctx.locals[requestIdKey]`). |
|
||||
|
||||
`RequestLoggerOptions`: `format` (`"pretty"` default \| `"json"`), `sink(line, record)`
|
||||
(default `console.log`), `requestIdKey` (default `"requestId"`), `now`.
|
||||
`RequestRecord` = `{ time, id, method, path, status, durationMs }`.
|
||||
|
||||
### Caching — `@wrnexus/core`
|
||||
|
||||
| Export | Kind | Notes |
|
||||
| -------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `TTLCache<V>` | class | In-memory TTL cache: `get`, `set`, `getOrLoad(key, loader, ttlMs?)`, `delete`, `clear`, `size`. Constructor takes a default `ttlMs` (60s). |
|
||||
| `cacheControl(options)` | fn | Build a `Cache-Control` value from `CacheControlOptions`. |
|
||||
| `withCacheControl(res, options)` | fn | Apply `Cache-Control` to a response. |
|
||||
| `etag(body, weak?)` | fn | Stable quoted FNV-1a ETag (weak by default). |
|
||||
| `notModified(req, tag)` | fn | `true` when `If-None-Match` matches — send a `304`. |
|
||||
|
||||
`CacheControlOptions`: `maxAge`, `sMaxAge`, `private`, `noStore`, `noCache`,
|
||||
`staleWhileRevalidate`, `immutable`.
|
||||
|
||||
### File uploads — `@wrnexus/core`
|
||||
|
||||
Bun parses `multipart/form-data` via `Request.formData()`; these helpers
|
||||
validate and persist the resulting `File`s.
|
||||
|
||||
| Export | Signature | Notes |
|
||||
| --------------------------- | --------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| `collectUploads(form)` | `(FormData) => { field, file }[]` | Every non-empty `File` in a parsed form. |
|
||||
| `saveUpload(file, options)` | `(File, SaveUploadOptions) => Promise<SavedUpload>` | Validates size/type, sanitizes the name, writes via `Bun.write`. Throws `UploadError`. |
|
||||
| `sanitizeFilename(name)` | `(string) => string` | Strips separators, traversal, control/illegal chars; caps at 255. |
|
||||
| `UploadError` | class | Thrown on rejected uploads. |
|
||||
|
||||
`SaveUploadOptions`: `dir` (required), `maxBytes`, `allowedTypes` (MIME types
|
||||
like `"image/png"` and/or extensions like `".png"`), `filename(file)`.
|
||||
`SavedUpload` = `{ path, filename, size, type }`.
|
||||
|
||||
### Streaming & SSE — `@wrnexus/core`
|
||||
|
||||
| Export | Signature | Notes |
|
||||
| ------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| `streamResponse(source, init?)` | `(Iterable\|AsyncIterable<string\|Uint8Array>, StreamResponseInit?) => Response` | Streaming `Response` from a chunk source (basis for streaming SSR). |
|
||||
| `sse(source)` | `(Iterable\|AsyncIterable<ServerSentEvent>) => Response` | `text/event-stream` response. |
|
||||
|
||||
`StreamResponseInit`: `status`, `headers`, `contentType` (default
|
||||
`"text/html; charset=utf-8"`). `ServerSentEvent`: `{ data, event?, id?, retry? }`.
|
||||
|
||||
### Realtime rooms — `@wrnexus/core`
|
||||
|
||||
WebSocket rooms. A file in `app/realtime/` exports
|
||||
`default defineRoom({ ... })` and is served at `ws://host/realtime/<name>`.
|
||||
|
||||
| Export | Signature | Notes |
|
||||
| --------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| `defineRoom(handlers)` | `(RoomHandlers) => RoomDefinition` | Define a room. Export the result as `default`. |
|
||||
| `isRoomDefinition(value)` | `(unknown) => boolean` | Type guard for a room definition. |
|
||||
| `createRealtimeRegistry()` | `() => RealtimeRegistry` | Server-side connection manager mapping sockets ↔ rooms. |
|
||||
| `bridgeRealtime(registry, bus, topic?)` | `(RealtimeRegistry, RealtimeBus, string?) => () => void` | Bridge broadcasts/`toUser` sends across processes via a pub/sub bus. |
|
||||
|
||||
`RoomHandlers`: `authorize(info) => boolean` (gate before accept — return
|
||||
`false` to reject with 403), `onConnect(client)`, `onMessage(client, message)`
|
||||
(JSON auto-parsed), `onLeave(client)`. A handler receives a `RoomClient` with
|
||||
`id`, `user`, `query`, `data`, `room`, and `send` / `broadcast` /
|
||||
`to(id)` / `toUser(user)` / `close`. The `Room` API adds `state`, `clients()`,
|
||||
`count()`, and `broadcast`. `RealtimeBus` is structurally satisfied by
|
||||
`@wrnexus/pubsub`. Legacy `RealtimeHandler`/`RealtimeSocket` raw handlers are
|
||||
still exported. Connection-targeted sends (`send`, `to(id)`) stay local; room
|
||||
broadcasts and `toUser` cross the bridge.
|
||||
|
||||
### Error pages — `@wrnexus/core`
|
||||
|
||||
| Export | Signature | Notes |
|
||||
| ------------------------------ | -------------------------------- | ----------------------------------------------------- |
|
||||
| `renderError(err, mode)` | `(unknown, Mode) => Response` | Dev page (with stack) or generic prod page by `mode`. |
|
||||
| `renderDevError(err, status?)` | `(unknown, number?) => Response` | Readable HTML error page including the stack trace. |
|
||||
| `renderProdError(status?)` | `(number?) => Response` | Generic page that never leaks file paths. |
|
||||
| `renderNotFound()` | `() => Response` | Simple 404 page. |
|
||||
|
||||
`Mode` = `"development" | "production"`.
|
||||
|
||||
### Security headers & CORS — `@wrnexus/core`
|
||||
|
||||
| Export | Signature | Notes |
|
||||
| -------------------------------------------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `withSecurityHeaders(req, res, mode, security?, nonce?)` | → `Response` | Applies CORS + CSP, HSTS, `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, COOP, Trusted Types, and `extraHeaders`. |
|
||||
| `createCorsPreflightResponse(req, security?)` | → `Response \| null` | Builds a `204`/`403` preflight response for CORS `OPTIONS` requests. |
|
||||
| `isWebSocketOriginAllowed(req, security?)` | → `boolean` | Guards WS upgrades against cross-site hijacking (allows same-origin, configured CORS origins, and non-browser clients). |
|
||||
|
||||
Config types: `SecurityConfig` (top-level), `CorsConfig`/`CorsOrigin`,
|
||||
`ContentSecurityPolicyConfig`/`CspDirectiveValue`, `HstsConfig`,
|
||||
`TrustedTypesConfig`, `PermissionsPolicyConfig`. WrNexus applies sensible
|
||||
defaults (self-only CSP, `frame-ancestors 'none'`, restrictive Permissions-Policy,
|
||||
HSTS in production, Trusted Types in production); each is individually
|
||||
overridable or disable-able via `false`.
|
||||
|
||||
### Storage: cookies, sessions, localStorage — `@wrnexus/core`
|
||||
|
||||
These back the `ctx.cookies`, `ctx.session`, and `ctx.localStorage` fields.
|
||||
|
||||
| Export | Kind | Notes |
|
||||
| --------------------------------------------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `setSessionBackend(backend)` | fn | Swap the **sync** session persistence backend (`SessionBackend`) — e.g. `bun:sqlite`. Default is process-local memory. Call once at startup. |
|
||||
| `loadSession(backend, options?)` | fn → `Middleware` | Back `ctx.session` with an **async** store (`AsyncSessionBackend`: `load`/`save`/`destroy`) — loads before the request, saves after. `options.ttlMs` default 24h. |
|
||||
| `CookieStore` | type | `get`/`getAll`/`has`/`set(name, value, opts?)`/`delete`/`headers`. |
|
||||
| `SessionStore` | type | `id`/`get`/`getAll`/`set`/`delete`/`regenerate`/`clear`. |
|
||||
| `LocalStorageSnapshot` | type | Read-only view of the browser's localStorage sent via header for CSR bindings. |
|
||||
| `CookieOptions` | type | `path`, `domain`, `maxAge`, `expires`, `httpOnly`, `secure`, `sameSite`. |
|
||||
| `SessionEntry` / `SessionBackend` / `AsyncSessionBackend` | types | Session persistence contracts. |
|
||||
|
||||
### Low-level security helpers — `@wrnexus/core`
|
||||
|
||||
| Export | Signature | Notes |
|
||||
| ----------------------------- | --------------------- | --------------------------------------------------- |
|
||||
| `escapeHtml(value)` | `(string) => string` | Escape for HTML text/attributes. |
|
||||
| `isSafeIslandName(name)` | `(string) => boolean` | Allow only a conservative `[A-Za-z0-9_-]+` charset. |
|
||||
| `isSafeRequestPath(pathname)` | `(string) => boolean` | Reject NULs, `..` traversal, and backslashes. |
|
||||
|
||||
### JSX runtime — `@wrnexus/core`, `@wrnexus/core/jsx-runtime`, `@wrnexus/core/jsx-dev-runtime`
|
||||
|
||||
A server-side JSX runtime that renders to HTML **strings** (no virtual DOM).
|
||||
Point `tsconfig`'s `jsxImportSource` at `@wrnexus/core`.
|
||||
|
||||
| Export | Kind | Notes |
|
||||
| ------------------------------------------ | ------ | --------------------------------------------------------------------------------------- |
|
||||
| `jsx` / `jsxs` | fn | The runtime factory (TypeScript calls these automatically). Returns an `Html` instance. |
|
||||
| `Fragment` | symbol | JSX fragment marker. |
|
||||
| `Html` | class | Wraps a raw, already-safe HTML string (`toString()` returns it). |
|
||||
| `mustache(expr)` | fn | Emit a `{{expr}}` placeholder (tagged-template or string form) for the client binder. |
|
||||
| `JSXComponent` / `JSXProps` / `Renderable` | types | Component signature and renderable value types. |
|
||||
|
||||
Values interpolated as children are HTML-escaped unless they are an `Html`
|
||||
instance; use `dangerouslySetInnerHTML={{ __html }}` for trusted markup. Void
|
||||
elements render without a closing tag; `className`→`class`, `htmlFor`→`for`, and
|
||||
`style` objects are serialized to CSS text.
|
||||
|
||||
The subpath exports map to the runtime TypeScript's JSX transform expects:
|
||||
|
||||
```jsonc
|
||||
// tsconfig.json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "@wrnexus/core",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### A minimal middleware chain
|
||||
|
||||
```ts
|
||||
import {
|
||||
createContext,
|
||||
withContextHeaders,
|
||||
sessionAuth,
|
||||
requireAuth,
|
||||
requestLogger,
|
||||
rateLimit,
|
||||
csrfProtection,
|
||||
type Middleware,
|
||||
} from "@wrnexus/core";
|
||||
|
||||
const chain: Middleware[] = [
|
||||
requestLogger({ format: "json" }),
|
||||
rateLimit({ max: 100, windowMs: 60_000 }),
|
||||
csrfProtection(),
|
||||
sessionAuth(),
|
||||
requireAuth({ loginPath: "/login" }),
|
||||
];
|
||||
```
|
||||
|
||||
### Password auth
|
||||
|
||||
```ts
|
||||
import { hashPassword, verifyPassword, logIn, getUser } from "@wrnexus/core";
|
||||
|
||||
// Registration
|
||||
const passwordHash = await hashPassword(form.password);
|
||||
|
||||
// Login
|
||||
if (await verifyPassword(form.password, user.passwordHash)) {
|
||||
logIn(ctx, { id: user.id, email: user.email });
|
||||
}
|
||||
|
||||
const current = getUser<{ id: string }>(ctx); // or null
|
||||
```
|
||||
|
||||
### HTTP caching with ETags
|
||||
|
||||
```ts
|
||||
import { etag, notModified, withCacheControl } from "@wrnexus/core";
|
||||
|
||||
const body = JSON.stringify(data);
|
||||
const tag = etag(body);
|
||||
if (notModified(ctx.req, tag)) {
|
||||
return new Response(null, { status: 304, headers: { ETag: tag } });
|
||||
}
|
||||
const res = new Response(body, { headers: { ETag: tag, "content-type": "application/json" } });
|
||||
return withCacheControl(res, { maxAge: 60, staleWhileRevalidate: 300 });
|
||||
```
|
||||
|
||||
### Streaming SSE
|
||||
|
||||
```ts
|
||||
import { sse } from "@wrnexus/core";
|
||||
|
||||
async function* ticks() {
|
||||
for (let n = 0; ; n++) {
|
||||
yield { event: "tick", data: String(n) };
|
||||
await Bun.sleep(1000);
|
||||
}
|
||||
}
|
||||
export default (ctx) => sse(ticks());
|
||||
```
|
||||
|
||||
### A realtime room
|
||||
|
||||
```ts
|
||||
// app/realtime/chat.ts
|
||||
import { defineRoom } from "@wrnexus/core";
|
||||
|
||||
export default defineRoom({
|
||||
authorize: (info) => !!info.user, // require auth
|
||||
onConnect(client) {
|
||||
client.user = client.query.user;
|
||||
client.room.broadcast({ type: "join", id: client.id });
|
||||
},
|
||||
onMessage(client, msg) {
|
||||
client.broadcast({ type: "say", from: client.id, text: msg.text });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Scale it across processes:
|
||||
|
||||
```ts
|
||||
import { createRealtimeRegistry, bridgeRealtime } from "@wrnexus/core";
|
||||
import { createPubSub } from "@wrnexus/pubsub";
|
||||
import { redisDriver } from "@wrnexus/pubsub/redis";
|
||||
|
||||
const registry = createRealtimeRegistry();
|
||||
bridgeRealtime(registry, createPubSub(redisDriver(process.env.REDIS_URL)));
|
||||
```
|
||||
|
||||
### JSX rendering
|
||||
|
||||
```tsx
|
||||
import { Html } from "@wrnexus/core";
|
||||
|
||||
function Card({ title, body }: { title: string; body: string }) {
|
||||
return (
|
||||
<article class="card">
|
||||
<h2>{title}</h2>
|
||||
<p>{body}</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
const html: Html = <Card title="Hi" body="<b>escaped</b> automatically" />;
|
||||
return new Response(html.toString(), { headers: { "content-type": "text/html" } });
|
||||
```
|
||||
|
||||
## Requirements / Notes
|
||||
|
||||
- **Bun-only.** Uses `Bun.password` (argon2id), `Bun.write`, web-standard
|
||||
`Request`/`Response`/`FormData`/`ReadableStream`, and the global `crypto`.
|
||||
Node is not supported.
|
||||
- Session and rate-limit backends default to **process-local memory**. For
|
||||
multi-instance deployments, swap in a shared backend: `setSessionBackend` (sync,
|
||||
e.g. `bun:sqlite`) or `loadSession` (async, e.g. Redis) for sessions, a custom
|
||||
`RateLimitStore` for limits, and `bridgeRealtime` for realtime.
|
||||
- Works with the rest of the framework: realtime bridging is structurally
|
||||
compatible with [`@wrnexus/pubsub`](../pubsub); the security, auth, and JSX
|
||||
primitives here are consumed by the WrNexus server/router packages.
|
||||
- Subpath exports: `@wrnexus/core/jsx-runtime` and `@wrnexus/core/jsx-dev-runtime`
|
||||
for TypeScript's automatic JSX transform.
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@wrnexus/core",
|
||||
"version": "0.2.12",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./jsx-runtime": "./src/jsx-runtime.ts",
|
||||
"./jsx-dev-runtime": "./src/jsx-dev-runtime.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Authentication primitives.
|
||||
*
|
||||
* Passwords are hashed with argon2id via `Bun.password`. Sessions ride on the
|
||||
* existing cookie-backed `SessionStore`: logging a user in stores a serializable
|
||||
* user object under the "user" key, and `sessionAuth` hydrates `ctx.user` from
|
||||
* it on every request. `requireAuth` is a guard middleware for protected routes.
|
||||
*/
|
||||
|
||||
import type { Context, Middleware } from "./context.ts";
|
||||
|
||||
/** Session key under which the authenticated user is stored. */
|
||||
export const SESSION_USER_KEY = "user";
|
||||
|
||||
/** Hash a plaintext password (argon2id). Store the returned string. */
|
||||
export function hashPassword(password: string): Promise<string> {
|
||||
return Bun.password.hash(password);
|
||||
}
|
||||
|
||||
/** Verify a plaintext password against a stored hash. Safe against bad hashes. */
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
if (!hash) return false;
|
||||
try {
|
||||
return await Bun.password.verify(password, hash);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the authenticated user in the session and on the context. */
|
||||
export function logIn<U = unknown>(ctx: Context, user: U): void {
|
||||
// Regenerate the session id first so a pre-login (possibly attacker-planted)
|
||||
// id can't be reused post-login — defends against session fixation.
|
||||
ctx.session.regenerate();
|
||||
ctx.session.set(SESSION_USER_KEY, user);
|
||||
ctx.user = user;
|
||||
}
|
||||
|
||||
/** Clear the session and forget the current user. */
|
||||
export function logOut(ctx: Context): void {
|
||||
ctx.session.clear();
|
||||
ctx.user = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The currently-authenticated user, or null. Reads `ctx.user` first (set by
|
||||
* `sessionAuth`/`logIn`), falling back to the session store.
|
||||
*/
|
||||
export function getUser<U = unknown>(ctx: Context): U | null {
|
||||
if (ctx.user != null) return ctx.user as U;
|
||||
const fromSession = ctx.session.get<U>(SESSION_USER_KEY);
|
||||
return fromSession ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate `ctx.user` from the session for every request. Register this early in
|
||||
* the middleware chain so downstream pages and API routes can read `ctx.user`.
|
||||
*/
|
||||
export function sessionAuth(): Middleware {
|
||||
return (ctx, next) => {
|
||||
ctx.user = ctx.session.get(SESSION_USER_KEY) ?? null;
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
export interface RequireAuthOptions {
|
||||
/** Where to redirect unauthenticated page requests. Default "/login". */
|
||||
loginPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard that requires an authenticated user. Unauthenticated requests that look
|
||||
* like an API/fetch call get a 401 JSON response; page navigations get a 302
|
||||
* redirect to the login page with the original target preserved as `?next=`.
|
||||
*/
|
||||
export function requireAuth(options: RequireAuthOptions = {}): Middleware {
|
||||
const loginPath = options.loginPath ?? "/login";
|
||||
return (ctx, next) => {
|
||||
if (getUser(ctx) != null) return next();
|
||||
if (wantsJson(ctx)) {
|
||||
return Response.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const target = encodeURIComponent(ctx.url.pathname + ctx.url.search);
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: `${loginPath}?next=${target}` },
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function wantsJson(ctx: Context): boolean {
|
||||
if (ctx.url.pathname.startsWith("/api/")) return true;
|
||||
const accept = ctx.req.headers.get("accept") ?? "";
|
||||
return accept.includes("application/json") && !accept.includes("text/html");
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Caching primitives:
|
||||
* - `TTLCache` — a small in-memory time-to-live cache with `getOrLoad`, for
|
||||
* memoising expensive data (query results, computed pages).
|
||||
* - HTTP helpers — `cacheControl` to build a directive, `withCacheControl` to
|
||||
* apply it, and `etag` / `notModified` for conditional requests (304s).
|
||||
*/
|
||||
|
||||
// --- In-memory TTL cache ---------------------------------------------------
|
||||
|
||||
interface Entry<V> {
|
||||
value: V;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export class TTLCache<V = unknown> {
|
||||
private store = new Map<string, Entry<V>>();
|
||||
private loading = new Map<string, Promise<V>>();
|
||||
private revisions = new Map<string, number>();
|
||||
private generation = 0;
|
||||
constructor(private readonly ttlMs = 60_000) {}
|
||||
|
||||
get(key: string): V | undefined {
|
||||
const entry = this.store.get(key);
|
||||
if (!entry) return undefined;
|
||||
if (entry.expiresAt <= Date.now()) {
|
||||
this.store.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
set(key: string, value: V, ttlMs = this.ttlMs): void {
|
||||
this.revisions.set(key, (this.revisions.get(key) ?? 0) + 1);
|
||||
this.store.set(key, { value, expiresAt: Date.now() + ttlMs });
|
||||
}
|
||||
|
||||
/** Return the cached value or compute, cache, and return it. */
|
||||
async getOrLoad(key: string, loader: () => Promise<V> | V, ttlMs = this.ttlMs): Promise<V> {
|
||||
const hit = this.get(key);
|
||||
if (hit !== undefined) return hit;
|
||||
const pending = this.loading.get(key);
|
||||
if (pending) return pending;
|
||||
const revision = this.revisions.get(key) ?? 0;
|
||||
const generation = this.generation;
|
||||
const promise = Promise.resolve().then(loader);
|
||||
this.loading.set(key, promise);
|
||||
try {
|
||||
const value = await promise;
|
||||
if (this.generation === generation && (this.revisions.get(key) ?? 0) === revision) {
|
||||
this.store.set(key, { value, expiresAt: Date.now() + ttlMs });
|
||||
}
|
||||
return value;
|
||||
} finally {
|
||||
if (this.loading.get(key) === promise) this.loading.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
this.store.delete(key);
|
||||
this.loading.delete(key);
|
||||
this.revisions.set(key, (this.revisions.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.store.clear();
|
||||
this.loading.clear();
|
||||
this.revisions.clear();
|
||||
this.generation++;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.store.size;
|
||||
}
|
||||
}
|
||||
|
||||
// --- HTTP caching ----------------------------------------------------------
|
||||
|
||||
export interface CacheControlOptions {
|
||||
/** max-age in seconds. */
|
||||
maxAge?: number;
|
||||
/** s-maxage (shared/CDN cache) in seconds. */
|
||||
sMaxAge?: number;
|
||||
/** Mark private (per-user) rather than public. */
|
||||
private?: boolean;
|
||||
/** no-store: never cache. Overrides other directives. */
|
||||
noStore?: boolean;
|
||||
/** no-cache: revalidate before use. */
|
||||
noCache?: boolean;
|
||||
/** stale-while-revalidate window in seconds. */
|
||||
staleWhileRevalidate?: number;
|
||||
immutable?: boolean;
|
||||
}
|
||||
|
||||
/** Build a Cache-Control header value from options. */
|
||||
export function cacheControl(options: CacheControlOptions): string {
|
||||
if (options.noStore) return "no-store";
|
||||
const parts: string[] = [options.private ? "private" : "public"];
|
||||
if (options.noCache) parts.push("no-cache");
|
||||
if (options.maxAge !== undefined)
|
||||
parts.push(`max-age=${Math.max(0, Math.floor(options.maxAge))}`);
|
||||
if (options.sMaxAge !== undefined)
|
||||
parts.push(`s-maxage=${Math.max(0, Math.floor(options.sMaxAge))}`);
|
||||
if (options.staleWhileRevalidate !== undefined) {
|
||||
parts.push(`stale-while-revalidate=${Math.max(0, Math.floor(options.staleWhileRevalidate))}`);
|
||||
}
|
||||
if (options.immutable) parts.push("immutable");
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
/** Apply a Cache-Control header to a response (returns the same response). */
|
||||
export function withCacheControl(res: Response, options: CacheControlOptions): Response {
|
||||
try {
|
||||
res.headers.set("Cache-Control", cacheControl(options));
|
||||
} catch {
|
||||
/* immutable response — skip */
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/** A stable, quoted ETag for a string/bytes body (FNV-1a, weak by default). */
|
||||
export function etag(body: string | ArrayBuffer | Uint8Array, weak = true): string {
|
||||
const bytes =
|
||||
typeof body === "string"
|
||||
? new TextEncoder().encode(body)
|
||||
: body instanceof Uint8Array
|
||||
? body
|
||||
: new Uint8Array(body);
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
hash ^= bytes[i]!;
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
const tag = `"${(hash >>> 0).toString(16)}-${bytes.length.toString(16)}"`;
|
||||
return weak ? `W/${tag}` : tag;
|
||||
}
|
||||
|
||||
/** True when the request's If-None-Match matches the given ETag (send a 304). */
|
||||
export function notModified(req: Request, tag: string): boolean {
|
||||
const inm = req.headers.get("if-none-match");
|
||||
if (!inm) return false;
|
||||
const normalize = (t: string) => t.trim().replace(/^W\//, "");
|
||||
const target = normalize(tag);
|
||||
return inm.split(",").some((candidate) => normalize(candidate) === target);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Core request context and middleware contracts.
|
||||
*
|
||||
* The `Context` object is the single value that flows through middleware,
|
||||
* pages and API routes. It is intentionally small and framework-agnostic so
|
||||
* it can later be reused by the `.wrn` compiler output.
|
||||
*/
|
||||
|
||||
import {
|
||||
applyCookieHeaders,
|
||||
createCookieStore,
|
||||
createLocalStorageSnapshot,
|
||||
createSessionStore,
|
||||
type CookieStore,
|
||||
type LocalStorageSnapshot,
|
||||
type SessionStore,
|
||||
} from "./storage.ts";
|
||||
|
||||
/** Translate a key for the active language, interpolating `{param}` placeholders. */
|
||||
export type TFunction = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
export type Context = {
|
||||
/** The raw incoming web-standard Request. */
|
||||
req: Request;
|
||||
/** Parsed URL of the request (pathname, query, etc.). */
|
||||
url: URL;
|
||||
/** Active language for this request (resolved by the runtime); "" if i18n is unused. */
|
||||
lang: string;
|
||||
/** Translate a key for the active language (identity until the runtime sets it). */
|
||||
t: TFunction;
|
||||
/** Dynamic route params, e.g. `/users/[id]` -> `{ id: "42" }`. */
|
||||
params: Record<string, string>;
|
||||
/**
|
||||
* Per-request scratch space. Middleware can attach values here
|
||||
* (e.g. the authenticated user) and downstream handlers can read them.
|
||||
*/
|
||||
locals: Record<string, unknown>;
|
||||
/**
|
||||
* The authenticated user for this request, or null when anonymous. Populated
|
||||
* by the `sessionAuth` middleware (or `logIn`); read via `getUser(ctx)`.
|
||||
*/
|
||||
user?: unknown;
|
||||
/**
|
||||
* The direct socket peer IP, set by the server from `server.requestIP`. This
|
||||
* is NOT spoofable by request headers — prefer it over `x-forwarded-for` for
|
||||
* rate limiting unless you run behind a trusted proxy.
|
||||
*/
|
||||
ip?: string;
|
||||
/** Read/write HTTP cookies for the current response. */
|
||||
cookies: CookieStore;
|
||||
/** In-memory cookie-backed session store. */
|
||||
session: SessionStore;
|
||||
/** Read-only localStorage snapshot sent by the browser for CSR data bindings. */
|
||||
localStorage: LocalStorageSnapshot;
|
||||
};
|
||||
|
||||
/** Calls the next middleware in the chain (or the final route handler). */
|
||||
export type Next = () => Promise<Response> | Response;
|
||||
|
||||
/**
|
||||
* Middleware runs before pages and API routes. It can:
|
||||
* - inspect/modify `ctx`
|
||||
* - short-circuit by returning a `Response` without calling `next()`
|
||||
* - continue by returning `await next()`
|
||||
*/
|
||||
export type Middleware = (ctx: Context, next: Next) => Promise<Response> | Response;
|
||||
|
||||
/** SEO metadata rendered into the document `<head>`. */
|
||||
export type SeoConfig = {
|
||||
title?: string;
|
||||
titleTemplate?: string;
|
||||
description?: string;
|
||||
canonical?: string;
|
||||
canonicalBase?: string;
|
||||
robots?: string;
|
||||
keywords?: string | string[];
|
||||
image?: string;
|
||||
siteName?: string;
|
||||
type?: string;
|
||||
locale?: string;
|
||||
twitterCard?: string;
|
||||
twitterSite?: string;
|
||||
themeColor?: string;
|
||||
};
|
||||
|
||||
/** Page metadata rendered into the document `<head>`. */
|
||||
export type PageMeta = SeoConfig;
|
||||
|
||||
/** A page module's default export. Returns an HTML string for the body. */
|
||||
export type PageComponent = (ctx: Context) => string | Promise<string>;
|
||||
|
||||
/** Create a fresh context for an incoming request. */
|
||||
export function createContext(req: Request, url: URL): Context {
|
||||
const cookies = createCookieStore(req);
|
||||
return {
|
||||
req,
|
||||
url,
|
||||
params: {},
|
||||
locals: {},
|
||||
lang: "",
|
||||
t: (key) => key,
|
||||
cookies,
|
||||
// `url` already reflects X-Forwarded-Proto when trustProxy is on, so session
|
||||
// cookies get `Secure` behind a TLS-terminating proxy (matches CSRF cookies).
|
||||
session: createSessionStore(cookies, req, undefined, url.protocol === "https:"),
|
||||
localStorage: createLocalStorageSnapshot(req),
|
||||
};
|
||||
}
|
||||
|
||||
/** Apply headers accumulated on the context, such as Set-Cookie. */
|
||||
export function withContextHeaders(ctx: Context, res: Response): Response {
|
||||
const headers = new Headers(res.headers);
|
||||
applyCookieHeaders(ctx, headers);
|
||||
return new Response(res.body, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* CSRF protection via the double-submit cookie pattern.
|
||||
*
|
||||
* The framework sets a readable `wire-csrf` cookie on page loads; the client
|
||||
* echoes it in an `x-csrf-token` header on unsafe requests (the Wire UI form
|
||||
* runtime does this automatically). The server checks header === cookie. A
|
||||
* cross-site attacker can't read the cookie to forge the header, so the request
|
||||
* is rejected — while same-origin requests pass.
|
||||
*/
|
||||
|
||||
import type { Context, Middleware } from "./context.ts";
|
||||
|
||||
export const CSRF_COOKIE = "wire-csrf";
|
||||
export const CSRF_HEADER = "x-csrf-token";
|
||||
|
||||
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
|
||||
/** Ensure the CSRF cookie exists (readable by JS) and return its token. */
|
||||
export function csrfToken(ctx: Context): string {
|
||||
let token = ctx.cookies.get(CSRF_COOKIE);
|
||||
if (!token) {
|
||||
token = crypto.randomUUID().replace(/-/g, "");
|
||||
// Readable by JS (double-submit needs it) but Secure on HTTPS.
|
||||
ctx.cookies.set(CSRF_COOKIE, token, {
|
||||
sameSite: "Lax",
|
||||
path: "/",
|
||||
secure: ctx.url.protocol === "https:",
|
||||
});
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an unsafe request's CSRF token against the cookie. Safe methods
|
||||
* (GET/HEAD/OPTIONS) always pass. The token may arrive in the `x-csrf-token`
|
||||
* header or a `_csrf` field already parsed onto `ctx.locals`.
|
||||
*/
|
||||
export function verifyCsrf(ctx: Context): boolean {
|
||||
if (SAFE_METHODS.has(ctx.req.method.toUpperCase())) return true;
|
||||
const cookie = ctx.cookies.get(CSRF_COOKIE);
|
||||
const sent = ctx.req.headers.get(CSRF_HEADER) ?? (ctx.locals._csrf as string | undefined);
|
||||
return !!cookie && !!sent && timingSafeEqual(cookie, sent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time string comparison — the running time does not depend on where
|
||||
* the first differing byte is, so an attacker can't time-probe the token.
|
||||
*/
|
||||
function timingSafeEqual(a: string, b: string): boolean {
|
||||
let diff = a.length ^ b.length;
|
||||
const max = Math.max(a.length, b.length);
|
||||
for (let i = 0; i < max; i++) {
|
||||
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||
}
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
/** Middleware that 403s unsafe requests with a missing/mismatched CSRF token. */
|
||||
export function csrfProtection(): Middleware {
|
||||
return (ctx, next) =>
|
||||
verifyCsrf(ctx) ? next() : new Response("Invalid CSRF token", { status: 403 });
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Error + status pages. Every page here is a self-contained HTML document —
|
||||
* inline CSS only, no external stylesheet, no JavaScript (so it renders under the
|
||||
* strict CSP, even when the app's assets are what failed). Theme-aware via
|
||||
* `prefers-color-scheme`, styled in the WrNexus design language (ink-navy,
|
||||
* azure, a faint blueprint grid + glow). Development shows the stack trace;
|
||||
* production never leaks internal paths.
|
||||
*/
|
||||
|
||||
import { escapeHtml } from "./security.ts";
|
||||
|
||||
export type Mode = "development" | "production";
|
||||
|
||||
interface ErrorPageOptions {
|
||||
status: number;
|
||||
/** Big display code, e.g. "404" / "500". */
|
||||
code: string;
|
||||
title: string;
|
||||
message: string;
|
||||
/** Monospace eyebrow, e.g. "ERROR 404". */
|
||||
eyebrow?: string;
|
||||
/** Optional dev-only detail (error name + stack), rendered in a code panel. */
|
||||
detail?: { heading: string; body: string };
|
||||
/** Show a "Back home" action (default true). */
|
||||
home?: boolean;
|
||||
}
|
||||
|
||||
/** Shared, self-contained, theme-aware error document. */
|
||||
function errorDocument(o: ErrorPageOptions): string {
|
||||
const eyebrow = escapeHtml(o.eyebrow ?? `ERROR ${o.status}`);
|
||||
const title = escapeHtml(o.title);
|
||||
const message = escapeHtml(o.message);
|
||||
const detail = o.detail
|
||||
? `
|
||||
<section class="detail">
|
||||
<div class="detail-head">${escapeHtml(o.detail.heading)}</div>
|
||||
<pre class="detail-body">${escapeHtml(o.detail.body)}</pre>
|
||||
</section>`
|
||||
: "";
|
||||
const home = o.home === false ? "" : `<a class="btn btn-primary" href="/">Back to home</a>`;
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>${title}</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0e17; --bg2: #070a12; --text: #e7ecf5; --muted: #93a1b8;
|
||||
--brand: #6ea0ff; --brand-2: #3f7dff; --border: rgba(255,255,255,.10);
|
||||
--card: rgba(255,255,255,.03); --grid: rgba(110,160,255,.10);
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg: #f7f9fc; --bg2: #eef2f8; --text: #0f172a; --muted: #5a6b85;
|
||||
--brand: #2b62f0; --brand-2: #2b62f0; --border: rgba(15,23,42,.10);
|
||||
--card: rgba(15,23,42,.02); --grid: rgba(43,98,240,.09);
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
margin: 0; background: var(--bg); color: var(--text);
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility;
|
||||
display: grid; place-items: center; min-height: 100%;
|
||||
padding: clamp(1.5rem, 5vw, 4rem); position: relative; overflow-x: hidden;
|
||||
}
|
||||
/* Blueprint grid + radial glow backdrop. */
|
||||
body::before {
|
||||
content: ""; position: fixed; inset: 0; z-index: 0; pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(to right, var(--grid) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, var(--grid) 1px, transparent 1px);
|
||||
background-size: 56px 56px;
|
||||
-webkit-mask-image: radial-gradient(ellipse 75% 60% at 50% 30%, #000 10%, transparent 72%);
|
||||
mask-image: radial-gradient(ellipse 75% 60% at 50% 30%, #000 10%, transparent 72%);
|
||||
}
|
||||
body::after {
|
||||
content: ""; position: fixed; left: 50%; top: -10%; z-index: 0; pointer-events: none;
|
||||
width: min(680px, 90vw); height: 420px; transform: translateX(-50%);
|
||||
background: radial-gradient(circle at center, color-mix(in oklab, var(--brand-2) 34%, transparent), transparent 68%);
|
||||
filter: blur(8px); opacity: .55;
|
||||
}
|
||||
main {
|
||||
position: relative; z-index: 1; width: 100%; max-width: 640px; text-align: center;
|
||||
animation: rise .6s cubic-bezier(.16,1,.3,1) both;
|
||||
}
|
||||
@keyframes rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
|
||||
@media (prefers-reduced-motion: reduce) { main { animation: none; } }
|
||||
.eyebrow {
|
||||
font: 600 .72rem/1 ui-monospace, "SFMono-Regular", Menlo, monospace;
|
||||
letter-spacing: .22em; color: var(--brand); text-transform: uppercase;
|
||||
}
|
||||
.code {
|
||||
margin: .5rem 0 0; font-weight: 800; line-height: .9;
|
||||
font-size: clamp(5rem, 22vw, 11rem); letter-spacing: -.04em;
|
||||
background: linear-gradient(180deg, var(--text), color-mix(in oklab, var(--brand) 60%, var(--text)));
|
||||
-webkit-background-clip: text; background-clip: text; color: transparent;
|
||||
}
|
||||
h1 { margin: .25rem 0 0; font-size: clamp(1.4rem, 4vw, 2rem); font-weight: 700; letter-spacing: -.02em; }
|
||||
.msg { margin: .9rem auto 0; max-width: 30rem; color: var(--muted); line-height: 1.65; font-size: 1rem; }
|
||||
.actions { margin-top: 2rem; display: flex; flex-wrap: wrap; gap: .75rem; justify-content: center; }
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: .5rem; text-decoration: none;
|
||||
padding: .7rem 1.25rem; border-radius: 10px; font-weight: 600; font-size: .9rem;
|
||||
transition: transform .12s ease, filter .12s ease, border-color .12s ease;
|
||||
}
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn-primary {
|
||||
color: #fff; background: linear-gradient(180deg, var(--brand), var(--brand-2));
|
||||
box-shadow: 0 8px 24px -10px color-mix(in oklab, var(--brand-2) 80%, transparent);
|
||||
}
|
||||
.btn-primary:hover { filter: brightness(1.08); }
|
||||
.detail {
|
||||
margin: 2.25rem auto 0; text-align: left; max-width: 100%;
|
||||
border: 1px solid var(--border); border-radius: 12px; background: var(--card); overflow: hidden;
|
||||
}
|
||||
.detail-head {
|
||||
padding: .7rem 1rem; font: 600 .75rem/1 ui-monospace, "SFMono-Regular", Menlo, monospace;
|
||||
color: var(--brand); border-bottom: 1px solid var(--border);
|
||||
white-space: pre-wrap; word-break: break-word;
|
||||
}
|
||||
.detail-body {
|
||||
margin: 0; padding: 1rem; max-height: 40vh; overflow: auto;
|
||||
font: .8rem/1.6 ui-monospace, "SFMono-Regular", Menlo, monospace;
|
||||
color: var(--muted); white-space: pre-wrap; word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<p class="eyebrow">${eyebrow}</p>
|
||||
<div class="code" aria-hidden="true">${escapeHtml(o.code)}</div>
|
||||
<h1>${title}</h1>
|
||||
<p class="msg">${message}</p>
|
||||
<div class="actions">${home}</div>
|
||||
${detail}
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/** Common status → friendly copy, for a generic HTML status page. */
|
||||
const STATUS_COPY: Record<number, { title: string; message: string }> = {
|
||||
400: {
|
||||
title: "Bad request",
|
||||
message: "The request couldn't be understood. Check the URL and try again.",
|
||||
},
|
||||
401: { title: "Sign in required", message: "You need to be signed in to view this page." },
|
||||
403: { title: "Access denied", message: "You don't have permission to view this page." },
|
||||
404: {
|
||||
title: "Page not found",
|
||||
message: "The page you're looking for doesn't exist or has moved.",
|
||||
},
|
||||
413: { title: "Too large", message: "The request was larger than the server allows." },
|
||||
429: {
|
||||
title: "Slow down",
|
||||
message: "You've made too many requests. Please wait a moment and try again.",
|
||||
},
|
||||
500: {
|
||||
title: "Something went wrong",
|
||||
message: "The server hit an unexpected error. Please try again in a moment.",
|
||||
},
|
||||
502: {
|
||||
title: "Bad gateway",
|
||||
message: "We couldn't reach an upstream service. Please try again shortly.",
|
||||
},
|
||||
503: {
|
||||
title: "Temporarily unavailable",
|
||||
message: "The service is down for a moment. Please try again shortly.",
|
||||
},
|
||||
};
|
||||
|
||||
/** A beautiful, self-contained HTML page for any 4xx/5xx status. */
|
||||
export function renderStatusPage(status: number): Response {
|
||||
const copy = STATUS_COPY[status] ?? {
|
||||
title: status >= 500 ? "Something went wrong" : "Something's not right",
|
||||
message: "An unexpected response was returned. Please try again.",
|
||||
};
|
||||
return new Response(
|
||||
errorDocument({ status, code: String(status), title: copy.title, message: copy.message }),
|
||||
{ status, headers: { "content-type": "text/html; charset=utf-8" } },
|
||||
);
|
||||
}
|
||||
|
||||
/** Readable, styled development error page — includes the stack trace. */
|
||||
export function renderDevError(err: unknown, status = 500): Response {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
const name = error.name || "Error";
|
||||
const message = error.message || "Unknown error";
|
||||
return new Response(
|
||||
errorDocument({
|
||||
status,
|
||||
code: String(status),
|
||||
eyebrow: "DEVELOPMENT ERROR",
|
||||
title: name,
|
||||
message,
|
||||
detail: { heading: `${name}: ${message}`, body: error.stack || "(no stack available)" },
|
||||
}),
|
||||
{ status, headers: { "content-type": "text/html; charset=utf-8" } },
|
||||
);
|
||||
}
|
||||
|
||||
/** Generic production error page — no stack, no file paths. */
|
||||
export function renderProdError(status = 500): Response {
|
||||
return renderStatusPage(status);
|
||||
}
|
||||
|
||||
/** Pick the right error page for the current mode. */
|
||||
export function renderError(err: unknown, mode: Mode): Response {
|
||||
return mode === "development" ? renderDevError(err) : renderProdError();
|
||||
}
|
||||
|
||||
/** Beautiful 404 page. */
|
||||
export function renderNotFound(): Response {
|
||||
return renderStatusPage(404);
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
import type { Mode } from "./errors.ts";
|
||||
|
||||
export type CorsOrigin = "*" | string | string[];
|
||||
|
||||
export interface CorsConfig {
|
||||
/** Enable CORS headers and preflight handling. Defaults to false. */
|
||||
enabled?: boolean;
|
||||
/** Allowed origins. Use "*" for public APIs. Defaults to "*". */
|
||||
origin?: CorsOrigin;
|
||||
/** Allowed methods for preflight responses. */
|
||||
methods?: string[];
|
||||
/** Allowed request headers. Defaults to the browser's requested headers. */
|
||||
allowedHeaders?: string[];
|
||||
/** Response headers exposed to browser JavaScript. */
|
||||
exposedHeaders?: string[];
|
||||
/** Whether to send Access-Control-Allow-Credentials. */
|
||||
credentials?: boolean;
|
||||
/** Access-Control-Max-Age, in seconds. */
|
||||
maxAge?: number;
|
||||
}
|
||||
|
||||
export type CspDirectiveValue = string | string[] | false | null | undefined;
|
||||
|
||||
export interface ContentSecurityPolicyConfig {
|
||||
/** Defaults to true. */
|
||||
enabled?: boolean;
|
||||
/** Use Content-Security-Policy-Report-Only instead of enforcing. */
|
||||
reportOnly?: boolean;
|
||||
/** Merge or remove directives. Set a directive to false/null to remove it. */
|
||||
directives?: Record<string, CspDirectiveValue>;
|
||||
/** Set false to start from an empty policy instead of WrNexus defaults. */
|
||||
useDefaults?: boolean;
|
||||
}
|
||||
|
||||
export interface HstsConfig {
|
||||
/** Defaults to true in production, false in development. */
|
||||
enabled?: boolean;
|
||||
/** Defaults to 31536000 seconds (1 year). */
|
||||
maxAge?: number;
|
||||
/** Defaults to true. */
|
||||
includeSubDomains?: boolean;
|
||||
/** Defaults to true. */
|
||||
preload?: boolean;
|
||||
}
|
||||
|
||||
export interface TrustedTypesConfig {
|
||||
/** Defaults to true in production, false in development. */
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* Defaults to ["*"] in production so browser extensions and dev tooling can
|
||||
* create their own policies without noisy console errors. Set this to a
|
||||
* concrete list, e.g. ["wrnexus", "default"], for stricter deployments.
|
||||
*/
|
||||
policyNames?: string[];
|
||||
/** Defaults to true. */
|
||||
requireForScript?: boolean;
|
||||
/** Adds "allow-duplicates" to the trusted-types directive. */
|
||||
allowDuplicates?: boolean;
|
||||
}
|
||||
|
||||
export type PermissionsPolicyConfig = Record<string, string | string[] | false | null | undefined>;
|
||||
|
||||
export interface SecurityConfig {
|
||||
/** Set false to skip all framework security headers except explicitly enabled CORS. */
|
||||
headers?: boolean;
|
||||
/**
|
||||
* Trust `X-Forwarded-Proto` / `X-Forwarded-Host` when building `ctx.url` — set
|
||||
* this when the app runs behind a TLS-terminating reverse proxy (nginx, the
|
||||
* WrNexus gateway, a load balancer). Without it, a proxied app sees the internal
|
||||
* `http://` request and marks cookies (e.g. CSRF/session) non-`Secure`. Default
|
||||
* false; enable ONLY when a trusted proxy actually sets these headers.
|
||||
*/
|
||||
trustProxy?: boolean;
|
||||
cors?: boolean | CorsConfig;
|
||||
contentSecurityPolicy?: false | ContentSecurityPolicyConfig;
|
||||
hsts?: false | HstsConfig;
|
||||
trustedTypes?: false | TrustedTypesConfig;
|
||||
/** Defaults to "same-origin". */
|
||||
crossOriginOpenerPolicy?: false | "same-origin" | "same-origin-allow-popups" | "unsafe-none";
|
||||
/** Defaults to "DENY". */
|
||||
frameOptions?: false | "DENY" | "SAMEORIGIN";
|
||||
/** Defaults to "strict-origin-when-cross-origin". */
|
||||
referrerPolicy?: false | string;
|
||||
/** Defaults to a restrictive browser capability policy. */
|
||||
permissionsPolicy?: false | PermissionsPolicyConfig;
|
||||
/** Extra static headers applied last. */
|
||||
extraHeaders?: Record<string, string>;
|
||||
}
|
||||
|
||||
const DEFAULT_CSP: Record<string, string[]> = {
|
||||
"default-src": ["'self'"],
|
||||
"script-src": ["'self'"],
|
||||
"style-src": ["'self'", "'unsafe-inline'"],
|
||||
"img-src": ["'self'", "data:", "blob:"],
|
||||
"font-src": ["'self'", "data:"],
|
||||
"connect-src": ["'self'", "ws:", "wss:"],
|
||||
"object-src": ["'none'"],
|
||||
"base-uri": ["'self'"],
|
||||
"frame-ancestors": ["'none'"],
|
||||
"form-action": ["'self'"],
|
||||
};
|
||||
|
||||
const DEFAULT_PERMISSIONS_POLICY: PermissionsPolicyConfig = {
|
||||
accelerometer: [],
|
||||
autoplay: [],
|
||||
camera: [],
|
||||
"display-capture": [],
|
||||
"encrypted-media": [],
|
||||
fullscreen: ["self"],
|
||||
geolocation: [],
|
||||
gyroscope: [],
|
||||
magnetometer: [],
|
||||
microphone: [],
|
||||
midi: [],
|
||||
payment: [],
|
||||
"picture-in-picture": [],
|
||||
"sync-xhr": [],
|
||||
unload: [],
|
||||
usb: [],
|
||||
"xr-spatial-tracking": [],
|
||||
};
|
||||
|
||||
const DEFAULT_CORS_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
|
||||
|
||||
/**
|
||||
* Guard a WebSocket upgrade against Cross-Site WebSocket Hijacking: browsers
|
||||
* always send an `Origin` header on a WS handshake, and — unlike fetch — WS is
|
||||
* NOT subject to CORS, so cookies would otherwise flow cross-site. We allow
|
||||
* same-origin (Origin host === Host header), configured CORS origins, and
|
||||
* non-browser clients (no Origin, which also carry no ambient cookies).
|
||||
*/
|
||||
export function isWebSocketOriginAllowed(req: Request, security?: SecurityConfig): boolean {
|
||||
const origin = req.headers.get("origin");
|
||||
if (!origin) return true; // native/non-browser client — not the CSWSH threat
|
||||
let originHost: string;
|
||||
try {
|
||||
originHost = new URL(origin).host;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (originHost === req.headers.get("host")) return true; // same-origin
|
||||
|
||||
const cors = normalizeCors(security?.cors);
|
||||
if (cors.enabled) {
|
||||
const configured = cors.origin ?? "*";
|
||||
if (configured === "*") return true;
|
||||
const list = Array.isArray(configured) ? configured : [configured];
|
||||
return list.includes(origin);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function createCorsPreflightResponse(
|
||||
req: Request,
|
||||
security?: SecurityConfig,
|
||||
): Response | null {
|
||||
if (req.method.toUpperCase() !== "OPTIONS") return null;
|
||||
if (!req.headers.has("origin") || !req.headers.has("access-control-request-method")) return null;
|
||||
|
||||
const cors = normalizeCors(security?.cors);
|
||||
if (!cors.enabled) return null;
|
||||
|
||||
const headers = new Headers();
|
||||
const allowed = applyCorsHeaders(req, headers, cors);
|
||||
if (!allowed) return new Response("CORS origin denied", { status: 403 });
|
||||
|
||||
return new Response(null, { status: 204, headers });
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the request URL, honoring `X-Forwarded-Proto` / `X-Forwarded-Host` when
|
||||
* `trustProxy` is set (app behind a TLS-terminating reverse proxy). This makes
|
||||
* `ctx.url.protocol` reflect the EXTERNAL scheme, so protocol-dependent logic —
|
||||
* `Secure` cookies, canonical URLs — is correct behind nginx / the gateway.
|
||||
* Security checks that compare the raw `Host`/`Origin` headers don't use this URL,
|
||||
* so they are unaffected. An invalid forwarded value is ignored by the URL setter.
|
||||
*/
|
||||
export function resolveRequestUrl(req: Request, trustProxy?: boolean): URL {
|
||||
const url = new URL(req.url);
|
||||
if (!trustProxy) return url;
|
||||
const proto = req.headers.get("x-forwarded-proto");
|
||||
if (proto) url.protocol = (proto.split(",")[0] ?? "").trim() + ":";
|
||||
const host = req.headers.get("x-forwarded-host");
|
||||
if (host) {
|
||||
const h = (host.split(",")[0] ?? "").trim();
|
||||
url.host = h;
|
||||
if (!h.includes(":")) url.port = ""; // drop the internal proxy port when none forwarded
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export function withSecurityHeaders(
|
||||
req: Request,
|
||||
res: Response,
|
||||
mode: Mode,
|
||||
security?: SecurityConfig,
|
||||
nonce?: string,
|
||||
): Response {
|
||||
const headers = new Headers(res.headers);
|
||||
const cors = normalizeCors(security?.cors);
|
||||
|
||||
if (cors.enabled) {
|
||||
applyCorsHeaders(req, headers, cors);
|
||||
}
|
||||
|
||||
if (security?.headers !== false) {
|
||||
applyBaseSecurityHeaders(headers, mode, security, nonce);
|
||||
}
|
||||
|
||||
if (security?.extraHeaders) {
|
||||
Object.entries(security.extraHeaders).forEach(([name, value]) => headers.set(name, value));
|
||||
}
|
||||
|
||||
return new Response(res.body, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
function applyBaseSecurityHeaders(
|
||||
headers: Headers,
|
||||
mode: Mode,
|
||||
security?: SecurityConfig,
|
||||
nonce?: string,
|
||||
): void {
|
||||
headers.set("X-Content-Type-Options", "nosniff");
|
||||
|
||||
const frameOptions = security?.frameOptions ?? "DENY";
|
||||
if (frameOptions !== false) headers.set("X-Frame-Options", frameOptions);
|
||||
|
||||
const coop = security?.crossOriginOpenerPolicy ?? "same-origin";
|
||||
if (coop !== false) headers.set("Cross-Origin-Opener-Policy", coop);
|
||||
|
||||
const referrerPolicy = security?.referrerPolicy ?? "strict-origin-when-cross-origin";
|
||||
if (referrerPolicy !== false) headers.set("Referrer-Policy", referrerPolicy);
|
||||
|
||||
const configuredPermissions = security?.permissionsPolicy;
|
||||
const permissionsPolicy =
|
||||
configuredPermissions === false
|
||||
? false
|
||||
: { ...DEFAULT_PERMISSIONS_POLICY, ...(configuredPermissions ?? {}) };
|
||||
if (permissionsPolicy !== false) {
|
||||
const value = serializePermissionsPolicy(permissionsPolicy);
|
||||
if (value) headers.set("Permissions-Policy", value);
|
||||
}
|
||||
|
||||
const csp = serializeCsp(mode, security, nonce);
|
||||
if (csp) {
|
||||
const cspConfig = security?.contentSecurityPolicy;
|
||||
const reportOnly = typeof cspConfig === "object" && cspConfig.reportOnly === true;
|
||||
headers.set(
|
||||
reportOnly ? "Content-Security-Policy-Report-Only" : "Content-Security-Policy",
|
||||
csp,
|
||||
);
|
||||
}
|
||||
|
||||
const hsts = security?.hsts;
|
||||
const hstsEnabled =
|
||||
hsts !== false &&
|
||||
(mode === "production" || (typeof hsts === "object" && hsts.enabled === true));
|
||||
if (hstsEnabled) {
|
||||
headers.set("Strict-Transport-Security", serializeHsts(typeof hsts === "object" ? hsts : {}));
|
||||
}
|
||||
}
|
||||
|
||||
let warnedCredentialsWildcard = false;
|
||||
|
||||
function normalizeCors(cors: SecurityConfig["cors"]): CorsConfig & { enabled: boolean } {
|
||||
if (cors === true) return { enabled: true, origin: "*" };
|
||||
if (!cors) return { enabled: false };
|
||||
const normalized = { ...cors, enabled: cors.enabled !== false };
|
||||
// `*` + credentials would reflect ANY origin back with credentials allowed —
|
||||
// effectively disabling the same-origin policy. Refuse the combination and
|
||||
// drop credentials so it degrades to a safe public (non-credentialed) API.
|
||||
if (normalized.credentials && (normalized.origin ?? "*") === "*") {
|
||||
if (!warnedCredentialsWildcard) {
|
||||
warnedCredentialsWildcard = true;
|
||||
console.warn(
|
||||
'[wrnexus] CORS `credentials: true` cannot be combined with `origin: "*"`; ' +
|
||||
"credentials disabled. Set an explicit origin allowlist to use credentials.",
|
||||
);
|
||||
}
|
||||
normalized.credentials = false;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function applyCorsHeaders(req: Request, headers: Headers, cors: CorsConfig): boolean {
|
||||
const origin = req.headers.get("origin");
|
||||
if (!origin) return true;
|
||||
|
||||
const allowOrigin = resolveAllowedOrigin(origin, cors);
|
||||
if (!allowOrigin) return false;
|
||||
|
||||
headers.set("Access-Control-Allow-Origin", allowOrigin);
|
||||
appendVary(headers, "Origin");
|
||||
|
||||
if (cors.credentials) headers.set("Access-Control-Allow-Credentials", "true");
|
||||
if (cors.exposedHeaders?.length) {
|
||||
headers.set("Access-Control-Expose-Headers", cors.exposedHeaders.join(", "));
|
||||
}
|
||||
|
||||
if (req.method.toUpperCase() === "OPTIONS") {
|
||||
headers.set("Access-Control-Allow-Methods", (cors.methods ?? DEFAULT_CORS_METHODS).join(", "));
|
||||
|
||||
const requestedHeaders = req.headers.get("access-control-request-headers");
|
||||
const allowedHeaders = cors.allowedHeaders?.join(", ") ?? requestedHeaders;
|
||||
if (allowedHeaders) headers.set("Access-Control-Allow-Headers", allowedHeaders);
|
||||
if (typeof cors.maxAge === "number") {
|
||||
headers.set("Access-Control-Max-Age", String(Math.max(0, Math.floor(cors.maxAge))));
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveAllowedOrigin(origin: string, cors: CorsConfig): string | null {
|
||||
const configured = cors.origin ?? "*";
|
||||
if (configured === "*") return cors.credentials ? origin : "*";
|
||||
if (typeof configured === "string") return configured === origin ? origin : null;
|
||||
return configured.includes(origin) ? origin : null;
|
||||
}
|
||||
|
||||
function appendVary(headers: Headers, value: string): void {
|
||||
const existing = headers.get("Vary");
|
||||
if (!existing) {
|
||||
headers.set("Vary", value);
|
||||
return;
|
||||
}
|
||||
|
||||
const values = existing.split(",").map((item) => item.trim().toLowerCase());
|
||||
if (!values.includes(value.toLowerCase())) headers.set("Vary", `${existing}, ${value}`);
|
||||
}
|
||||
|
||||
function serializeCsp(mode: Mode, security?: SecurityConfig, nonce?: string): string {
|
||||
const config = security?.contentSecurityPolicy;
|
||||
if (config === false || config?.enabled === false) return "";
|
||||
|
||||
const directives = new Map<string, string[]>();
|
||||
if (config?.useDefaults !== false) {
|
||||
for (const [name, value] of Object.entries(DEFAULT_CSP)) {
|
||||
directives.set(name, [...value]);
|
||||
}
|
||||
if (mode === "development") {
|
||||
directives.set("script-src", ["'self'", "'unsafe-inline'"]);
|
||||
} else {
|
||||
directives.set("upgrade-insecure-requests", []);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, value] of Object.entries(config?.directives ?? {})) {
|
||||
if (value === false || value === null) {
|
||||
directives.delete(name);
|
||||
continue;
|
||||
}
|
||||
if (value === undefined) continue;
|
||||
directives.set(name, Array.isArray(value) ? value : value.split(/\s+/).filter(Boolean));
|
||||
}
|
||||
|
||||
// A per-request nonce lets inline framework scripts run under a strict policy:
|
||||
// add 'nonce-…' to script-src and drop 'unsafe-inline' (browsers ignore
|
||||
// 'unsafe-inline' when a nonce is present anyway).
|
||||
if (nonce) {
|
||||
const scriptSrc = directives.get("script-src") ?? ["'self'"];
|
||||
directives.set("script-src", [
|
||||
...scriptSrc.filter((v) => v !== "'unsafe-inline'"),
|
||||
`'nonce-${nonce}'`,
|
||||
]);
|
||||
}
|
||||
|
||||
applyTrustedTypesDirectives(directives, mode, security?.trustedTypes);
|
||||
|
||||
return [...directives.entries()]
|
||||
.map(([name, values]) => (values.length ? `${name} ${values.join(" ")}` : name))
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
function applyTrustedTypesDirectives(
|
||||
directives: Map<string, string[]>,
|
||||
mode: Mode,
|
||||
trustedTypes: SecurityConfig["trustedTypes"],
|
||||
): void {
|
||||
if (trustedTypes === false) return;
|
||||
|
||||
const enabled =
|
||||
typeof trustedTypes === "object" ? trustedTypes.enabled !== false : mode === "production";
|
||||
if (!enabled) return;
|
||||
|
||||
const policyNames =
|
||||
typeof trustedTypes === "object" && trustedTypes.policyNames?.length
|
||||
? trustedTypes.policyNames
|
||||
: ["*"];
|
||||
const trustedTypesValues = [...policyNames];
|
||||
if (typeof trustedTypes === "object" && trustedTypes.allowDuplicates) {
|
||||
trustedTypesValues.push("'allow-duplicates'");
|
||||
}
|
||||
directives.set("trusted-types", trustedTypesValues);
|
||||
|
||||
const requireForScript =
|
||||
typeof trustedTypes === "object" ? trustedTypes.requireForScript !== false : true;
|
||||
if (requireForScript) directives.set("require-trusted-types-for", ["'script'"]);
|
||||
}
|
||||
|
||||
function serializeHsts(config: HstsConfig): string {
|
||||
const parts = [`max-age=${config.maxAge ?? 31536000}`];
|
||||
if (config.includeSubDomains !== false) parts.push("includeSubDomains");
|
||||
if (config.preload !== false) parts.push("preload");
|
||||
return parts.join("; ");
|
||||
}
|
||||
|
||||
function serializePermissionsPolicy(policy: PermissionsPolicyConfig): string {
|
||||
return Object.entries(policy)
|
||||
.flatMap(([feature, value]) => {
|
||||
if (value === false || value === null || value === undefined) return [];
|
||||
if (typeof value === "string") return [`${feature}=${value}`];
|
||||
return [`${feature}=(${value.join(" ")})`];
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @wrnexus/core — shared types and primitives used by every other package.
|
||||
*/
|
||||
export type {
|
||||
Context,
|
||||
Next,
|
||||
Middleware,
|
||||
PageMeta,
|
||||
PageComponent,
|
||||
SeoConfig,
|
||||
TFunction,
|
||||
} from "./context.ts";
|
||||
export { createContext, withContextHeaders } from "./context.ts";
|
||||
|
||||
export { escapeHtml, isSafeIslandName, isSafeRequestPath } from "./security.ts";
|
||||
export { csrfToken, verifyCsrf, csrfProtection, CSRF_COOKIE, CSRF_HEADER } from "./csrf.ts";
|
||||
|
||||
export {
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
logIn,
|
||||
logOut,
|
||||
getUser,
|
||||
sessionAuth,
|
||||
requireAuth,
|
||||
SESSION_USER_KEY,
|
||||
} from "./auth.ts";
|
||||
export type { RequireAuthOptions } from "./auth.ts";
|
||||
|
||||
export { rateLimit, peerKey, proxyKey, defaultKey } from "./ratelimit.ts";
|
||||
export type { RateLimitOptions, RateLimitStore, Bucket } from "./ratelimit.ts";
|
||||
|
||||
export { requestLogger } from "./logging.ts";
|
||||
export type { RequestLoggerOptions, RequestRecord } from "./logging.ts";
|
||||
|
||||
export { TTLCache, cacheControl, withCacheControl, etag, notModified } from "./cache.ts";
|
||||
export type { CacheControlOptions } from "./cache.ts";
|
||||
|
||||
export { saveUpload, collectUploads, sanitizeFilename, UploadError } from "./uploads.ts";
|
||||
export type { SaveUploadOptions, SavedUpload } from "./uploads.ts";
|
||||
|
||||
export { streamResponse, sse } from "./stream.ts";
|
||||
export type { StreamResponseInit, ServerSentEvent } from "./stream.ts";
|
||||
|
||||
export {
|
||||
defineRoom,
|
||||
isRoomDefinition,
|
||||
createRealtimeRegistry,
|
||||
bridgeRealtime,
|
||||
} from "./realtime.ts";
|
||||
export type {
|
||||
RealtimeBus,
|
||||
RealtimeSocket,
|
||||
RealtimeHandler,
|
||||
RawSocket,
|
||||
Room,
|
||||
RoomClient,
|
||||
RoomHandlers,
|
||||
RoomAuthInfo,
|
||||
RoomDefinition,
|
||||
Target,
|
||||
RealtimeRegistry,
|
||||
RealtimeConnectMeta,
|
||||
RealtimeBridge,
|
||||
RealtimeEnvelope,
|
||||
} from "./realtime.ts";
|
||||
|
||||
export type { Mode } from "./errors.ts";
|
||||
export {
|
||||
renderDevError,
|
||||
renderProdError,
|
||||
renderError,
|
||||
renderNotFound,
|
||||
renderStatusPage,
|
||||
} from "./errors.ts";
|
||||
|
||||
export type {
|
||||
ContentSecurityPolicyConfig,
|
||||
CorsConfig,
|
||||
CorsOrigin,
|
||||
CspDirectiveValue,
|
||||
HstsConfig,
|
||||
PermissionsPolicyConfig,
|
||||
SecurityConfig,
|
||||
TrustedTypesConfig,
|
||||
} from "./headers.ts";
|
||||
export {
|
||||
createCorsPreflightResponse,
|
||||
withSecurityHeaders,
|
||||
isWebSocketOriginAllowed,
|
||||
resolveRequestUrl,
|
||||
} from "./headers.ts";
|
||||
|
||||
export type {
|
||||
CookieOptions,
|
||||
CookieStore,
|
||||
LocalStorageSnapshot,
|
||||
SessionStore,
|
||||
SessionBackend,
|
||||
SessionEntry,
|
||||
AsyncSessionBackend,
|
||||
} from "./storage.ts";
|
||||
export { setSessionBackend, loadSession } from "./storage.ts";
|
||||
|
||||
export { Fragment, Html, jsx, jsxs, mustache } from "./jsx-runtime.ts";
|
||||
export type { Component as JSXComponent, Props as JSXProps, Renderable } from "./jsx-runtime.ts";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Fragment, jsx as jsxDEV } from "./jsx-runtime.ts";
|
||||
export type { JSX } from "./jsx-runtime.ts";
|
||||
@@ -0,0 +1,175 @@
|
||||
import { escapeHtml } from "./security.ts";
|
||||
|
||||
export type Renderable = Html | string | number | boolean | null | undefined | Renderable[];
|
||||
|
||||
export type Props = Record<string, unknown> & {
|
||||
children?: Renderable;
|
||||
dangerouslySetInnerHTML?: { __html?: unknown };
|
||||
};
|
||||
|
||||
export type Component<P extends Props = Props> = (props: P) => Renderable;
|
||||
export type ElementType = string | Component | typeof Fragment;
|
||||
|
||||
export class Html {
|
||||
constructor(public readonly html: string) {}
|
||||
|
||||
toString(): string {
|
||||
return this.html;
|
||||
}
|
||||
}
|
||||
|
||||
export const Fragment = Symbol.for("wrnexus.fragment");
|
||||
|
||||
const VOID_ELEMENTS = new Set([
|
||||
"area",
|
||||
"base",
|
||||
"br",
|
||||
"col",
|
||||
"embed",
|
||||
"hr",
|
||||
"img",
|
||||
"input",
|
||||
"link",
|
||||
"meta",
|
||||
"param",
|
||||
"source",
|
||||
"track",
|
||||
"wbr",
|
||||
]);
|
||||
const SAFE_TAG_NAME = /^[A-Za-z][A-Za-z0-9._:-]*$/;
|
||||
const SAFE_ATTR_NAME = /^[A-Za-z_:][A-Za-z0-9:._-]*$/;
|
||||
|
||||
function isHtml(value: unknown): value is Html {
|
||||
return value instanceof Html;
|
||||
}
|
||||
|
||||
function raw(value: string): Html {
|
||||
return new Html(value);
|
||||
}
|
||||
|
||||
export function mustache(expr: string): Html;
|
||||
export function mustache(strings: TemplateStringsArray, ...values: unknown[]): Html;
|
||||
export function mustache(input: string | TemplateStringsArray, ...values: unknown[]): Html {
|
||||
const expr =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input.reduce((out, part, index) => {
|
||||
const value = index < values.length ? String(values[index]) : "";
|
||||
return out + part + value;
|
||||
}, "");
|
||||
|
||||
return raw(`{{${expr.trim()}}}`);
|
||||
}
|
||||
|
||||
function renderChild(value: Renderable): string {
|
||||
if (value === null || value === undefined || typeof value === "boolean") return "";
|
||||
if (Array.isArray(value)) return value.map(renderChild).join("");
|
||||
if (isHtml(value)) return value.html;
|
||||
return escapeHtml(String(value));
|
||||
}
|
||||
|
||||
function renderComponentResult(value: Renderable): string {
|
||||
if (value === null || value === undefined || typeof value === "boolean") return "";
|
||||
if (Array.isArray(value)) return value.map(renderComponentResult).join("");
|
||||
if (isHtml(value)) return value.html;
|
||||
|
||||
// WrNexus page/component strings are HTML by convention.
|
||||
if (typeof value === "string") return value;
|
||||
return escapeHtml(String(value));
|
||||
}
|
||||
|
||||
function attrName(name: string): string {
|
||||
if (name === "className") return "class";
|
||||
if (name === "htmlFor") return "for";
|
||||
return name;
|
||||
}
|
||||
|
||||
function styleToString(value: Record<string, unknown>): string {
|
||||
return Object.entries(value)
|
||||
.filter(([, v]) => v !== null && v !== undefined && v !== false)
|
||||
.map(([k, v]) => `${k.replace(/[A-Z]/g, (ch) => `-${ch.toLowerCase()}`)}: ${String(v)}`)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
function renderAttrs(props: Props): string {
|
||||
const attrs: string[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
if (
|
||||
key === "children" ||
|
||||
key === "key" ||
|
||||
key === "ref" ||
|
||||
key === "dangerouslySetInnerHTML" ||
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
value === false
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === "function") continue;
|
||||
|
||||
const name = attrName(key);
|
||||
if (!SAFE_ATTR_NAME.test(name)) continue;
|
||||
if (value === true) {
|
||||
attrs.push(name);
|
||||
continue;
|
||||
}
|
||||
|
||||
const rendered =
|
||||
key === "style" && typeof value === "object" && !Array.isArray(value)
|
||||
? styleToString(value as Record<string, unknown>)
|
||||
: String(value);
|
||||
|
||||
attrs.push(`${name}="${escapeHtml(rendered)}"`);
|
||||
}
|
||||
|
||||
return attrs.length ? ` ${attrs.join(" ")}` : "";
|
||||
}
|
||||
|
||||
export function jsx(type: ElementType, props: Props | null): Html {
|
||||
const safeProps = props ?? {};
|
||||
|
||||
if (type === Fragment) {
|
||||
return raw(renderChild(safeProps.children));
|
||||
}
|
||||
|
||||
if (typeof type === "function") {
|
||||
return raw(renderComponentResult(type(safeProps)));
|
||||
}
|
||||
|
||||
if (!SAFE_TAG_NAME.test(type)) throw new TypeError(`Invalid JSX tag name: ${type}`);
|
||||
|
||||
const attrs = renderAttrs(safeProps);
|
||||
if (VOID_ELEMENTS.has(type)) {
|
||||
return raw(`<${type}${attrs}>`);
|
||||
}
|
||||
|
||||
const children =
|
||||
safeProps.dangerouslySetInnerHTML && "__html" in safeProps.dangerouslySetInnerHTML
|
||||
? String(safeProps.dangerouslySetInnerHTML.__html ?? "")
|
||||
: renderChild(safeProps.children);
|
||||
|
||||
return raw(`<${type}${attrs}>${children}</${type}>`);
|
||||
}
|
||||
|
||||
export const jsxs = jsx;
|
||||
|
||||
// TypeScript's automatic JSX runtime looks for this exported namespace.
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
export namespace JSX {
|
||||
export type Element = Html;
|
||||
export type ElementType = string | Component;
|
||||
|
||||
export interface ElementChildrenAttribute {
|
||||
children: unknown;
|
||||
}
|
||||
|
||||
export interface IntrinsicAttributes {
|
||||
key?: string | number;
|
||||
}
|
||||
|
||||
export interface IntrinsicElements {
|
||||
[tagName: string]: Props;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Structured request logging middleware. Emits one record per request with a
|
||||
* request id, method, path, status, and duration — as pretty text (dev) or JSON
|
||||
* (production/log aggregation). The request id is stored on `ctx.locals` so
|
||||
* downstream handlers can correlate their own logs.
|
||||
*/
|
||||
|
||||
import type { Context, Middleware } from "./context.ts";
|
||||
|
||||
export interface RequestRecord {
|
||||
time: string;
|
||||
id: string;
|
||||
method: string;
|
||||
path: string;
|
||||
status: number;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface RequestLoggerOptions {
|
||||
/** "pretty" (default) for humans, "json" for machines. */
|
||||
format?: "pretty" | "json";
|
||||
/** Where each finished record goes. Default console.log. */
|
||||
sink?: (line: string, record: RequestRecord) => void;
|
||||
/** ctx.locals key for the request id. Default "requestId". */
|
||||
requestIdKey?: string;
|
||||
/** Clock injection for tests. Default Date.now. */
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export function requestLogger(options: RequestLoggerOptions = {}): Middleware {
|
||||
const format = options.format ?? "pretty";
|
||||
const sink = options.sink ?? ((line) => console.log(line));
|
||||
const idKey = options.requestIdKey ?? "requestId";
|
||||
const now = options.now ?? Date.now;
|
||||
|
||||
return async (ctx: Context, next) => {
|
||||
const start = now();
|
||||
const id = (ctx.locals[idKey] as string | undefined) ?? crypto.randomUUID();
|
||||
ctx.locals[idKey] = id;
|
||||
|
||||
let status = 500;
|
||||
try {
|
||||
const res = await next();
|
||||
status = res.status;
|
||||
return res;
|
||||
} finally {
|
||||
const record: RequestRecord = {
|
||||
time: new Date(start).toISOString(),
|
||||
id,
|
||||
method: ctx.req.method,
|
||||
path: ctx.url.pathname,
|
||||
status,
|
||||
durationMs: now() - start,
|
||||
};
|
||||
sink(format === "json" ? JSON.stringify(record) : formatPretty(record), record);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function formatPretty(r: RequestRecord): string {
|
||||
return `${r.method} ${r.path} → ${r.status} ${r.durationMs}ms [${r.id.slice(0, 8)}]`;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Fixed-window rate limiting middleware. Keeps an in-memory counter per key
|
||||
* (client IP by default, read from `x-forwarded-for` / `x-real-ip`) and rejects
|
||||
* requests over the limit with a 429 and a `Retry-After` header. Sets the
|
||||
* `RateLimit-Limit` / `RateLimit-Remaining` / `RateLimit-Reset` headers.
|
||||
*
|
||||
* The store is process-local; behind multiple instances use a shared store
|
||||
* (out of scope here). Suitable as-is for single-process apps and dev.
|
||||
*/
|
||||
|
||||
import type { Context, Middleware } from "./context.ts";
|
||||
|
||||
export interface RateLimitOptions {
|
||||
/** Window length in milliseconds. Default 60_000 (1 minute). */
|
||||
windowMs?: number;
|
||||
/** Max requests allowed per key per window. Default 60. */
|
||||
max?: number;
|
||||
/** Derive the bucket key from the request. Default: client IP. */
|
||||
key?: (ctx: Context) => string;
|
||||
/**
|
||||
* Trust `x-forwarded-for` / `x-real-ip` for the client IP. Default false —
|
||||
* those headers are attacker-spoofable, so by default we key on the direct
|
||||
* socket peer (`ctx.ip`). Enable ONLY when behind a proxy that overwrites
|
||||
* these headers (nginx, a load balancer, Cloudflare).
|
||||
*/
|
||||
trustProxy?: boolean;
|
||||
/** Body returned on 429. Default "Too Many Requests". */
|
||||
message?: string;
|
||||
/** Emit RateLimit-* headers. Default true. */
|
||||
headers?: boolean;
|
||||
/** Persistence for the counters. Default: process-local memory. */
|
||||
store?: RateLimitStore;
|
||||
/** Maximum in-memory keys before oldest buckets are evicted. Ignored for custom stores. */
|
||||
maxKeys?: number;
|
||||
}
|
||||
|
||||
export interface Bucket {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pluggable rate-limit counter store. The default is process-local memory; swap
|
||||
* in a shared store (Redis/SQL) so limits hold across instances. `hit` records
|
||||
* one request for `key` in the current window and returns the running bucket.
|
||||
* It may be async (e.g. a Redis INCR + PEXPIRE) — the middleware awaits it.
|
||||
*/
|
||||
export interface RateLimitStore {
|
||||
hit(key: string, windowMs: number, now: number): Bucket | Promise<Bucket>;
|
||||
}
|
||||
|
||||
function createMemoryRateLimitStore(maxKeys: number): RateLimitStore {
|
||||
const buckets = new Map<string, Bucket>();
|
||||
return {
|
||||
hit(key, windowMs, now) {
|
||||
let bucket = buckets.get(key);
|
||||
if (!bucket || bucket.resetAt <= now) {
|
||||
if (!bucket && buckets.size >= maxKeys) {
|
||||
for (const [k, b] of buckets) if (b.resetAt <= now) buckets.delete(k);
|
||||
while (buckets.size >= maxKeys) buckets.delete(buckets.keys().next().value!);
|
||||
}
|
||||
bucket = { count: 0, resetAt: now + windowMs };
|
||||
buckets.set(key, bucket);
|
||||
}
|
||||
bucket.count++;
|
||||
return bucket;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function rateLimit(options: RateLimitOptions = {}): Middleware {
|
||||
const windowMs = options.windowMs ?? 60_000;
|
||||
const max = options.max ?? 60;
|
||||
const emitHeaders = options.headers ?? true;
|
||||
const maxKeys = options.maxKeys ?? 10_000;
|
||||
if (!Number.isInteger(maxKeys) || maxKeys < 1)
|
||||
throw new RangeError("rateLimit maxKeys must be a positive integer");
|
||||
const keyOf = options.key ?? (options.trustProxy ? proxyKey : peerKey);
|
||||
const store = options.store ?? createMemoryRateLimitStore(maxKeys);
|
||||
|
||||
return async (ctx, next) => {
|
||||
const now = Date.now();
|
||||
const bucket = await store.hit(keyOf(ctx), windowMs, now);
|
||||
|
||||
const resetSec = Math.max(0, Math.ceil((bucket.resetAt - now) / 1000));
|
||||
const remaining = Math.max(0, max - bucket.count);
|
||||
|
||||
if (bucket.count > max) {
|
||||
const res = new Response(options.message ?? "Too Many Requests", {
|
||||
status: 429,
|
||||
headers: { "content-type": "text/plain", "retry-after": String(resetSec) },
|
||||
});
|
||||
if (emitHeaders) applyHeaders(res, max, 0, resetSec);
|
||||
return res;
|
||||
}
|
||||
|
||||
const res = await next();
|
||||
if (emitHeaders) applyHeaders(res, max, remaining, resetSec);
|
||||
return res;
|
||||
};
|
||||
}
|
||||
|
||||
function applyHeaders(res: Response, limit: number, remaining: number, resetSec: number): void {
|
||||
try {
|
||||
res.headers.set("RateLimit-Limit", String(limit));
|
||||
res.headers.set("RateLimit-Remaining", String(remaining));
|
||||
res.headers.set("RateLimit-Reset", String(resetSec));
|
||||
} catch {
|
||||
/* immutable response — skip */
|
||||
}
|
||||
}
|
||||
|
||||
/** Non-spoofable key: the direct socket peer IP (set by the server). */
|
||||
export function peerKey(ctx: Context): string {
|
||||
return ctx.ip ?? "global";
|
||||
}
|
||||
|
||||
/** Proxy-aware key: trusts `x-forwarded-for` / `x-real-ip`, else the peer IP. */
|
||||
export function proxyKey(ctx: Context): string {
|
||||
const xff = ctx.req.headers.get("x-forwarded-for");
|
||||
if (xff) return xff.split(",")[0]!.trim();
|
||||
return ctx.req.headers.get("x-real-ip") ?? ctx.ip ?? "global";
|
||||
}
|
||||
|
||||
/** @deprecated Use `peerKey` (default) or `proxyKey`. Kept for compatibility. */
|
||||
export const defaultKey = proxyKey;
|
||||
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* Realtime rooms.
|
||||
*
|
||||
* A file in `app/realtime/` exports `default defineRoom({ onConnect, onMessage,
|
||||
* onLeave })` and is served at `ws://host/realtime/<name>`. The framework's
|
||||
* client runtime (`/__wrnexus/realtime.js`) handles the browser side, so pages
|
||||
* ship NO hand-written WebSocket code.
|
||||
*
|
||||
* Handlers get a `RoomClient` with everything you need:
|
||||
* client.send(msg) → this connection
|
||||
* client.broadcast(msg) → everyone else in the room
|
||||
* client.room.broadcast(msg) → everyone (incl. sender)
|
||||
* client.to(id | ids).send(msg) → specific connection(s)
|
||||
* client.toUser(u | users).send() → a user / selected users (all their tabs)
|
||||
* client.user = "u1" → identify a connection for targeting
|
||||
* client.data / client.room.state → per-connection / shared room state
|
||||
*
|
||||
* The dynamic route `app/realtime/[room].ts` gives one handler many independent
|
||||
* rooms — `/realtime/lobby` and `/realtime/game-7` are separate room instances.
|
||||
*/
|
||||
|
||||
// --- Low-level socket the registry drives (a subset of Bun's ServerWebSocket) ---
|
||||
|
||||
export interface RawSocket {
|
||||
send(data: string): unknown;
|
||||
close(code?: number, reason?: string): void;
|
||||
}
|
||||
|
||||
// --- Legacy raw handler (still supported alongside defineRoom) ---
|
||||
|
||||
export interface RealtimeSocket<Data = unknown> {
|
||||
readonly data: Data;
|
||||
send(data: string | Uint8Array): number;
|
||||
subscribe(topic: string): void;
|
||||
unsubscribe(topic: string): void;
|
||||
publish(topic: string, data: string | Uint8Array): number;
|
||||
isSubscribed(topic: string): boolean;
|
||||
close(code?: number, reason?: string): void;
|
||||
}
|
||||
|
||||
export interface RealtimeHandler<Data = unknown> {
|
||||
open?(ws: RealtimeSocket<Data>): void | Promise<void>;
|
||||
message?(ws: RealtimeSocket<Data>, message: string | Uint8Array): void | Promise<void>;
|
||||
close?(ws: RealtimeSocket<Data>, code?: number, reason?: string): void | Promise<void>;
|
||||
drain?(ws: RealtimeSocket<Data>): void | Promise<void>;
|
||||
}
|
||||
|
||||
// --- Room API ---
|
||||
|
||||
export interface Target {
|
||||
/** Send a message (objects are JSON-serialized). */
|
||||
send(message: unknown): void;
|
||||
}
|
||||
|
||||
export interface Room<TData = Record<string, unknown>> {
|
||||
readonly name: string;
|
||||
/** Shared, in-memory room state (lives while ≥1 client is connected). */
|
||||
readonly state: Record<string, unknown>;
|
||||
/** All connected clients. */
|
||||
clients(): RoomClient<TData>[];
|
||||
/** Number of connected clients. */
|
||||
count(): number;
|
||||
/** Send to everyone in the room, including the sender. */
|
||||
broadcast(message: unknown): void;
|
||||
/** Target specific connection id(s). */
|
||||
to(id: string | string[]): Target;
|
||||
/** Target a user / users by identity (reaches all their connections). */
|
||||
toUser(user: string | string[]): Target;
|
||||
}
|
||||
|
||||
export interface RoomClient<TData = Record<string, unknown>> {
|
||||
/** Unique per connection (a tab). */
|
||||
readonly id: string;
|
||||
/** App identity for targeting; assign it in `onConnect`. */
|
||||
user: string | undefined;
|
||||
/** Query params from the connection URL. */
|
||||
readonly query: Record<string, string>;
|
||||
/** Per-connection scratch state. */
|
||||
readonly data: TData;
|
||||
readonly room: Room<TData>;
|
||||
/** Send to THIS connection. */
|
||||
send(message: unknown): void;
|
||||
/** Send to everyone else in the room. */
|
||||
broadcast(message: unknown): void;
|
||||
/** Target specific connection id(s). */
|
||||
to(id: string | string[]): Target;
|
||||
/** Target a user / users by identity. */
|
||||
toUser(user: string | string[]): Target;
|
||||
/** Close this connection. */
|
||||
close(code?: number, reason?: string): void;
|
||||
}
|
||||
|
||||
/** Info available when authorizing a connection, before it is accepted. */
|
||||
export interface RoomAuthInfo {
|
||||
/** Authenticated session user id, or `?user=` — undefined when anonymous. */
|
||||
user?: string;
|
||||
/** Connection URL query params. */
|
||||
query: Record<string, string>;
|
||||
/** The upgrade request's headers (cookies, etc.). */
|
||||
headers: Headers;
|
||||
}
|
||||
|
||||
export interface RoomHandlers<TData = Record<string, unknown>> {
|
||||
/**
|
||||
* Gate the connection BEFORE it is accepted. Return false to reject the
|
||||
* upgrade with 403 (e.g. `authorize: (info) => !!info.user` to require auth).
|
||||
*/
|
||||
authorize?(info: RoomAuthInfo): boolean | Promise<boolean>;
|
||||
/** A client connected (a new tab joined the room). */
|
||||
onConnect?(client: RoomClient<TData>): void | Promise<void>;
|
||||
/** A message arrived (JSON is parsed; non-JSON arrives as a string). */
|
||||
onMessage?(client: RoomClient<TData>, message: any): void | Promise<void>;
|
||||
/** A client disconnected. */
|
||||
onLeave?(client: RoomClient<TData>): void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface RoomDefinition<TData = Record<string, unknown>> {
|
||||
readonly __wrnexusRoom: true;
|
||||
readonly handlers: RoomHandlers<TData>;
|
||||
}
|
||||
|
||||
/** Define a realtime room. Export the result as the `default` of a realtime file. */
|
||||
export function defineRoom<TData = Record<string, unknown>>(
|
||||
handlers: RoomHandlers<TData>,
|
||||
): RoomDefinition<TData> {
|
||||
return { __wrnexusRoom: true, handlers };
|
||||
}
|
||||
|
||||
export function isRoomDefinition(value: unknown): value is RoomDefinition {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === "object" &&
|
||||
(value as { __wrnexusRoom?: unknown }).__wrnexusRoom === true
|
||||
);
|
||||
}
|
||||
|
||||
// --- Registry (server-side connection manager) ---
|
||||
|
||||
interface Conn {
|
||||
id: string;
|
||||
user?: string;
|
||||
data: Record<string, unknown>;
|
||||
query: Record<string, string>;
|
||||
socket: RawSocket;
|
||||
roomName: string;
|
||||
client: RoomClient;
|
||||
}
|
||||
|
||||
interface RoomImpl {
|
||||
name: string;
|
||||
state: Record<string, unknown>;
|
||||
def: RoomDefinition;
|
||||
conns: Map<string, Conn>;
|
||||
users: Map<string, Set<string>>; // user identity → connection ids
|
||||
}
|
||||
|
||||
export interface RealtimeConnectMeta {
|
||||
room: string;
|
||||
def: RoomDefinition;
|
||||
query?: Record<string, string>;
|
||||
user?: string;
|
||||
}
|
||||
|
||||
/** One cross-instance message: a room broadcast, or a targeted user send. */
|
||||
export interface RealtimeEnvelope {
|
||||
room: string;
|
||||
/** If set, deliver only to these user identities; otherwise the whole room. */
|
||||
users?: string[];
|
||||
message: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pub/sub bridge for horizontal scaling. Wire the registry to a shared bus
|
||||
* (Redis pub/sub, NATS, …): local broadcasts/`toUser` sends are published to
|
||||
* peers, and messages received from peers are delivered via `registry.deliver`.
|
||||
* Connection-targeted sends (`send`, `to(id)`) stay local (ids are per-process).
|
||||
*/
|
||||
export interface RealtimeBridge {
|
||||
publish(envelope: RealtimeEnvelope): void;
|
||||
}
|
||||
|
||||
export interface RealtimeRegistry {
|
||||
open(socket: RawSocket, meta: RealtimeConnectMeta): void | Promise<void>;
|
||||
message(socket: RawSocket, raw: string | Uint8Array): void | Promise<void>;
|
||||
close(socket: RawSocket): void | Promise<void>;
|
||||
/** Attach a cross-instance bridge (call once at startup). */
|
||||
setBridge(bridge: RealtimeBridge): void;
|
||||
/** Deliver an envelope received from a peer to LOCAL connections only. */
|
||||
deliver(envelope: RealtimeEnvelope): void;
|
||||
/** Number of live connections (across all rooms) — for tests/metrics. */
|
||||
size(): number;
|
||||
}
|
||||
|
||||
function serialize(message: unknown): string {
|
||||
return typeof message === "string" ? message : JSON.stringify(message);
|
||||
}
|
||||
|
||||
/** Create the registry that maps sockets ↔ rooms and drives room handlers. */
|
||||
export function createRealtimeRegistry(): RealtimeRegistry {
|
||||
const rooms = new Map<string, RoomImpl>();
|
||||
const bySocket = new Map<RawSocket, Conn>();
|
||||
let bridge: RealtimeBridge | null = null;
|
||||
let applyingRemote = false; // true while delivering a peer envelope (no re-publish)
|
||||
|
||||
const publish = (envelope: RealtimeEnvelope): void => {
|
||||
if (bridge && !applyingRemote) bridge.publish(envelope);
|
||||
};
|
||||
|
||||
const send = (conn: Conn | undefined, payload: string): void => {
|
||||
if (!conn) return;
|
||||
try {
|
||||
conn.socket.send(payload);
|
||||
} catch {
|
||||
/* socket already gone */
|
||||
}
|
||||
};
|
||||
|
||||
const reindexUser = (room: RoomImpl, conn: Conn, next: string | undefined): void => {
|
||||
if (conn.user === next) return;
|
||||
if (conn.user) {
|
||||
const set = room.users.get(conn.user);
|
||||
if (set) {
|
||||
set.delete(conn.id);
|
||||
if (!set.size) room.users.delete(conn.user);
|
||||
}
|
||||
}
|
||||
conn.user = next;
|
||||
if (next) {
|
||||
let set = room.users.get(next);
|
||||
if (!set) room.users.set(next, (set = new Set()));
|
||||
set.add(conn.id);
|
||||
}
|
||||
};
|
||||
|
||||
const idsForUsers = (room: RoomImpl, user: string | string[]): string[] => {
|
||||
const out: string[] = [];
|
||||
for (const u of Array.isArray(user) ? user : [user]) {
|
||||
const set = room.users.get(u);
|
||||
if (set) out.push(...set);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const makeRoomApi = (room: RoomImpl): Room => ({
|
||||
name: room.name,
|
||||
state: room.state,
|
||||
clients: () => Array.from(room.conns.values(), (c) => c.client),
|
||||
count: () => room.conns.size,
|
||||
broadcast: (message) => {
|
||||
const payload = serialize(message);
|
||||
for (const c of room.conns.values()) send(c, payload);
|
||||
publish({ room: room.name, message });
|
||||
},
|
||||
to: (id) => ({
|
||||
send: (message) => {
|
||||
// Connection-targeted: local only (ids are per-process).
|
||||
const payload = serialize(message);
|
||||
for (const cid of Array.isArray(id) ? id : [id]) send(room.conns.get(cid), payload);
|
||||
},
|
||||
}),
|
||||
toUser: (user) => ({
|
||||
send: (message) => {
|
||||
const payload = serialize(message);
|
||||
for (const cid of idsForUsers(room, user)) send(room.conns.get(cid), payload);
|
||||
publish({ room: room.name, users: Array.isArray(user) ? user : [user], message });
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const makeClientApi = (room: RoomImpl, conn: Conn): RoomClient => {
|
||||
const roomApi = makeRoomApi(room);
|
||||
return {
|
||||
id: conn.id,
|
||||
get user() {
|
||||
return conn.user;
|
||||
},
|
||||
set user(value: string | undefined) {
|
||||
reindexUser(room, conn, value);
|
||||
},
|
||||
query: conn.query,
|
||||
data: conn.data,
|
||||
room: roomApi,
|
||||
send: (message) => send(conn, serialize(message)),
|
||||
broadcast: (message) => {
|
||||
const payload = serialize(message);
|
||||
for (const c of room.conns.values()) if (c.id !== conn.id) send(c, payload);
|
||||
// Peers deliver to all their conns (all "others" relative to this one).
|
||||
publish({ room: room.name, message });
|
||||
},
|
||||
to: roomApi.to,
|
||||
toUser: roomApi.toUser,
|
||||
close: (code, reason) => conn.socket.close(code, reason),
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
async open(socket, meta) {
|
||||
let room = rooms.get(meta.room);
|
||||
if (!room) {
|
||||
room = { name: meta.room, state: {}, def: meta.def, conns: new Map(), users: new Map() };
|
||||
rooms.set(meta.room, room);
|
||||
}
|
||||
const conn: Conn = {
|
||||
id: randomId(),
|
||||
data: {},
|
||||
query: meta.query ?? {},
|
||||
socket,
|
||||
roomName: meta.room,
|
||||
client: null as unknown as RoomClient,
|
||||
};
|
||||
conn.client = makeClientApi(room, conn);
|
||||
room.conns.set(conn.id, conn);
|
||||
bySocket.set(socket, conn);
|
||||
if (meta.user) reindexUser(room, conn, meta.user);
|
||||
await room.def.handlers.onConnect?.(conn.client);
|
||||
},
|
||||
|
||||
async message(socket, raw) {
|
||||
const conn = bySocket.get(socket);
|
||||
if (!conn) return;
|
||||
const room = rooms.get(conn.roomName);
|
||||
if (!room) return;
|
||||
const text = typeof raw === "string" ? raw : new TextDecoder().decode(raw);
|
||||
let message: unknown;
|
||||
try {
|
||||
message = JSON.parse(text);
|
||||
} catch {
|
||||
message = text;
|
||||
}
|
||||
await room.def.handlers.onMessage?.(conn.client, message);
|
||||
},
|
||||
|
||||
async close(socket) {
|
||||
const conn = bySocket.get(socket);
|
||||
if (!conn) return;
|
||||
bySocket.delete(socket);
|
||||
const room = rooms.get(conn.roomName);
|
||||
if (!room) return;
|
||||
try {
|
||||
await room.def.handlers.onLeave?.(conn.client);
|
||||
} finally {
|
||||
room.conns.delete(conn.id);
|
||||
reindexUser(room, conn, undefined);
|
||||
if (room.conns.size === 0) rooms.delete(room.name);
|
||||
}
|
||||
},
|
||||
|
||||
setBridge(b) {
|
||||
bridge = b;
|
||||
},
|
||||
|
||||
deliver(envelope) {
|
||||
const room = rooms.get(envelope.room);
|
||||
if (!room) return;
|
||||
applyingRemote = true; // suppress re-publishing what we received
|
||||
try {
|
||||
const payload = serialize(envelope.message);
|
||||
if (envelope.users) {
|
||||
for (const cid of idsForUsers(room, envelope.users)) send(room.conns.get(cid), payload);
|
||||
} else {
|
||||
for (const c of room.conns.values()) send(c, payload);
|
||||
}
|
||||
} finally {
|
||||
applyingRemote = false;
|
||||
}
|
||||
},
|
||||
|
||||
size: () => bySocket.size,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A minimal pub/sub bus (structurally satisfied by `@wrnexus/pubsub`). Used to
|
||||
* bridge realtime broadcasts across processes without a hard dependency.
|
||||
*/
|
||||
export interface RealtimeBus {
|
||||
publish(topic: string, message: unknown): void | Promise<void>;
|
||||
subscribe(topic: string, handler: (message: unknown, topic: string) => void): () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge a realtime registry across processes/instances via a pub/sub bus (use
|
||||
* the Redis driver so it crosses machines). After this, `client.room.broadcast`
|
||||
* and `client.toUser(...)` reach connected clients on **every** app process/
|
||||
* instance subscribed to the same bus — the foundation for realtime that works
|
||||
* with multiple running apps behind the gateway. Connection-targeted sends
|
||||
* (`send`, `to(id)`) stay local. Returns an unsubscribe function.
|
||||
*
|
||||
* import { createRealtimeRegistry, bridgeRealtime } from "@wrnexus/core";
|
||||
* import { createPubSub } from "@wrnexus/pubsub";
|
||||
* import { redisDriver } from "@wrnexus/pubsub/redis";
|
||||
* bridgeRealtime(registry, createPubSub(redisDriver(process.env.REDIS_URL)));
|
||||
*/
|
||||
export function bridgeRealtime(
|
||||
registry: RealtimeRegistry,
|
||||
bus: RealtimeBus,
|
||||
topic = "wrnexus:realtime",
|
||||
): () => void {
|
||||
registry.setBridge({ publish: (envelope) => void bus.publish(topic, envelope) });
|
||||
return bus.subscribe(topic, (message) => registry.deliver(message as RealtimeEnvelope));
|
||||
}
|
||||
|
||||
function randomId(): string {
|
||||
const bytes = new Uint8Array(12);
|
||||
crypto.getRandomValues(bytes);
|
||||
let out = "";
|
||||
for (const b of bytes) out += b.toString(16).padStart(2, "0");
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Small, dependency-free security helpers shared across packages.
|
||||
*/
|
||||
|
||||
const HTML_ESCAPES: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
|
||||
/**
|
||||
* Escape a string for safe interpolation into HTML text or attributes.
|
||||
* Used for page metadata (title/description) so untrusted values can't
|
||||
* break out of an attribute or inject markup.
|
||||
*/
|
||||
export function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch]!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Client island names come from `data-client="..."` attributes and from
|
||||
* filenames in `app/client`. We only ever allow a conservative charset so a
|
||||
* name can never be used to traverse the filesystem or inject code.
|
||||
*/
|
||||
const SAFE_NAME = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
export function isSafeIslandName(name: string): boolean {
|
||||
return SAFE_NAME.test(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject obvious path-traversal in a request path before it is ever used to
|
||||
* resolve a file. The router never builds file paths from request input
|
||||
* (routes are resolved against a pre-scanned table), but this is a cheap
|
||||
* defense-in-depth guard.
|
||||
*/
|
||||
export function isSafeRequestPath(pathname: string): boolean {
|
||||
if (pathname.includes("\0")) return false;
|
||||
// Reject `..` segments and backslashes that could escape a directory.
|
||||
const decoded = safeDecode(pathname);
|
||||
if (decoded === null) return false;
|
||||
return !/(^|\/)\.\.(\/|$)/.test(decoded) && !decoded.includes("\\");
|
||||
}
|
||||
|
||||
function safeDecode(value: string): string | null {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
import type { Context, Middleware } from "./context.ts";
|
||||
|
||||
export interface CookieOptions {
|
||||
path?: string;
|
||||
domain?: string;
|
||||
maxAge?: number;
|
||||
expires?: Date | string;
|
||||
httpOnly?: boolean;
|
||||
secure?: boolean;
|
||||
sameSite?: "Strict" | "Lax" | "None" | "strict" | "lax" | "none";
|
||||
}
|
||||
|
||||
export interface CookieStore {
|
||||
get(name: string): string | undefined;
|
||||
getAll(): Record<string, string>;
|
||||
has(name: string): boolean;
|
||||
set(name: string, value: string, options?: CookieOptions): void;
|
||||
delete(name: string, options?: CookieOptions): void;
|
||||
headers(): string[];
|
||||
}
|
||||
|
||||
export interface SessionStore {
|
||||
id(): string;
|
||||
get<T = unknown>(key: string): T | undefined;
|
||||
getAll(): Record<string, unknown>;
|
||||
set(key: string, value: unknown): void;
|
||||
delete(key: string): void;
|
||||
/** Issue a fresh session id, keeping the data — defends against fixation. */
|
||||
regenerate(): void;
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
export interface LocalStorageSnapshot {
|
||||
get(key: string): string | undefined;
|
||||
getAll(): Record<string, string>;
|
||||
has(key: string): boolean;
|
||||
}
|
||||
|
||||
const SESSION_COOKIE = "wrnexus.sid";
|
||||
/** Idle timeout: a session expires this long after its last access. */
|
||||
const SESSION_TTL_MS = 1000 * 60 * 60 * 24; // 24 hours
|
||||
/** Run a background sweep after this many new sessions (bounds memory). */
|
||||
const SESSION_GC_EVERY = 500;
|
||||
|
||||
/** A stored session: its data plus an absolute expiry timestamp (ms). */
|
||||
export interface SessionEntry {
|
||||
data: Record<string, unknown>;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pluggable session persistence. The default is process-local memory; swap in a
|
||||
* shared backend (Redis, SQL, etc.) via `setSessionBackend` so sessions survive
|
||||
* restarts and work across multiple instances. Methods are synchronous, so a
|
||||
* backend must be sync (e.g. `bun:sqlite`); async stores need a load/save
|
||||
* wrapper around the request (future work).
|
||||
*/
|
||||
export interface SessionBackend {
|
||||
get(id: string): SessionEntry | undefined;
|
||||
set(id: string, entry: SessionEntry): void;
|
||||
delete(id: string): void;
|
||||
/** Optional: drop expired entries. Called periodically by the store. */
|
||||
gc?(now: number): void;
|
||||
}
|
||||
|
||||
function createMemorySessionBackend(): SessionBackend {
|
||||
const map = new Map<string, SessionEntry>();
|
||||
return {
|
||||
get: (id) => map.get(id),
|
||||
set: (id, entry) => void map.set(id, entry),
|
||||
delete: (id) => void map.delete(id),
|
||||
gc: (now) => {
|
||||
for (const [key, entry] of map) if (entry.expiresAt <= now) map.delete(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let sessionBackend: SessionBackend = createMemorySessionBackend();
|
||||
let sessionsSinceGc = 0;
|
||||
|
||||
/** Replace the session persistence backend (call once at startup). */
|
||||
export function setSessionBackend(backend: SessionBackend): void {
|
||||
sessionBackend = backend;
|
||||
}
|
||||
|
||||
/**
|
||||
* An ASYNC session store (Redis, a remote DB). Use it via the `loadSession`
|
||||
* middleware, which loads the session before the request and saves it after —
|
||||
* keeping the `ctx.session` API synchronous while persistence is shared across
|
||||
* instances.
|
||||
*/
|
||||
export interface AsyncSessionBackend {
|
||||
load(id: string): Promise<SessionEntry | undefined>;
|
||||
save(id: string, entry: SessionEntry): Promise<void>;
|
||||
destroy(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Back `ctx.session` with an async store. Register early (before anything reads
|
||||
* `ctx.session`). Loads once at the start of the request and saves once at the
|
||||
* end; regenerate/clear destroy the old id.
|
||||
*/
|
||||
export function loadSession(
|
||||
backend: AsyncSessionBackend,
|
||||
options: { ttlMs?: number } = {},
|
||||
): Middleware {
|
||||
const ttlMs = options.ttlMs ?? SESSION_TTL_MS;
|
||||
return async (ctx: Context, next) => {
|
||||
let id = ctx.cookies.get(SESSION_COOKIE);
|
||||
let entry = id ? await backend.load(id) : undefined;
|
||||
if (id && entry && entry.expiresAt <= Date.now()) {
|
||||
await backend.destroy(id);
|
||||
entry = undefined;
|
||||
id = undefined;
|
||||
} else if (id && !entry) {
|
||||
id = undefined; // unknown/expired id → anonymous
|
||||
}
|
||||
const destroys = new Set<string>();
|
||||
|
||||
const ensure = (): Record<string, unknown> => {
|
||||
if (!id) {
|
||||
id = randomId();
|
||||
ctx.cookies.set(SESSION_COOKIE, id, sessionCookieOptions(ctx.url.protocol === "https:"));
|
||||
}
|
||||
if (!entry) entry = { data: {}, expiresAt: Date.now() + ttlMs };
|
||||
return entry.data;
|
||||
};
|
||||
|
||||
ctx.session = {
|
||||
id() {
|
||||
ensure();
|
||||
return id!;
|
||||
},
|
||||
get<T = unknown>(key: string): T | undefined {
|
||||
return (entry?.data[key] as T | undefined) ?? undefined;
|
||||
},
|
||||
getAll() {
|
||||
return entry ? { ...entry.data } : {};
|
||||
},
|
||||
set(key, value) {
|
||||
ensure()[key] = value;
|
||||
},
|
||||
delete(key) {
|
||||
if (entry) delete entry.data[key];
|
||||
},
|
||||
regenerate() {
|
||||
const data = entry?.data ?? {};
|
||||
if (id) destroys.add(id);
|
||||
id = randomId();
|
||||
entry = { data, expiresAt: Date.now() + ttlMs };
|
||||
ctx.cookies.set(SESSION_COOKIE, id, sessionCookieOptions(ctx.url.protocol === "https:"));
|
||||
},
|
||||
clear() {
|
||||
if (id) destroys.add(id);
|
||||
entry = undefined;
|
||||
id = undefined;
|
||||
ctx.cookies.delete(SESSION_COOKIE, sessionCookieOptions(ctx.url.protocol === "https:"));
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
return await next();
|
||||
} finally {
|
||||
for (const gone of destroys) if (gone !== id) await backend.destroy(gone);
|
||||
if (id && entry) {
|
||||
entry.expiresAt = Date.now() + ttlMs;
|
||||
await backend.save(id, entry);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const COOKIE_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
||||
|
||||
/** Read a live (non-expired) session entry, sliding its expiry forward. */
|
||||
function readSessionEntry(id: string): SessionEntry | undefined {
|
||||
const entry = sessionBackend.get(id);
|
||||
if (!entry) return undefined;
|
||||
if (entry.expiresAt <= Date.now()) {
|
||||
sessionBackend.delete(id);
|
||||
return undefined;
|
||||
}
|
||||
entry.expiresAt = Date.now() + SESSION_TTL_MS; // sliding idle expiry
|
||||
sessionBackend.set(id, entry); // persist the slide (matters for external backends)
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function createCookieStore(req: Request): CookieStore {
|
||||
const incoming = parseCookieHeader(req.headers.get("cookie") ?? "");
|
||||
const outgoing: string[] = [];
|
||||
|
||||
return {
|
||||
get(name) {
|
||||
return incoming[name];
|
||||
},
|
||||
getAll() {
|
||||
return { ...incoming };
|
||||
},
|
||||
has(name) {
|
||||
return Object.prototype.hasOwnProperty.call(incoming, name);
|
||||
},
|
||||
set(name, value, options) {
|
||||
incoming[name] = value;
|
||||
outgoing.push(serializeCookie(name, value, { path: "/", ...options }));
|
||||
},
|
||||
delete(name, options) {
|
||||
delete incoming[name];
|
||||
outgoing.push(
|
||||
serializeCookie(name, "", {
|
||||
path: "/",
|
||||
...options,
|
||||
expires: new Date(0),
|
||||
maxAge: 0,
|
||||
}),
|
||||
);
|
||||
},
|
||||
headers() {
|
||||
return [...outgoing];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createSessionStore(
|
||||
cookies: CookieStore,
|
||||
req: Request,
|
||||
cookieName = SESSION_COOKIE,
|
||||
secure = new URL(req.url).protocol === "https:",
|
||||
): SessionStore {
|
||||
let id = cookies.get(cookieName);
|
||||
let entry = id ? readSessionEntry(id) : undefined;
|
||||
if (id && !entry) id = undefined; // expired or unknown → treat as anonymous
|
||||
|
||||
const persist = (): void => {
|
||||
if (id && entry) sessionBackend.set(id, entry);
|
||||
};
|
||||
|
||||
const ensure = (): Record<string, unknown> => {
|
||||
if (!id) {
|
||||
id = randomId();
|
||||
cookies.set(cookieName, id, sessionCookieOptions(secure));
|
||||
}
|
||||
entry = readSessionEntry(id);
|
||||
if (!entry) {
|
||||
if (++sessionsSinceGc >= SESSION_GC_EVERY) {
|
||||
sessionsSinceGc = 0;
|
||||
sessionBackend.gc?.(Date.now());
|
||||
}
|
||||
entry = { data: {}, expiresAt: Date.now() + SESSION_TTL_MS };
|
||||
sessionBackend.set(id, entry);
|
||||
}
|
||||
return entry.data;
|
||||
};
|
||||
|
||||
return {
|
||||
id() {
|
||||
ensure();
|
||||
return id!;
|
||||
},
|
||||
get<T = unknown>(key: string): T | undefined {
|
||||
return (entry?.data[key] as T | undefined) ?? undefined;
|
||||
},
|
||||
getAll() {
|
||||
return entry ? { ...entry.data } : {};
|
||||
},
|
||||
set(key, value) {
|
||||
ensure()[key] = value;
|
||||
persist();
|
||||
},
|
||||
delete(key) {
|
||||
if (entry) {
|
||||
delete entry.data[key];
|
||||
persist();
|
||||
}
|
||||
},
|
||||
regenerate() {
|
||||
// Session fixation defense: move existing data under a brand-new id and
|
||||
// reissue the cookie, so any pre-login id an attacker planted is void.
|
||||
const data = entry?.data ?? {};
|
||||
if (id) sessionBackend.delete(id);
|
||||
id = randomId();
|
||||
entry = { data, expiresAt: Date.now() + SESSION_TTL_MS };
|
||||
sessionBackend.set(id, entry);
|
||||
cookies.set(cookieName, id, sessionCookieOptions(secure));
|
||||
},
|
||||
clear() {
|
||||
if (id) sessionBackend.delete(id);
|
||||
entry = undefined;
|
||||
id = undefined;
|
||||
cookies.delete(cookieName, sessionCookieOptions(secure));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createLocalStorageSnapshot(req: Request): LocalStorageSnapshot {
|
||||
const values = parseLocalStorageHeader(req.headers.get("x-wrnexus-local-storage"));
|
||||
return {
|
||||
get(key) {
|
||||
return values[key];
|
||||
},
|
||||
getAll() {
|
||||
return { ...values };
|
||||
},
|
||||
has(key) {
|
||||
return Object.prototype.hasOwnProperty.call(values, key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applyCookieHeaders(ctx: { cookies?: CookieStore }, headers: Headers): void {
|
||||
for (const value of ctx.cookies?.headers() ?? []) {
|
||||
headers.append("Set-Cookie", value);
|
||||
}
|
||||
}
|
||||
|
||||
function parseCookieHeader(header: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const part of header.split(";")) {
|
||||
const index = part.indexOf("=");
|
||||
if (index < 0) continue;
|
||||
const name = part.slice(0, index).trim();
|
||||
if (!name) continue;
|
||||
out[name] = safeDecode(part.slice(index + 1).trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function serializeCookie(name: string, value: string, options: CookieOptions): string {
|
||||
if (!COOKIE_NAME.test(name)) throw new Error(`Invalid cookie name: ${name}`);
|
||||
|
||||
const parts = [`${name}=${encodeURIComponent(value)}`];
|
||||
if (options.maxAge !== undefined) parts.push(`Max-Age=${Math.floor(options.maxAge)}`);
|
||||
if (options.domain) parts.push(`Domain=${options.domain}`);
|
||||
if (options.path) parts.push(`Path=${options.path}`);
|
||||
if (options.expires) {
|
||||
const expires = options.expires instanceof Date ? options.expires : new Date(options.expires);
|
||||
parts.push(`Expires=${expires.toUTCString()}`);
|
||||
}
|
||||
if (options.httpOnly) parts.push("HttpOnly");
|
||||
if (options.secure) parts.push("Secure");
|
||||
if (options.sameSite) parts.push(`SameSite=${normalizeSameSite(options.sameSite)}`);
|
||||
return parts.join("; ");
|
||||
}
|
||||
|
||||
function sessionCookieOptions(secure: boolean): CookieOptions {
|
||||
return {
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
sameSite: "Lax",
|
||||
secure,
|
||||
};
|
||||
}
|
||||
|
||||
function parseLocalStorageHeader(header: string | null): Record<string, string> {
|
||||
if (!header) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(decodeURIComponent(header)) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
||||
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (typeof value === "string") out[key] = value;
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSameSite(value: NonNullable<CookieOptions["sameSite"]>): string {
|
||||
const lower = value.toLowerCase();
|
||||
return lower === "strict" ? "Strict" : lower === "none" ? "None" : "Lax";
|
||||
}
|
||||
|
||||
function safeDecode(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/** A 256-bit cryptographically-random session id (no weak fallback). */
|
||||
function randomId(): string {
|
||||
const bytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(bytes);
|
||||
let out = "";
|
||||
for (const b of bytes) out += b.toString(16).padStart(2, "0");
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Streaming response primitives.
|
||||
*
|
||||
* `streamResponse` turns a (sync or async) iterable of strings/bytes into a
|
||||
* streaming `Response` — the basis for streaming SSR (send the shell, then flush
|
||||
* page chunks as they render) and any progressively-generated output. `sse`
|
||||
* builds a Server-Sent Events stream from an async iterable of events.
|
||||
*
|
||||
* API routes and pages can already return a `Response` with a `ReadableStream`
|
||||
* body and the framework streams it unbuffered; these helpers just make the
|
||||
* common cases ergonomic.
|
||||
*/
|
||||
|
||||
export interface StreamResponseInit {
|
||||
status?: number;
|
||||
headers?: HeadersInit;
|
||||
/** Content-Type; default "text/html; charset=utf-8". */
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
type Chunk = string | Uint8Array;
|
||||
type ChunkSource = Iterable<Chunk> | AsyncIterable<Chunk>;
|
||||
|
||||
/** Build a streaming Response from an (async) iterable of chunks. */
|
||||
export function streamResponse(source: ChunkSource, init: StreamResponseInit = {}): Response {
|
||||
const encoder = new TextEncoder();
|
||||
const iterator = getIterator(source);
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
try {
|
||||
const { done, value } = await iterator.next();
|
||||
if (done) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(typeof value === "string" ? encoder.encode(value) : value);
|
||||
} catch (err) {
|
||||
controller.error(err);
|
||||
}
|
||||
},
|
||||
async cancel() {
|
||||
await iterator.return?.(undefined);
|
||||
},
|
||||
});
|
||||
|
||||
const headers = new Headers(init.headers);
|
||||
if (!headers.has("content-type")) {
|
||||
headers.set("content-type", init.contentType ?? "text/html; charset=utf-8");
|
||||
}
|
||||
// Tell the server's compressor (and proxies) not to buffer/transform a stream.
|
||||
if (!headers.has("cache-control")) headers.set("cache-control", "no-transform");
|
||||
return new Response(stream, { status: init.status ?? 200, headers });
|
||||
}
|
||||
|
||||
export interface ServerSentEvent {
|
||||
data: string;
|
||||
event?: string;
|
||||
id?: string;
|
||||
/** Client reconnection hint in milliseconds. */
|
||||
retry?: number;
|
||||
}
|
||||
|
||||
/** Build a Server-Sent Events (text/event-stream) Response from events. */
|
||||
export function sse(source: Iterable<ServerSentEvent> | AsyncIterable<ServerSentEvent>): Response {
|
||||
const iterator = getIterator(source);
|
||||
async function* frames(): AsyncGenerator<string> {
|
||||
for (;;) {
|
||||
const { done, value } = await iterator.next();
|
||||
if (done) return;
|
||||
yield formatEvent(value);
|
||||
}
|
||||
}
|
||||
return streamResponse(frames(), {
|
||||
contentType: "text/event-stream",
|
||||
headers: { "cache-control": "no-cache, no-transform", connection: "keep-alive" },
|
||||
});
|
||||
}
|
||||
|
||||
function formatEvent(e: ServerSentEvent): string {
|
||||
let out = "";
|
||||
if (e.event) out += `event: ${e.event}\n`;
|
||||
if (e.id) out += `id: ${e.id}\n`;
|
||||
if (e.retry !== undefined) out += `retry: ${Math.floor(e.retry)}\n`;
|
||||
for (const line of e.data.split("\n")) out += `data: ${line}\n`;
|
||||
return out + "\n";
|
||||
}
|
||||
|
||||
function getIterator<T>(source: Iterable<T> | AsyncIterable<T>): AsyncIterator<T> | Iterator<T> {
|
||||
const asAsync = (source as AsyncIterable<T>)[Symbol.asyncIterator];
|
||||
if (typeof asAsync === "function") return asAsync.call(source);
|
||||
return (source as Iterable<T>)[Symbol.iterator]();
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* File upload helpers. Bun parses `multipart/form-data` natively via
|
||||
* `Request.formData()`, yielding web `File` objects; these helpers validate and
|
||||
* persist them safely (size/type limits, filename sanitisation to prevent path
|
||||
* traversal).
|
||||
*/
|
||||
|
||||
export class UploadError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "UploadError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface SaveUploadOptions {
|
||||
/** Destination directory. */
|
||||
dir: string;
|
||||
/** Reject files larger than this many bytes. */
|
||||
maxBytes?: number;
|
||||
/** Allowed MIME types (e.g. "image/png") and/or extensions (e.g. ".png"). */
|
||||
allowedTypes?: string[];
|
||||
/** Choose the stored filename. Default: the sanitised original name. */
|
||||
filename?: (file: File) => string;
|
||||
}
|
||||
|
||||
export interface SavedUpload {
|
||||
path: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
/** All `File` values in a parsed form, with their field names. */
|
||||
export function collectUploads(form: FormData): { field: string; file: File }[] {
|
||||
const out: { field: string; file: File }[] = [];
|
||||
for (const [field, value] of form) {
|
||||
if (value instanceof File && value.size > 0) out.push({ field, file: value });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Validate and write one uploaded file to disk. Throws `UploadError` on reject. */
|
||||
export async function saveUpload(file: File, options: SaveUploadOptions): Promise<SavedUpload> {
|
||||
if (options.maxBytes !== undefined && file.size > options.maxBytes) {
|
||||
throw new UploadError(`File "${file.name}" exceeds the ${options.maxBytes}-byte limit`);
|
||||
}
|
||||
if (options.allowedTypes && !isAllowed(file, options.allowedTypes)) {
|
||||
throw new UploadError(`File type not allowed: ${file.type || file.name || "unknown"}`);
|
||||
}
|
||||
|
||||
const filename = sanitizeFilename(
|
||||
options.filename ? options.filename(file) : file.name || "upload",
|
||||
);
|
||||
const path = `${options.dir.replace(/[/\\]+$/, "")}/${filename}`;
|
||||
await Bun.write(path, file);
|
||||
return { path, filename, size: file.size, type: file.type };
|
||||
}
|
||||
|
||||
function isAllowed(file: File, allowed: string[]): boolean {
|
||||
const type = (file.type || "").toLowerCase();
|
||||
const name = (file.name || "").toLowerCase();
|
||||
return allowed.some((entry) => {
|
||||
const e = entry.toLowerCase();
|
||||
return e.startsWith(".") ? name.endsWith(e) : type === e;
|
||||
});
|
||||
}
|
||||
|
||||
/** Strip directory separators, traversal, and control chars from a filename. */
|
||||
export function sanitizeFilename(name: string): string {
|
||||
const base = name
|
||||
.replace(/[/\\]+/g, "_") // path separators
|
||||
.replace(/\.\.+/g, ".") // collapse traversal dots
|
||||
// eslint-disable-next-line no-control-regex -- intentionally stripping control chars
|
||||
.replace(/[\x00-\x1f<>:"|?*]/g, "") // control + illegal chars
|
||||
.replace(/^\.+/, "") // no leading dots
|
||||
.trim();
|
||||
return base.length > 0 ? base.slice(0, 255) : "upload";
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import {
|
||||
createContext,
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
logIn,
|
||||
logOut,
|
||||
getUser,
|
||||
sessionAuth,
|
||||
requireAuth,
|
||||
} from "../src/index.ts";
|
||||
|
||||
function ctx(method = "GET", path = "/", accept?: string) {
|
||||
const headers: Record<string, string> = {};
|
||||
if (accept) headers.accept = accept;
|
||||
const url = new URL(`http://x${path}`);
|
||||
const req = new Request(url, { method, headers });
|
||||
return createContext(req, url);
|
||||
}
|
||||
|
||||
test("hashPassword / verifyPassword round-trip", async () => {
|
||||
const hash = await hashPassword("correct horse battery staple");
|
||||
expect(hash).toBeTruthy();
|
||||
expect(hash).not.toBe("correct horse battery staple");
|
||||
expect(await verifyPassword("correct horse battery staple", hash)).toBe(true);
|
||||
expect(await verifyPassword("wrong", hash)).toBe(false);
|
||||
});
|
||||
|
||||
test("verifyPassword tolerates empty/garbage hashes", async () => {
|
||||
expect(await verifyPassword("x", "")).toBe(false);
|
||||
expect(await verifyPassword("x", "not-a-real-hash")).toBe(false);
|
||||
});
|
||||
|
||||
test("logIn stores the user; getUser reads it; logOut clears it", () => {
|
||||
const c = ctx();
|
||||
expect(getUser(c)).toBeNull();
|
||||
logIn(c, { id: 1, email: "a@b.com" });
|
||||
expect(getUser<{ id: number }>(c)?.id).toBe(1);
|
||||
expect(c.session.get<{ id: number; email: string }>("user")).toEqual({ id: 1, email: "a@b.com" });
|
||||
logOut(c);
|
||||
expect(getUser(c)).toBeNull();
|
||||
expect(c.user).toBeNull();
|
||||
});
|
||||
|
||||
test("logIn regenerates the session id (fixation defense) but keeps data", () => {
|
||||
const c = ctx();
|
||||
c.session.set("cart", [1, 2]);
|
||||
const before = c.session.id();
|
||||
logIn(c, { id: 1, email: "a@b.com" });
|
||||
const after = c.session.id();
|
||||
expect(after).not.toBe(before); // fresh id issued on login
|
||||
expect(after.length).toBeGreaterThanOrEqual(32);
|
||||
expect(c.session.get<number[]>("cart")).toEqual([1, 2]); // data preserved
|
||||
expect(getUser<{ id: number }>(c)?.id).toBe(1);
|
||||
});
|
||||
|
||||
test("sessionAuth hydrates ctx.user from the session", async () => {
|
||||
const c = ctx();
|
||||
c.session.set("user", { id: 7 });
|
||||
let seen: unknown = "unset";
|
||||
await sessionAuth()(c, () => {
|
||||
seen = c.user;
|
||||
return new Response("ok");
|
||||
});
|
||||
expect(seen).toEqual({ id: 7 });
|
||||
});
|
||||
|
||||
test("requireAuth: passes through when authenticated", async () => {
|
||||
const c = ctx();
|
||||
logIn(c, { id: 1 });
|
||||
const res = await requireAuth()(c, () => new Response("secret"));
|
||||
expect(await res.text()).toBe("secret");
|
||||
});
|
||||
|
||||
test("requireAuth: 401 JSON for API paths when anonymous", async () => {
|
||||
const c = ctx("GET", "/api/me");
|
||||
const res = await requireAuth()(c, () => new Response("secret"));
|
||||
expect(res.status).toBe(401);
|
||||
expect(await res.json()).toEqual({ ok: false, error: "Unauthorized" });
|
||||
});
|
||||
|
||||
test("requireAuth: 302 redirect for page navigations when anonymous", async () => {
|
||||
const c = ctx("GET", "/dashboard?tab=1", "text/html");
|
||||
const res = await requireAuth()(c, () => new Response("secret"));
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toBe("/login?next=%2Fdashboard%3Ftab%3D1");
|
||||
});
|
||||
|
||||
test("requireAuth: custom loginPath", async () => {
|
||||
const c = ctx("GET", "/dashboard", "text/html");
|
||||
const res = await requireAuth({ loginPath: "/signin" })(c, () => new Response("x"));
|
||||
expect(res.headers.get("location")).toBe("/signin?next=%2Fdashboard");
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { createContext, csrfToken, verifyCsrf, CSRF_COOKIE } from "../src/index.ts";
|
||||
|
||||
function ctx(method: string, cookie?: string, header?: string) {
|
||||
const headers: Record<string, string> = {};
|
||||
if (cookie) headers.cookie = `${CSRF_COOKIE}=${cookie}`;
|
||||
if (header) headers["x-csrf-token"] = header;
|
||||
const req = new Request("http://x/api", { method, headers });
|
||||
return createContext(req, new URL(req.url));
|
||||
}
|
||||
|
||||
test("csrfToken issues a token", () => {
|
||||
const token = csrfToken(ctx("GET"));
|
||||
expect(token).toBeTruthy();
|
||||
expect(token.length).toBeGreaterThan(16);
|
||||
});
|
||||
|
||||
test("verifyCsrf: safe methods always pass", () => {
|
||||
expect(verifyCsrf(ctx("GET"))).toBe(true);
|
||||
expect(verifyCsrf(ctx("HEAD"))).toBe(true);
|
||||
});
|
||||
|
||||
test("verifyCsrf: unsafe methods need matching cookie + header", () => {
|
||||
expect(verifyCsrf(ctx("POST", "abc", "abc"))).toBe(true);
|
||||
expect(verifyCsrf(ctx("POST", "abc", "xyz"))).toBe(false); // mismatch
|
||||
expect(verifyCsrf(ctx("POST", "abc"))).toBe(false); // no header
|
||||
expect(verifyCsrf(ctx("POST", undefined, "abc"))).toBe(false); // no cookie
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { withSecurityHeaders, isWebSocketOriginAllowed } from "../src/index.ts";
|
||||
|
||||
const req = (headers: Record<string, string> = {}) => new Request("https://x/", { headers });
|
||||
|
||||
function scriptSrc(csp: string): string {
|
||||
return csp
|
||||
.split(";")
|
||||
.map((s) => s.trim())
|
||||
.find((s) => s.startsWith("script-src"))!;
|
||||
}
|
||||
|
||||
test("CSP nonce is added to script-src and drops unsafe-inline", () => {
|
||||
const res = withSecurityHeaders(req(), new Response("x"), "development", undefined, "ABC123");
|
||||
const directive = scriptSrc(res.headers.get("content-security-policy")!);
|
||||
expect(directive).toContain("'nonce-ABC123'");
|
||||
expect(directive).not.toContain("'unsafe-inline'");
|
||||
});
|
||||
|
||||
test("without a nonce, dev script-src keeps unsafe-inline (for HMR)", () => {
|
||||
const res = withSecurityHeaders(req(), new Response("x"), "development");
|
||||
expect(scriptSrc(res.headers.get("content-security-policy")!)).toContain("'unsafe-inline'");
|
||||
});
|
||||
|
||||
test("CORS credentials + origin:* is refused (credentials dropped)", () => {
|
||||
const res = withSecurityHeaders(
|
||||
req({ origin: "https://evil.test" }),
|
||||
new Response("x"),
|
||||
"production",
|
||||
{
|
||||
cors: { enabled: true, origin: "*", credentials: true },
|
||||
},
|
||||
);
|
||||
expect(res.headers.get("access-control-allow-credentials")).toBeNull();
|
||||
});
|
||||
|
||||
test("production sets HSTS + strict CSP", () => {
|
||||
const res = withSecurityHeaders(req(), new Response("x"), "production");
|
||||
expect(res.headers.get("strict-transport-security")).toContain("max-age=");
|
||||
expect(res.headers.get("content-security-policy")).toContain("default-src 'self'");
|
||||
});
|
||||
|
||||
test("permissions policy overrides merge with restrictive defaults", () => {
|
||||
const res = withSecurityHeaders(req(), new Response("x"), "development", {
|
||||
permissionsPolicy: { camera: ["self"] },
|
||||
});
|
||||
const policy = res.headers.get("permissions-policy")!;
|
||||
expect(policy).toContain("camera=(self)");
|
||||
expect(policy).toContain("microphone=()");
|
||||
});
|
||||
|
||||
test("isWebSocketOriginAllowed blocks cross-site WS (CSWSH), allows same-origin", () => {
|
||||
const wsReq = (origin: string | null, host: string) =>
|
||||
new Request("http://x/realtime/c", {
|
||||
headers: origin ? { origin, host } : { host },
|
||||
});
|
||||
expect(isWebSocketOriginAllowed(wsReq("http://app.test", "app.test"))).toBe(true); // same-origin
|
||||
expect(isWebSocketOriginAllowed(wsReq("http://evil.test", "app.test"))).toBe(false); // cross-site
|
||||
expect(isWebSocketOriginAllowed(wsReq(null, "app.test"))).toBe(true); // native client, no cookies
|
||||
// Explicit CORS allowlist opens a cross-origin WS.
|
||||
expect(
|
||||
isWebSocketOriginAllowed(wsReq("http://other.test", "app.test"), {
|
||||
cors: { enabled: true, origin: "http://other.test" },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { jsx } from "../src/jsx-runtime.ts";
|
||||
|
||||
test("JSX rejects dynamic tag-name injection", () => {
|
||||
expect(() => jsx("div><script>alert(1)</script><div" as "div", {})).toThrow(
|
||||
"Invalid JSX tag name",
|
||||
);
|
||||
});
|
||||
|
||||
test("JSX skips invalid spread attribute names", () => {
|
||||
const html = jsx("div", { 'title" onmouseover="alert(1)': "x", title: "safe" }).toString();
|
||||
expect(html).toBe('<div title="safe"></div>');
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import {
|
||||
createContext,
|
||||
rateLimit,
|
||||
requestLogger,
|
||||
TTLCache,
|
||||
cacheControl,
|
||||
withCacheControl,
|
||||
etag,
|
||||
notModified,
|
||||
saveUpload,
|
||||
collectUploads,
|
||||
sanitizeFilename,
|
||||
UploadError,
|
||||
setSessionBackend,
|
||||
loadSession,
|
||||
type SessionBackend,
|
||||
type SessionEntry,
|
||||
type AsyncSessionBackend,
|
||||
type RateLimitStore,
|
||||
} from "../src/index.ts";
|
||||
|
||||
function memoryBackend(): SessionBackend {
|
||||
const map = new Map<string, SessionEntry>();
|
||||
return {
|
||||
get: (id) => map.get(id),
|
||||
set: (id, e) => void map.set(id, e),
|
||||
delete: (id) => void map.delete(id),
|
||||
_map: map,
|
||||
} as SessionBackend & { _map: Map<string, SessionEntry> };
|
||||
}
|
||||
|
||||
function ctx(path = "/", headers: Record<string, string> = {}) {
|
||||
const url = new URL(`http://x${path}`);
|
||||
return createContext(new Request(url, { headers }), url);
|
||||
}
|
||||
|
||||
// --- rate limiting ---------------------------------------------------------
|
||||
|
||||
test("rateLimit (trustProxy) allows up to max then 429 with headers", async () => {
|
||||
const mw = rateLimit({ max: 2, windowMs: 60_000, trustProxy: true });
|
||||
const ok = () => new Response("ok");
|
||||
const key = { "x-forwarded-for": "1.1.1.1" };
|
||||
|
||||
const r1 = await mw(ctx("/", key), ok);
|
||||
const r2 = await mw(ctx("/", key), ok);
|
||||
const r3 = await mw(ctx("/", key), ok);
|
||||
expect(r1.status).toBe(200);
|
||||
expect(r1.headers.get("RateLimit-Remaining")).toBe("1");
|
||||
expect(r2.status).toBe(200);
|
||||
expect(r2.headers.get("RateLimit-Remaining")).toBe("0");
|
||||
expect(r3.status).toBe(429);
|
||||
expect(r3.headers.get("retry-after")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("rateLimit (trustProxy) buckets are independent per key", async () => {
|
||||
const mw = rateLimit({ max: 1, windowMs: 60_000, trustProxy: true });
|
||||
const ok = () => new Response("ok");
|
||||
const a = await mw(ctx("/", { "x-forwarded-for": "2.2.2.2" }), ok);
|
||||
const b = await mw(ctx("/", { "x-forwarded-for": "3.3.3.3" }), ok);
|
||||
expect(a.status).toBe(200);
|
||||
expect(b.status).toBe(200);
|
||||
});
|
||||
|
||||
test("setSessionBackend routes session data through a custom backend", () => {
|
||||
const backend = memoryBackend() as SessionBackend & { _map: Map<string, SessionEntry> };
|
||||
setSessionBackend(backend);
|
||||
try {
|
||||
const c = ctx("/");
|
||||
c.session.set("k", "v");
|
||||
const id = c.session.id();
|
||||
expect(backend._map.has(id)).toBe(true);
|
||||
expect(backend._map.get(id)!.data).toEqual({ k: "v" });
|
||||
} finally {
|
||||
setSessionBackend(memoryBackend()); // restore an equivalent for other tests
|
||||
}
|
||||
});
|
||||
|
||||
test("rateLimit accepts a custom (shared) store", async () => {
|
||||
const hits: string[] = [];
|
||||
const store: RateLimitStore = {
|
||||
hit(key, windowMs, now) {
|
||||
hits.push(key);
|
||||
return { count: hits.filter((k) => k === key).length, resetAt: now + windowMs };
|
||||
},
|
||||
};
|
||||
const mw = rateLimit({ max: 1, windowMs: 1000, store, trustProxy: true });
|
||||
const ok = () => new Response("ok");
|
||||
const key = { "x-forwarded-for": "5.5.5.5" };
|
||||
expect((await mw(ctx("/", key), ok)).status).toBe(200);
|
||||
expect((await mw(ctx("/", key), ok)).status).toBe(429);
|
||||
expect(hits.length).toBe(2); // both requests went through the injected store
|
||||
});
|
||||
|
||||
test("rateLimit awaits an ASYNC store (e.g. Redis)", async () => {
|
||||
const counts = new Map<string, number>();
|
||||
const store: RateLimitStore = {
|
||||
async hit(key, windowMs, now) {
|
||||
const n = (counts.get(key) ?? 0) + 1;
|
||||
counts.set(key, n);
|
||||
return { count: n, resetAt: now + windowMs };
|
||||
},
|
||||
};
|
||||
const mw = rateLimit({ max: 1, windowMs: 1000, store, trustProxy: true });
|
||||
const ok = () => new Response("ok");
|
||||
const key = { "x-forwarded-for": "7.7.7.7" };
|
||||
expect((await mw(ctx("/", key), ok)).status).toBe(200);
|
||||
expect((await mw(ctx("/", key), ok)).status).toBe(429);
|
||||
});
|
||||
|
||||
test("loadSession persists a session through an ASYNC backend across requests", async () => {
|
||||
const kv = new Map<string, SessionEntry>();
|
||||
const backend: AsyncSessionBackend = {
|
||||
load: async (id) => kv.get(id),
|
||||
save: async (id, entry) => void kv.set(id, entry),
|
||||
destroy: async (id) => void kv.delete(id),
|
||||
};
|
||||
const mw = loadSession(backend);
|
||||
|
||||
// Request 1: write a value, capture the issued session id.
|
||||
const c1 = ctx("/");
|
||||
await mw(c1, () => {
|
||||
c1.session.set("hits", 1);
|
||||
return new Response("ok");
|
||||
});
|
||||
const sid = c1.cookies.get("wrnexus.sid")!;
|
||||
expect(sid).toBeTruthy();
|
||||
expect(kv.has(sid)).toBe(true); // saved to the async backend
|
||||
|
||||
// Request 2: same cookie → session loads from the backend.
|
||||
const c2 = ctx("/", { cookie: `wrnexus.sid=${sid}` });
|
||||
let seen: unknown;
|
||||
await mw(c2, () => {
|
||||
seen = c2.session.get("hits");
|
||||
return new Response("ok");
|
||||
});
|
||||
expect(seen).toBe(1);
|
||||
});
|
||||
|
||||
test("rateLimit default keys on the non-spoofable peer IP, not XFF headers", async () => {
|
||||
const mw = rateLimit({ max: 1, windowMs: 60_000 });
|
||||
const ok = () => new Response("ok");
|
||||
// Same peer IP, different spoofed XFF → still one bucket (XFF ignored).
|
||||
const c1 = ctx("/", { "x-forwarded-for": "9.9.9.9" });
|
||||
c1.ip = "10.0.0.1";
|
||||
const c2 = ctx("/", { "x-forwarded-for": "8.8.8.8" });
|
||||
c2.ip = "10.0.0.1";
|
||||
expect((await mw(c1, ok)).status).toBe(200);
|
||||
expect((await mw(c2, ok)).status).toBe(429);
|
||||
});
|
||||
|
||||
test("rateLimit validates its in-memory key bound", () => {
|
||||
expect(() => rateLimit({ maxKeys: 0 })).toThrow("maxKeys");
|
||||
});
|
||||
|
||||
// --- request logging -------------------------------------------------------
|
||||
|
||||
test("requestLogger emits a structured record with duration and id", async () => {
|
||||
const records: string[] = [];
|
||||
let t = 1000;
|
||||
const mw = requestLogger({
|
||||
format: "json",
|
||||
sink: (line) => records.push(line),
|
||||
now: () => (t += 5),
|
||||
});
|
||||
const res = await mw(ctx("/api/users"), () => new Response("x", { status: 201 }));
|
||||
expect(res.status).toBe(201);
|
||||
const rec = JSON.parse(records[0]!);
|
||||
expect(rec.method).toBe("GET");
|
||||
expect(rec.path).toBe("/api/users");
|
||||
expect(rec.status).toBe(201);
|
||||
expect(rec.durationMs).toBeGreaterThanOrEqual(0);
|
||||
expect(rec.id).toBeTruthy();
|
||||
});
|
||||
|
||||
test("requestLogger logs status 500 when the handler throws", async () => {
|
||||
const records: string[] = [];
|
||||
const mw = requestLogger({ format: "json", sink: (l) => records.push(l) });
|
||||
await expect(
|
||||
mw(ctx("/boom"), () => {
|
||||
throw new Error("nope");
|
||||
}),
|
||||
).rejects.toThrow("nope");
|
||||
expect(JSON.parse(records[0]!).status).toBe(500);
|
||||
});
|
||||
|
||||
// --- caching ---------------------------------------------------------------
|
||||
|
||||
test("TTLCache getOrLoad caches until expiry", async () => {
|
||||
const cache = new TTLCache<number>(60_000);
|
||||
let calls = 0;
|
||||
const load = () => {
|
||||
calls++;
|
||||
return 42;
|
||||
};
|
||||
expect(await cache.getOrLoad("k", load)).toBe(42);
|
||||
expect(await cache.getOrLoad("k", load)).toBe(42);
|
||||
expect(calls).toBe(1);
|
||||
cache.delete("k");
|
||||
expect(await cache.getOrLoad("k", load)).toBe(42);
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
test("TTLCache coalesces concurrent loads for the same key", async () => {
|
||||
const cache = new TTLCache<number>();
|
||||
let calls = 0;
|
||||
const loader = async () => {
|
||||
calls++;
|
||||
await Promise.resolve();
|
||||
return 7;
|
||||
};
|
||||
expect(await Promise.all([cache.getOrLoad("x", loader), cache.getOrLoad("x", loader)])).toEqual([
|
||||
7, 7,
|
||||
]);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
test("TTLCache does not let an old in-flight load overwrite set, delete, or clear", async () => {
|
||||
const cache = new TTLCache<number>();
|
||||
let release!: (value: number) => void;
|
||||
const loading = cache.getOrLoad("x", () => new Promise<number>((resolve) => (release = resolve)));
|
||||
await Promise.resolve();
|
||||
cache.set("x", 9);
|
||||
release(1);
|
||||
expect(await loading).toBe(1);
|
||||
expect(cache.get("x")).toBe(9);
|
||||
|
||||
let releaseClear!: (value: number) => void;
|
||||
const clearing = cache.getOrLoad(
|
||||
"y",
|
||||
() => new Promise<number>((resolve) => (releaseClear = resolve)),
|
||||
);
|
||||
await Promise.resolve();
|
||||
cache.clear();
|
||||
releaseClear(2);
|
||||
await clearing;
|
||||
expect(cache.get("y")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("cacheControl builds directives; no-store wins", () => {
|
||||
expect(cacheControl({ maxAge: 60, sMaxAge: 120 })).toBe("public, max-age=60, s-maxage=120");
|
||||
expect(cacheControl({ private: true, noCache: true })).toBe("private, no-cache");
|
||||
expect(cacheControl({ noStore: true, maxAge: 99 })).toBe("no-store");
|
||||
const res = withCacheControl(new Response("x"), { maxAge: 30, immutable: true });
|
||||
expect(res.headers.get("Cache-Control")).toBe("public, max-age=30, immutable");
|
||||
});
|
||||
|
||||
test("etag + notModified drive conditional requests", () => {
|
||||
const tag = etag("hello world");
|
||||
expect(tag).toMatch(/^W\/"/);
|
||||
expect(etag("hello world")).toBe(tag); // stable
|
||||
expect(etag("different")).not.toBe(tag);
|
||||
const req = new Request("http://x", { headers: { "if-none-match": tag } });
|
||||
expect(notModified(req, tag)).toBe(true);
|
||||
expect(notModified(new Request("http://x"), tag)).toBe(false);
|
||||
});
|
||||
|
||||
// --- uploads ---------------------------------------------------------------
|
||||
|
||||
test("sanitizeFilename strips traversal and separators", () => {
|
||||
const s = sanitizeFilename("../../etc/passwd");
|
||||
expect(s).not.toContain("/");
|
||||
expect(s).not.toContain("..");
|
||||
expect(s).toContain("passwd");
|
||||
expect(sanitizeFilename("a/b\\c.png")).toBe("a_b_c.png");
|
||||
expect(sanitizeFilename("")).toBe("upload");
|
||||
});
|
||||
|
||||
test("saveUpload writes a validated file and enforces limits", async () => {
|
||||
const dir = join(tmpdir(), "wire-upload-test");
|
||||
const file = new File(["hello upload"], "note.txt", { type: "text/plain" });
|
||||
|
||||
const saved = await saveUpload(file, { dir, allowedTypes: ["text/plain", ".txt"] });
|
||||
expect(saved.filename).toBe("note.txt");
|
||||
expect(saved.size).toBe(12);
|
||||
expect(existsSync(saved.path)).toBe(true);
|
||||
|
||||
await expect(saveUpload(file, { dir, maxBytes: 4 })).rejects.toThrow(UploadError);
|
||||
await expect(saveUpload(file, { dir, allowedTypes: ["image/png"] })).rejects.toThrow(UploadError);
|
||||
});
|
||||
|
||||
test("collectUploads returns only non-empty File fields", async () => {
|
||||
const form = new FormData();
|
||||
form.append("name", "ada");
|
||||
form.append("avatar", new File(["img"], "a.png", { type: "image/png" }));
|
||||
const uploads = collectUploads(form);
|
||||
expect(uploads.length).toBe(1);
|
||||
expect(uploads[0]!.field).toBe("avatar");
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import {
|
||||
createRealtimeRegistry,
|
||||
bridgeRealtime,
|
||||
defineRoom,
|
||||
type RawSocket,
|
||||
} from "../src/index.ts";
|
||||
import { createPubSub } from "../../pubsub/src/index.ts";
|
||||
|
||||
/** A fake socket that records what the server sends to it. */
|
||||
function fakeSocket(): RawSocket & { received: string[] } {
|
||||
const received: string[] = [];
|
||||
return { received, send: (d: string) => received.push(d), close: () => {} };
|
||||
}
|
||||
|
||||
test("bridgeRealtime: a room broadcast on one registry reaches connections on another", async () => {
|
||||
// One shared bus stands in for Redis across two 'processes' (registries).
|
||||
const bus = createPubSub();
|
||||
|
||||
const room = defineRoom({
|
||||
onMessage(client, msg) {
|
||||
client.room.broadcast({ echo: msg }); // everyone in the room, on every process
|
||||
},
|
||||
});
|
||||
|
||||
const rA = createRealtimeRegistry();
|
||||
const rB = createRealtimeRegistry();
|
||||
bridgeRealtime(rA, bus);
|
||||
bridgeRealtime(rB, bus);
|
||||
|
||||
// A client connected to registry B, in room "chat".
|
||||
const sB = fakeSocket();
|
||||
await rB.open(sB, { room: "chat", def: room });
|
||||
|
||||
// A client connected to registry A triggers a broadcast.
|
||||
const sA = fakeSocket();
|
||||
await rA.open(sA, { room: "chat", def: room });
|
||||
await rA.message(sA, JSON.stringify({ hi: 1 }));
|
||||
|
||||
// The broadcast crossed the bus: B's client received it even though the
|
||||
// broadcast happened on registry A.
|
||||
const gotOnB = sB.received.find((p) => p.includes("echo"));
|
||||
expect(gotOnB).toBeTruthy();
|
||||
expect(JSON.parse(gotOnB!)).toEqual({ echo: { hi: 1 } });
|
||||
});
|
||||
|
||||
test("bridgeRealtime: no bus means broadcasts stay local", async () => {
|
||||
const room = defineRoom({
|
||||
onMessage(client, msg) {
|
||||
client.room.broadcast({ echo: msg });
|
||||
},
|
||||
});
|
||||
const rA = createRealtimeRegistry();
|
||||
const rB = createRealtimeRegistry(); // NOT bridged to A
|
||||
|
||||
const sB = fakeSocket();
|
||||
await rB.open(sB, { room: "chat", def: room });
|
||||
const sA = fakeSocket();
|
||||
await rA.open(sA, { room: "chat", def: room });
|
||||
await rA.message(sA, JSON.stringify({ hi: 1 }));
|
||||
|
||||
expect(sB.received.length).toBe(0); // isolated — nothing crossed
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import {
|
||||
defineRoom,
|
||||
isRoomDefinition,
|
||||
createRealtimeRegistry,
|
||||
type RawSocket,
|
||||
} from "../src/index.ts";
|
||||
|
||||
interface MockSocket extends RawSocket {
|
||||
sent: Record<string, unknown>[];
|
||||
}
|
||||
function mockSocket(): MockSocket {
|
||||
const sent: Record<string, unknown>[] = [];
|
||||
return {
|
||||
sent,
|
||||
send(data: string) {
|
||||
sent.push(JSON.parse(data) as Record<string, unknown>);
|
||||
},
|
||||
close() {},
|
||||
};
|
||||
}
|
||||
|
||||
test("defineRoom marks a room definition", () => {
|
||||
expect(isRoomDefinition(defineRoom({}))).toBe(true);
|
||||
expect(isRoomDefinition({})).toBe(false);
|
||||
expect(isRoomDefinition(null)).toBe(false);
|
||||
});
|
||||
|
||||
test("lifecycle hooks fire; broadcast reaches the whole room", async () => {
|
||||
const events: string[] = [];
|
||||
const def = defineRoom({
|
||||
onConnect(c) {
|
||||
events.push("connect");
|
||||
c.broadcast({ type: "join" }); // others only
|
||||
},
|
||||
onMessage(c, m) {
|
||||
events.push("message");
|
||||
c.room.broadcast({ type: "echo", text: m.text }); // everyone incl. sender
|
||||
},
|
||||
onLeave(c) {
|
||||
events.push("leave");
|
||||
c.broadcast({ type: "left" });
|
||||
},
|
||||
});
|
||||
const reg = createRealtimeRegistry();
|
||||
const a = mockSocket();
|
||||
const b = mockSocket();
|
||||
await reg.open(a, { room: "/r/x", def });
|
||||
await reg.open(b, { room: "/r/x", def });
|
||||
expect(a.sent.some((m) => m.type === "join")).toBe(true); // A saw B join
|
||||
expect(b.sent.some((m) => m.type === "join")).toBe(false); // B didn't see its own join
|
||||
|
||||
await reg.message(a, JSON.stringify({ text: "hi" }));
|
||||
expect(a.sent.some((m) => m.type === "echo" && m.text === "hi")).toBe(true); // sender sees own
|
||||
expect(b.sent.some((m) => m.type === "echo" && m.text === "hi")).toBe(true);
|
||||
|
||||
await reg.close(b);
|
||||
expect(a.sent.some((m) => m.type === "left")).toBe(true);
|
||||
expect(reg.size()).toBe(1);
|
||||
expect(events).toEqual(["connect", "connect", "message", "leave"]);
|
||||
});
|
||||
|
||||
test("to(connectionId) and toUser(user|users) target precisely", async () => {
|
||||
const ids: Record<string, string> = {};
|
||||
const def = defineRoom({
|
||||
onConnect(c) {
|
||||
c.user = c.query.as; // identify by ?as=
|
||||
ids[c.query.as!] = c.id;
|
||||
},
|
||||
onMessage(c, m) {
|
||||
if (m.toUser) c.toUser(m.toUser).send({ type: "dm", text: m.text });
|
||||
if (m.toId) c.to(m.toId).send({ type: "direct", text: m.text });
|
||||
},
|
||||
});
|
||||
const reg = createRealtimeRegistry();
|
||||
const alice = mockSocket();
|
||||
const bob = mockSocket();
|
||||
const carol = mockSocket();
|
||||
await reg.open(alice, { room: "/r", def, query: { as: "alice" } });
|
||||
await reg.open(bob, { room: "/r", def, query: { as: "bob" } });
|
||||
await reg.open(carol, { room: "/r", def, query: { as: "carol" } });
|
||||
|
||||
// single user
|
||||
await reg.message(alice, JSON.stringify({ toUser: "bob", text: "hey bob" }));
|
||||
expect(bob.sent.some((m) => m.type === "dm" && m.text === "hey bob")).toBe(true);
|
||||
expect(carol.sent.some((m) => m.type === "dm")).toBe(false);
|
||||
|
||||
// selected users
|
||||
await reg.message(alice, JSON.stringify({ toUser: ["bob", "carol"], text: "both" }));
|
||||
expect(bob.sent.filter((m) => m.type === "dm").length).toBe(2);
|
||||
expect(carol.sent.some((m) => m.text === "both")).toBe(true);
|
||||
|
||||
// by connection id
|
||||
await reg.message(alice, JSON.stringify({ toId: ids.carol, text: "by-id" }));
|
||||
expect(carol.sent.some((m) => m.type === "direct" && m.text === "by-id")).toBe(true);
|
||||
});
|
||||
|
||||
test("rooms are isolated from each other", async () => {
|
||||
const def = defineRoom({
|
||||
onMessage(c, m) {
|
||||
c.room.broadcast({ type: "x", text: m.text });
|
||||
},
|
||||
});
|
||||
const reg = createRealtimeRegistry();
|
||||
const a = mockSocket();
|
||||
const b = mockSocket();
|
||||
await reg.open(a, { room: "/room/1", def }); // dynamic room instances, one handler
|
||||
await reg.open(b, { room: "/room/2", def });
|
||||
await reg.message(a, JSON.stringify({ text: "one" }));
|
||||
expect(a.sent.some((m) => m.text === "one")).toBe(true);
|
||||
expect(b.sent.length).toBe(0); // different room, untouched
|
||||
});
|
||||
|
||||
test("bridge relays broadcasts + toUser across registries (horizontal scaling)", async () => {
|
||||
const regA = createRealtimeRegistry();
|
||||
const regB = createRealtimeRegistry();
|
||||
// A shared bus: each instance delivers the other's published envelopes.
|
||||
regA.setBridge({ publish: (env) => regB.deliver(env) });
|
||||
regB.setBridge({ publish: (env) => regA.deliver(env) });
|
||||
|
||||
const def = defineRoom({
|
||||
onConnect(c) {
|
||||
c.user = c.query.as;
|
||||
},
|
||||
onMessage(c, m) {
|
||||
if (m.toUser) c.toUser(m.toUser).send({ type: "dm", text: m.text });
|
||||
else c.room.broadcast({ type: "x", text: m.text });
|
||||
},
|
||||
});
|
||||
const a = mockSocket();
|
||||
const b = mockSocket();
|
||||
await regA.open(a, { room: "/r", def, query: { as: "alice" } });
|
||||
await regB.open(b, { room: "/r", def, query: { as: "bob" } }); // b is on the OTHER instance
|
||||
|
||||
// broadcast from A reaches B through the bridge
|
||||
await regA.message(a, JSON.stringify({ text: "cross-instance" }));
|
||||
expect(a.sent.some((m) => m.text === "cross-instance")).toBe(true);
|
||||
expect(b.sent.some((m) => m.text === "cross-instance")).toBe(true);
|
||||
|
||||
// toUser bob (on instance B) from A reaches him via the bridge; alice doesn't
|
||||
await regA.message(a, JSON.stringify({ toUser: "bob", text: "hi bob" }));
|
||||
expect(b.sent.some((m) => m.type === "dm" && m.text === "hi bob")).toBe(true);
|
||||
const aliceDms = a.sent.filter((m) => m.type === "dm").length;
|
||||
expect(aliceDms).toBe(0); // not looped back / not delivered to the wrong user
|
||||
});
|
||||
|
||||
test("room.state and count() track the live room", async () => {
|
||||
const def = defineRoom({
|
||||
onConnect(c) {
|
||||
c.room.state.hits = ((c.room.state.hits as number) ?? 0) + 1;
|
||||
c.send({ type: "welcome", online: c.room.count(), hits: c.room.state.hits });
|
||||
},
|
||||
});
|
||||
const reg = createRealtimeRegistry();
|
||||
const a = mockSocket();
|
||||
const b = mockSocket();
|
||||
await reg.open(a, { room: "/r", def });
|
||||
await reg.open(b, { room: "/r", def });
|
||||
expect(a.sent[0]).toMatchObject({ online: 1, hits: 1 });
|
||||
expect(b.sent[0]).toMatchObject({ online: 2, hits: 2 });
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { streamResponse, sse } from "../src/index.ts";
|
||||
|
||||
test("streamResponse streams a sync iterable of strings as HTML", async () => {
|
||||
const res = streamResponse(["<h1>", "Hello", "</h1>"]);
|
||||
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
||||
expect(await res.text()).toBe("<h1>Hello</h1>");
|
||||
});
|
||||
|
||||
test("streamResponse streams an async generator (streaming SSR shell + body)", async () => {
|
||||
async function* page() {
|
||||
yield '<!doctype html><body><div id="app">';
|
||||
yield "<p>content</p>";
|
||||
yield "</div></body>";
|
||||
}
|
||||
const res = streamResponse(page(), { status: 200 });
|
||||
const text = await res.text();
|
||||
expect(text).toContain('<div id="app">');
|
||||
expect(text).toContain("<p>content</p>");
|
||||
});
|
||||
|
||||
test("streamResponse honours custom content-type and status", async () => {
|
||||
const res = streamResponse(["plain"], { contentType: "text/plain", status: 201 });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.headers.get("content-type")).toBe("text/plain");
|
||||
});
|
||||
|
||||
test("sse formats Server-Sent Events frames", async () => {
|
||||
async function* events() {
|
||||
yield { data: "hello", event: "greeting", id: "1" };
|
||||
yield { data: "line1\nline2", retry: 3000 };
|
||||
}
|
||||
const res = sse(events());
|
||||
expect(res.headers.get("content-type")).toBe("text/event-stream");
|
||||
const text = await res.text();
|
||||
expect(text).toContain("event: greeting\nid: 1\ndata: hello\n\n");
|
||||
expect(text).toContain("retry: 3000\ndata: line1\ndata: line2\n\n");
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
# @wrnexus/csr
|
||||
|
||||
> The browser-side client runtime for WrNexus — generic, self-contained JS that hydrates server-rendered pages with reactivity, client-side navigation, and realtime rooms.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
|
||||
## Overview
|
||||
|
||||
`@wrnexus/csr` holds the three client runtimes that WrNexus serves to the browser. Components are authored as `.wrn` files and rendered on the **server**; this package provides the single, generic runtime that **hydrates** that HTML in the browser — there are no per-component browser bundles. Each runtime is exported as a plain-JS string (no build step, no imports) intended to be served verbatim from a well-known URL:
|
||||
|
||||
- **reactive** at `/__wrnexus/reactive.js` — reactive directives (`data-scope`, `data-text`, `data-for`, …)
|
||||
- **nav** at `/__wrnexus/nav.js` — SPA-style client navigation with graceful fallback
|
||||
- **realtime** at `/__wrnexus/realtime.js` — WebSocket "rooms", declarative or programmatic
|
||||
|
||||
The package itself runs on the server (it just returns strings); the strings it returns run in the browser. A dev/prod server (see `@wrnexus/core`) is responsible for actually serving them.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @wrnexus/csr
|
||||
```
|
||||
|
||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
|
||||
|
||||
## API
|
||||
|
||||
All exports come from the package root (`@wrnexus/csr`). The runtime source is delivered as strings, so the "API" on the server side is small; the real surface is the browser directives/globals each string installs.
|
||||
|
||||
### Runtime strings
|
||||
|
||||
| Export | Type | Served at | Contents |
|
||||
| ------------------ | -------- | ------------------------ | ------------------------------ |
|
||||
| `REACTIVE_RUNTIME` | `string` | `/__wrnexus/reactive.js` | Reactive directive runtime |
|
||||
| `NAV_RUNTIME` | `string` | `/__wrnexus/nav.js` | Client-side navigation runtime |
|
||||
| `REALTIME_RUNTIME` | `string` | `/__wrnexus/realtime.js` | Realtime rooms runtime |
|
||||
|
||||
### Accessor functions
|
||||
|
||||
Convenience getters that return the same strings.
|
||||
|
||||
```ts
|
||||
getReactiveRuntime(): string // → REACTIVE_RUNTIME
|
||||
getNavRuntime(): string // → NAV_RUNTIME
|
||||
getRealtimeRuntime(): string // → REALTIME_RUNTIME
|
||||
```
|
||||
|
||||
### Browser: reactive directives
|
||||
|
||||
Applied to any subtree containing `data-scope`. Expressions are parsed by a tiny eval-free evaluator, so a strict CSP with no `unsafe-eval` works.
|
||||
|
||||
| Directive | Purpose |
|
||||
| -------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| `data-scope="count: 0, name: 'x'"` | Declare reactive state on a subtree |
|
||||
| `data-on-<event>="count++"` | Run a statement in scope on a DOM event |
|
||||
| `data-text="expr"` | Bind an element's `textContent` to an expression |
|
||||
| `data-show="expr"` | Toggle visibility (`display`) on truthiness |
|
||||
| `data-for="item in list"` (opt. `item, i in list`) | Per-item list rendering template |
|
||||
| `{{expr}}` or `{expr}` | Interpolation inside text nodes and attribute values |
|
||||
| `data-wrnexus-csr="id"` | Target for a generated CSR fetch binding (fetches `/__wrnexus/csr?...`) |
|
||||
|
||||
Supported expression features: literals, identifiers, member access (`a.b`, `a[b]`), function/method calls, arrays, objects, arithmetic, comparison, equality, logical (`&& ||`), unary (`! - +`), and ternary. Statements support `++`/`--`, assignment operators (`= += -= *= /= %=`), and bare expression/method calls. Rendering is dependency-tracked: a signal change only re-runs the renderers that actually read it.
|
||||
|
||||
Browser globals installed: `window.__wrnexusHydrateScopes(root)` and `window.__wrnexusHydrateCsrFetches(root)` — both idempotent, so re-running after a DOM swap or HMR morph is safe. Both run automatically on `DOMContentLoaded`.
|
||||
|
||||
### Browser: navigation
|
||||
|
||||
Intercepts same-origin `<a>` clicks, fetches the target page, and swaps the `#app` container in place (via `importNode` — not `innerHTML` — so it works under a Trusted-Types CSP), updating history, title, and scroll, then re-hydrates. Cross-origin links, modified clicks, `download`/`data-no-nav`/`rel="external"`/`target` links, non-HTML responses, or a missing `#app` fall back to a full browser navigation.
|
||||
|
||||
- Programmatic navigation: `window.__wrnexusNavigate(url)`
|
||||
- Emits a `wrnexus:navigated` `CustomEvent` (`detail.url`) after each swap
|
||||
- Sends `x-wrnexus-nav: 1` on fetches so the server can return the page fragment
|
||||
- Appends any `/__wrnexus/*` runtime scripts the incoming page needs but the current document lacks
|
||||
|
||||
### Browser: realtime rooms
|
||||
|
||||
Connects to `/realtime/<name>` over WebSocket (`ws`/`wss` chosen from `location.protocol`). Two usage modes.
|
||||
|
||||
Programmatic API via `window.wire`:
|
||||
|
||||
```ts
|
||||
wire.room(name): Room // open (or reuse) a room connection
|
||||
wire.bindRooms(root?) // (re)bind declarative [data-room] containers
|
||||
|
||||
interface Room {
|
||||
name: string;
|
||||
send(obj: object | string): Room; // JSON-stringifies objects; queues until open
|
||||
on(type: string, cb): Room; // filter by msg.type; "*" or a fn = all messages
|
||||
on(cb): Room;
|
||||
close(): Room;
|
||||
}
|
||||
```
|
||||
|
||||
Internal lifecycle messages are emitted to listeners as `{ type }`: `__open`, `__close`, `__error`, and `__raw` (non-JSON frames, with `data`). Reconnect uses exponential backoff capped at 5s; queued sends flush on reconnect.
|
||||
|
||||
Declarative binding (zero JS) on a `data-room="<name>"` container:
|
||||
|
||||
| Attribute | On | Purpose |
|
||||
| ------------------------------------ | --------------- | ------------------------------------------------------------------------ |
|
||||
| `data-room="<name>"` | container | Connect to room `<name>` |
|
||||
| `data-room-user="<id>"` | container | Identify the connection (`?user=<id>`) |
|
||||
| `data-room-log` | element | Where incoming messages are appended |
|
||||
| `<template data-room-item="<type>">` | template | Row template for messages of that `type` (empty = fallback) |
|
||||
| `%field%` | inside template | Placeholder filled from the message field (text/attr only, HTML-escaped) |
|
||||
| `data-room-status` | element | Reflects connection state text (`connected`/`disconnected`/`error`) |
|
||||
| `data-room-status-class` | status element | Base class; a state variant (`is-connected`, …) is appended |
|
||||
| `<form data-room-send>` | form | Submits named fields as a JSON message |
|
||||
| `data-room-reset` | form field | Clears that field after send |
|
||||
|
||||
Rebinds on `wrnexus:navigated` and closes rooms whose container has left the page.
|
||||
|
||||
## Usage
|
||||
|
||||
Server side — serve the runtime strings from your router (example with `Bun.serve`):
|
||||
|
||||
```ts
|
||||
import { getReactiveRuntime, getNavRuntime, getRealtimeRuntime } from "@wrnexus/csr";
|
||||
|
||||
const routes: Record<string, string> = {
|
||||
"/__wrnexus/reactive.js": getReactiveRuntime(),
|
||||
"/__wrnexus/nav.js": getNavRuntime(),
|
||||
"/__wrnexus/realtime.js": getRealtimeRuntime(),
|
||||
};
|
||||
|
||||
Bun.serve({
|
||||
fetch(req) {
|
||||
const body = routes[new URL(req.url).pathname];
|
||||
if (body) {
|
||||
return new Response(body, {
|
||||
headers: { "content-type": "text/javascript; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
return new Response("Not found", { status: 404 });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Browser side — server-rendered HTML that the reactive runtime hydrates:
|
||||
|
||||
```html
|
||||
<div data-scope="count: 0">
|
||||
<button data-on-click="count++">+1</button>
|
||||
<span data-text="count"></span>
|
||||
<p>Total: {{count}}</p>
|
||||
</div>
|
||||
<script src="/__wrnexus/reactive.js"></script>
|
||||
```
|
||||
|
||||
A realtime chat, fully declarative:
|
||||
|
||||
```html
|
||||
<div data-room="lobby" data-room-user="ada">
|
||||
<div data-room-status></div>
|
||||
<ul data-room-log></ul>
|
||||
<template data-room-item="chat"><li>%user%: %text%</li></template>
|
||||
<form data-room-send>
|
||||
<input name="text" data-room-reset />
|
||||
<input type="hidden" name="type" value="chat" />
|
||||
<button>Send</button>
|
||||
</form>
|
||||
</div>
|
||||
<script src="/__wrnexus/realtime.js"></script>
|
||||
```
|
||||
|
||||
Or drive a room from code:
|
||||
|
||||
```ts
|
||||
const room = wire.room("lobby");
|
||||
room.on("chat", (msg) => console.log(msg.user, msg.text));
|
||||
room.send({ type: "chat", user: "ada", text: "hi" });
|
||||
```
|
||||
|
||||
## Requirements / Notes
|
||||
|
||||
- **Bun-only** on the server (the package integrates with Bun-based WrNexus servers); the emitted strings are plain browser JS with no dependencies.
|
||||
- Browser runtimes are **self-contained** (no imports, no build step) and **idempotent**, so re-hydration after navigation or HMR is safe.
|
||||
- Designed for a **strict CSP**: the reactive expression evaluator avoids `eval`/`new Function` (no `unsafe-eval`), and DOM swaps use `importNode`/attribute writes rather than `innerHTML` (Trusted-Types friendly).
|
||||
- Peer packages: rendered `.wrn` components and the serving layer come from `@wrnexus/core` (the sole dependency); pages are rendered by the WrNexus dev/prod server.
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.2.12",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @wrnexus/csr — the browser reactive runtime.
|
||||
*
|
||||
* Components are `.wrn` files rendered on the SERVER (see @wrnexus/dev-server)
|
||||
* and hydrated in the browser by this single, generic runtime — served once at
|
||||
* `/__wrnexus/reactive.js` for any page that contains a `data-scope`. There are
|
||||
* no per-component browser bundles: SSR stays cleanly separated from CSR.
|
||||
*/
|
||||
|
||||
import { REACTIVE_RUNTIME } from "./reactive-runtime.ts";
|
||||
import { NAV_RUNTIME } from "./nav-runtime.ts";
|
||||
import { REALTIME_RUNTIME } from "./realtime-runtime.ts";
|
||||
|
||||
export { REACTIVE_RUNTIME } from "./reactive-runtime.ts";
|
||||
export { NAV_RUNTIME } from "./nav-runtime.ts";
|
||||
export { REALTIME_RUNTIME } from "./realtime-runtime.ts";
|
||||
|
||||
/** The reactive runtime served at `/__wrnexus/reactive.js` (plain browser JS). */
|
||||
export function getReactiveRuntime(): string {
|
||||
return REACTIVE_RUNTIME;
|
||||
}
|
||||
|
||||
/** The client-side navigation runtime served at `/__wrnexus/nav.js`. */
|
||||
export function getNavRuntime(): string {
|
||||
return NAV_RUNTIME;
|
||||
}
|
||||
|
||||
/** The realtime client runtime served at `/__wrnexus/realtime.js`. */
|
||||
export function getRealtimeRuntime(): string {
|
||||
return REALTIME_RUNTIME;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Client-side navigation runtime, served at `/__wrnexus/nav.js`.
|
||||
*
|
||||
* Progressive enhancement over normal links: intercepts same-origin `<a>`
|
||||
* clicks, fetches the target page's HTML, swaps the `#app` container in place,
|
||||
* updates history/title/scroll, ensures any framework runtimes the new page
|
||||
* needs are present, and re-hydrates. Anything unexpected (cross-origin,
|
||||
* modified click, non-HTML response, missing `#app`) falls back to a full
|
||||
* browser navigation, so behaviour degrades safely.
|
||||
*
|
||||
* Data "loaders": pages load their data on the server (SSR `api` bindings), so
|
||||
* the fetched HTML already contains fresh data — no separate client loader is
|
||||
* needed. Client-side (`csr`) bindings and reactive scopes re-hydrate after the
|
||||
* swap. Programmatic navigation is exposed as `window.__wrnexusNavigate(url)`.
|
||||
*/
|
||||
|
||||
export const NAV_RUNTIME = String.raw`
|
||||
(function () {
|
||||
if (!window.history || !history.pushState || !window.fetch || !window.DOMParser) return;
|
||||
if (window.__wrnexusNavInstalled) return;
|
||||
window.__wrnexusNavInstalled = true;
|
||||
|
||||
var APP_ID = "app";
|
||||
|
||||
function pathOf(src) { return String(src).split("?")[0]; }
|
||||
|
||||
function isLocalLink(a) {
|
||||
if (!a || a.hasAttribute("download") || a.hasAttribute("data-no-nav")) return false;
|
||||
if (a.target && a.target !== "_self") return false;
|
||||
if (a.origin !== location.origin) return false;
|
||||
var href = a.getAttribute("href");
|
||||
if (!href || href.charAt(0) === "#") return false;
|
||||
var rel = (a.getAttribute("rel") || "").toLowerCase();
|
||||
return rel.indexOf("external") === -1;
|
||||
}
|
||||
|
||||
function loadedScriptPaths() {
|
||||
var set = {};
|
||||
document.querySelectorAll("script[src]").forEach(function (s) {
|
||||
var src = s.getAttribute("src");
|
||||
if (src) set[pathOf(src)] = true;
|
||||
});
|
||||
return set;
|
||||
}
|
||||
|
||||
// Append any /__wrnexus/* runtime the incoming page declares but the current
|
||||
// document has not loaded yet. Fresh scripts self-initialise on load.
|
||||
function ensureScripts(doc) {
|
||||
var loaded = loadedScriptPaths();
|
||||
doc.querySelectorAll("script[src]").forEach(function (s) {
|
||||
var src = s.getAttribute("src");
|
||||
if (!src || loaded[pathOf(src)]) return;
|
||||
loaded[pathOf(src)] = true;
|
||||
var el = document.createElement("script");
|
||||
el.src = src;
|
||||
el.async = false; // preserve execution order (e.g. schemas.js before validate.js)
|
||||
document.body.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
// Re-hydrate already-loaded runtimes against the swapped-in DOM. Every
|
||||
// entrypoint is idempotent, so this is safe even when a fresh script also
|
||||
// self-initialises the same nodes.
|
||||
function rehydrate() {
|
||||
try { if (window.__wrnexusHydrateScopes) window.__wrnexusHydrateScopes(document); } catch (e) {}
|
||||
try { if (window.__wrnexusHydrateCsrFetches) window.__wrnexusHydrateCsrFetches(document); } catch (e) {}
|
||||
try { if (window.__wireValidate) window.__wireValidate.init(document); } catch (e) {}
|
||||
}
|
||||
|
||||
function render(html, url, isPop) {
|
||||
var doc = new DOMParser().parseFromString(html, "text/html");
|
||||
var to = doc.getElementById(APP_ID);
|
||||
var from = document.getElementById(APP_ID);
|
||||
if (!to || !from) { location.href = url; return; } // structure mismatch → hard nav
|
||||
if (doc.title) document.title = doc.title;
|
||||
// Swap #app by importing nodes — NOT innerHTML — so it works under a strict
|
||||
// Trusted-Types CSP (require-trusted-types-for 'script').
|
||||
var imported = [];
|
||||
for (var i = 0; i < to.childNodes.length; i++) imported.push(document.importNode(to.childNodes[i], true));
|
||||
from.replaceChildren.apply(from, imported);
|
||||
ensureScripts(doc);
|
||||
rehydrate();
|
||||
if (!isPop) { history.pushState({ wrnexusNav: true }, "", url); window.scrollTo(0, 0); }
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent("wrnexus:navigated", { detail: { url: url } }));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
var inFlight = null;
|
||||
|
||||
function navigate(url, isPop) {
|
||||
var token = {};
|
||||
inFlight = token;
|
||||
fetch(url, { headers: { "x-wrnexus-nav": "1", accept: "text/html" }, credentials: "same-origin" })
|
||||
.then(function (r) {
|
||||
if (inFlight !== token) return null; // superseded by a newer navigation
|
||||
if (r.redirected && r.url) url = r.url; // follow server redirects (e.g. auth)
|
||||
var ct = r.headers.get("content-type") || "";
|
||||
if (ct.indexOf("text/html") === -1) { location.href = url; return null; }
|
||||
return r.text().then(function (t) {
|
||||
if (inFlight === token) render(t, url, isPop);
|
||||
});
|
||||
})
|
||||
.catch(function () { location.href = url; });
|
||||
}
|
||||
|
||||
document.addEventListener(
|
||||
"click",
|
||||
function (e) {
|
||||
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
var a = e.target && e.target.closest ? e.target.closest("a") : null;
|
||||
if (!isLocalLink(a)) return;
|
||||
if (a.href === location.href) { e.preventDefault(); return; }
|
||||
e.preventDefault();
|
||||
navigate(a.href, false);
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
window.addEventListener("popstate", function () {
|
||||
navigate(location.href, true);
|
||||
});
|
||||
|
||||
// Programmatic navigation for forms/actions and app code.
|
||||
window.__wrnexusNavigate = function (url) {
|
||||
navigate(new URL(url, location.href).href, false);
|
||||
};
|
||||
})();
|
||||
`.trim();
|
||||
@@ -0,0 +1,577 @@
|
||||
/**
|
||||
* Browser reactive runtime (Point 2: reactive directives).
|
||||
*
|
||||
* Served verbatim at `/__wrnexus/reactive.js` for any page that contains a
|
||||
* `data-scope`. It is plain browser JS (no build step) and self-contained: it
|
||||
* inlines a tiny `signal()` so it has no imports to resolve.
|
||||
*
|
||||
* Supported directives (this is exactly what the `.wrn` compiler emits):
|
||||
* data-scope="count: 0, name: 'x'" declare reactive state on a subtree
|
||||
* data-on-<event>="count++" run a statement in scope on an event
|
||||
* data-text="expr" element textContent follows an expression
|
||||
* data-wrnexus-csr="id" target for generated CSR fetch bindings
|
||||
* {{expr}} or {expr} interpolation inside text nodes
|
||||
*
|
||||
* Expressions are evaluated by a tiny parser instead of `eval`/`new Function`,
|
||||
* so production can use a strong CSP without `unsafe-eval`.
|
||||
*/
|
||||
export const REACTIVE_RUNTIME = String.raw`
|
||||
(function () {
|
||||
function signal(initial) {
|
||||
var value = initial;
|
||||
var subs = new Set();
|
||||
return {
|
||||
get: function () { return value; },
|
||||
set: function (v) {
|
||||
if (Object.is(v, value)) return;
|
||||
value = v;
|
||||
subs.forEach(function (f) { f(value); });
|
||||
},
|
||||
subscribe: function (f) { subs.add(f); return function () { subs.delete(f); }; }
|
||||
};
|
||||
}
|
||||
|
||||
function setupScope(el) {
|
||||
if (el.__wrnexusScope) return; // idempotent: safe to call again after an HMR morph
|
||||
el.__wrnexusScope = true;
|
||||
el.__wrnexusHydrated = true; // marks the subtree as client-owned for the HMR morph
|
||||
var decl = el.getAttribute("data-scope") || "";
|
||||
var initial = parseScopeDecl(decl);
|
||||
|
||||
var signals = {};
|
||||
Object.keys(initial).forEach(function (k) { signals[k] = signal(initial[k]); });
|
||||
|
||||
var renderers = [];
|
||||
// Dependency-tracked rendering: while a renderer runs, every signal it reads
|
||||
// subscribes THAT renderer (not a blanket "re-render everything"). A signal
|
||||
// change then re-runs only the renderers that actually read it. The Set in
|
||||
// signal.subscribe dedupes, so re-subscribing each run is cheap and bounded.
|
||||
var currentRenderer = null;
|
||||
function reactive(fn) {
|
||||
function run() {
|
||||
var prev = currentRenderer;
|
||||
currentRenderer = run;
|
||||
try { fn(); } finally { currentRenderer = prev; }
|
||||
}
|
||||
renderers.push(run);
|
||||
return run;
|
||||
}
|
||||
function renderAll() { renderers.forEach(function (f) { f(); }); }
|
||||
|
||||
function readScope(name) {
|
||||
var sig = signals[name];
|
||||
if (!sig) return undefined;
|
||||
if (currentRenderer) sig.subscribe(currentRenderer); // track dependency
|
||||
return sig.get();
|
||||
}
|
||||
function peekScope(name) {
|
||||
return signals[name] ? signals[name].get() : undefined;
|
||||
}
|
||||
|
||||
function evalExpr(expr) {
|
||||
return evaluateExpression(expr, readScope);
|
||||
}
|
||||
function runStmt(stmt) {
|
||||
splitTopLevel(stmt, ";").forEach(function (part) {
|
||||
runStatement(part, function (e) { return evaluateExpression(e, peekScope); }, peekScope, function (name, value) {
|
||||
if (!signals[name]) {
|
||||
signals[name] = signal(value);
|
||||
renderAll(); // new variable: re-run once so readers pick it up + re-track
|
||||
} else {
|
||||
signals[name].set(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// A binding belongs to THIS scope only when el is the node's nearest
|
||||
// [data-scope] ancestor. Otherwise a nested scope owns it and we skip it,
|
||||
// so an outer scope never clobbers an inner one's values.
|
||||
function owns(node) {
|
||||
var host = node.nodeType === 1 ? node : node.parentNode;
|
||||
return !!host && host.closest && host.closest("[data-scope]") === el;
|
||||
}
|
||||
|
||||
// --- data-for list rendering -------------------------------------------
|
||||
// Each [data-for="item in list"] element is a per-item template. On any
|
||||
// change to the list (or a dependency an item reads), the list re-renders.
|
||||
function parseFor(value) {
|
||||
var m = /^\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*)\s*)?\s+in\s+([\s\S]+?)\s*$/.exec(
|
||||
value || "",
|
||||
);
|
||||
return m ? { item: m[1], index: m[2], list: m[3] } : null;
|
||||
}
|
||||
function fillMustache(str, itemEval) {
|
||||
return str.replace(/\{\{\s*([^}]+?)\s*\}\}|\{([^{}]+)\}/g, function (_, d, s) {
|
||||
var e = (d || s).trim();
|
||||
try { return String(itemEval(e)); } catch (err) { return ""; }
|
||||
});
|
||||
}
|
||||
function hydrateItem(root, locals) {
|
||||
function localRead(name) {
|
||||
return Object.prototype.hasOwnProperty.call(locals, name) ? locals[name] : peekScope(name);
|
||||
}
|
||||
function itemEval(expr) { return evaluateExpression(expr, localRead); }
|
||||
|
||||
var els = [root];
|
||||
if (root.querySelectorAll) Array.prototype.push.apply(els, root.querySelectorAll("*"));
|
||||
els.forEach(function (n) {
|
||||
if (n.nodeType !== 1) return;
|
||||
Array.prototype.slice.call(n.attributes).forEach(function (attr) {
|
||||
if (attr.name === "data-text") {
|
||||
try { n.textContent = String(itemEval(attr.value)); } catch (e) { /* ignore */ }
|
||||
} else if (attr.name.indexOf("data-on-") === 0) {
|
||||
var evt = attr.name.slice("data-on-".length);
|
||||
var stmt = attr.value;
|
||||
n.addEventListener(evt, function () {
|
||||
try {
|
||||
splitTopLevel(stmt, ";").forEach(function (part) {
|
||||
runStatement(part, itemEval, localRead, function (name, value) {
|
||||
if (Object.prototype.hasOwnProperty.call(locals, name)) locals[name] = value;
|
||||
else if (!signals[name]) { signals[name] = signal(value); renderAll(); }
|
||||
else signals[name].set(value);
|
||||
});
|
||||
});
|
||||
} catch (e) { console.error("[wrnexus] data-for handler error", e); }
|
||||
});
|
||||
} else if (attr.value.indexOf("{") !== -1) {
|
||||
attr.value = fillMustache(attr.value, itemEval);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
|
||||
var tn;
|
||||
while ((tn = walker.nextNode())) {
|
||||
if (tn.nodeValue.indexOf("{") === -1) continue;
|
||||
tn.nodeValue = fillMustache(tn.nodeValue, itemEval);
|
||||
}
|
||||
}
|
||||
|
||||
Array.prototype.slice.call(el.querySelectorAll("[data-for]")).forEach(function (tpl) {
|
||||
if (!tpl.parentNode || !owns(tpl)) return;
|
||||
var spec = parseFor(tpl.getAttribute("data-for"));
|
||||
if (!spec) return;
|
||||
tpl.removeAttribute("data-for");
|
||||
var parent = tpl.parentNode;
|
||||
var marker = document.createComment("wire-for");
|
||||
parent.insertBefore(marker, tpl);
|
||||
parent.removeChild(tpl);
|
||||
var clones = [];
|
||||
reactive(function () {
|
||||
var list = evalExpr(spec.list);
|
||||
if (!list || typeof list.length !== "number") list = [];
|
||||
for (var c = 0; c < clones.length; c++) {
|
||||
if (clones[c].parentNode) clones[c].parentNode.removeChild(clones[c]);
|
||||
}
|
||||
clones = [];
|
||||
var frag = document.createDocumentFragment();
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
var clone = tpl.cloneNode(true);
|
||||
var locals = {};
|
||||
locals[spec.item] = list[i];
|
||||
if (spec.index) locals[spec.index] = i;
|
||||
hydrateItem(clone, locals);
|
||||
frag.appendChild(clone);
|
||||
clones.push(clone);
|
||||
}
|
||||
parent.insertBefore(frag, marker.nextSibling);
|
||||
});
|
||||
});
|
||||
|
||||
// data-text bindings
|
||||
el.querySelectorAll("[data-text]").forEach(function (node) {
|
||||
if (!owns(node)) return;
|
||||
var expr = node.getAttribute("data-text");
|
||||
reactive(function () {
|
||||
try { node.textContent = String(evalExpr(expr)); } catch (e) { /* ignore */ }
|
||||
});
|
||||
});
|
||||
|
||||
// data-show="expr" — toggle visibility on truthiness.
|
||||
el.querySelectorAll("[data-show]").forEach(function (node) {
|
||||
if (!owns(node)) return;
|
||||
var showExpr = node.getAttribute("data-show");
|
||||
reactive(function () {
|
||||
var visible = true;
|
||||
try { visible = !!evalExpr(showExpr); } catch (e) { /* keep visible */ }
|
||||
node.style.display = visible ? "" : "none";
|
||||
});
|
||||
});
|
||||
|
||||
// {{expr}} / {expr} interpolation in text nodes
|
||||
var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);
|
||||
var textNode;
|
||||
while ((textNode = walker.nextNode())) {
|
||||
var template = textNode.nodeValue;
|
||||
if (template.indexOf("{") === -1) continue;
|
||||
if (!owns(textNode)) continue;
|
||||
(function (node, tpl) {
|
||||
reactive(function () {
|
||||
node.nodeValue = tpl.replace(/\{\{\s*([^}]+?)\s*\}\}|\{([^{}]+)\}/g, function (_, doubleExpr, singleExpr) {
|
||||
var expr = doubleExpr || singleExpr;
|
||||
try { return String(evalExpr(expr.trim())); } catch (err) { return ""; }
|
||||
});
|
||||
});
|
||||
})(textNode, template);
|
||||
}
|
||||
|
||||
// data-on-<event> handlers, on the scope element and the descendants it owns.
|
||||
var nodes = [el].concat(Array.prototype.slice.call(el.querySelectorAll("*")));
|
||||
nodes.forEach(function (node) {
|
||||
if (!owns(node)) return;
|
||||
Array.prototype.slice.call(node.attributes).forEach(function (attr) {
|
||||
if (attr.name.indexOf("data-on-") !== 0) return;
|
||||
var evt = attr.name.slice("data-on-".length);
|
||||
var stmt = attr.value;
|
||||
node.addEventListener(evt, function () {
|
||||
try { runStmt(stmt); } catch (e) {
|
||||
console.error("[wrnexus] handler error in '" + stmt + "'", e);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
renderAll();
|
||||
}
|
||||
|
||||
function hydrateScopes(root) {
|
||||
(root || document).querySelectorAll("[data-scope]").forEach(setupScope);
|
||||
}
|
||||
|
||||
function parseScopeDecl(decl) {
|
||||
var initial = {};
|
||||
splitTopLevel(decl, ",").forEach(function (part) {
|
||||
var idx = findTopLevel(part, ":");
|
||||
if (idx < 0) return;
|
||||
var name = part.slice(0, idx).trim();
|
||||
var expr = part.slice(idx + 1).trim();
|
||||
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name)) return;
|
||||
try {
|
||||
initial[name] = evaluateExpression(expr, function () { return undefined; });
|
||||
} catch (e) {
|
||||
console.error("[wrnexus] invalid data-scope value for '" + name + "'", e);
|
||||
}
|
||||
});
|
||||
return initial;
|
||||
}
|
||||
|
||||
function runStatement(stmt, evalExpr, read, write) {
|
||||
stmt = String(stmt || "").trim();
|
||||
if (!stmt) return;
|
||||
|
||||
var inc = stmt.match(/^([A-Za-z_$][A-Za-z0-9_$]*)\s*(\+\+|--)$/);
|
||||
if (inc) {
|
||||
write(inc[1], Number(read(inc[1]) || 0) + (inc[2] === "++" ? 1 : -1));
|
||||
return;
|
||||
}
|
||||
|
||||
var assign = stmt.match(/^([A-Za-z_$][A-Za-z0-9_$]*)\s*(\+=|-=|\*=|\/=|%=|=)\s*([\s\S]+)$/);
|
||||
if (!assign) {
|
||||
evalExpr(stmt); // bare expression statement (e.g. a method/function call)
|
||||
return;
|
||||
}
|
||||
|
||||
var name = assign[1];
|
||||
var op = assign[2];
|
||||
var next = evalExpr(assign[3]);
|
||||
var current = read(name);
|
||||
if (op === "+=") next = current + next;
|
||||
else if (op === "-=") next = Number(current || 0) - Number(next || 0);
|
||||
else if (op === "*=") next = Number(current || 0) * Number(next || 0);
|
||||
else if (op === "/=") next = Number(current || 0) / Number(next || 0);
|
||||
else if (op === "%=") next = Number(current || 0) % Number(next || 0);
|
||||
write(name, next);
|
||||
}
|
||||
|
||||
// A small, eval-free expression evaluator (so a strict CSP needs no
|
||||
// 'unsafe-eval'). Supports: literals, identifiers, member access (a.b, a[b]),
|
||||
// function/method calls, arrays, objects, arithmetic, comparison, equality,
|
||||
// logical (&& ||), unary (! - +), and the ternary operator.
|
||||
function evaluateExpression(expr, read) {
|
||||
var tokens = tokenizeExpression(String(expr || ""));
|
||||
var index = 0;
|
||||
function peek() { return tokens[index]; }
|
||||
function next() { return tokens[index++]; }
|
||||
function is(v) { return peek() && peek().value === v; }
|
||||
function match(v) { if (is(v)) { index++; return true; } return false; }
|
||||
function expect(v) { if (!match(v)) throw new Error("Expected '" + v + "'"); }
|
||||
|
||||
function parsePrimary() {
|
||||
var t = next();
|
||||
if (!t) throw new Error("Unexpected end of expression");
|
||||
if (t.type === "number" || t.type === "string") return { value: t.value };
|
||||
if (t.type === "ident") {
|
||||
if (t.value === "true") return { value: true };
|
||||
if (t.value === "false") return { value: false };
|
||||
if (t.value === "null") return { value: null };
|
||||
if (t.value === "undefined") return { value: undefined };
|
||||
return { value: read(t.value) };
|
||||
}
|
||||
if (t.value === "(") { var v = parseTernary(); expect(")"); return { value: v }; }
|
||||
if (t.value === "[") {
|
||||
var arr = [];
|
||||
if (!is("]")) { arr.push(parseTernary()); while (match(",")) arr.push(parseTernary()); }
|
||||
expect("]");
|
||||
return { value: arr };
|
||||
}
|
||||
if (t.value === "{") {
|
||||
var obj = {};
|
||||
if (!is("}")) {
|
||||
do {
|
||||
var kt = next();
|
||||
var key = kt.value;
|
||||
expect(":");
|
||||
obj[key] = parseTernary();
|
||||
} while (match(","));
|
||||
}
|
||||
expect("}");
|
||||
return { value: obj };
|
||||
}
|
||||
throw new Error("Unexpected token '" + t.value + "'");
|
||||
}
|
||||
|
||||
function parsePostfix() {
|
||||
var node = parsePrimary();
|
||||
for (;;) {
|
||||
if (match(".")) {
|
||||
var prop = next().value;
|
||||
node = { value: node.value == null ? undefined : node.value[prop], obj: node.value };
|
||||
} else if (match("[")) {
|
||||
var key = parseTernary();
|
||||
expect("]");
|
||||
node = { value: node.value == null ? undefined : node.value[key], obj: node.value };
|
||||
} else if (is("(")) {
|
||||
next();
|
||||
var args = [];
|
||||
if (!is(")")) { args.push(parseTernary()); while (match(",")) args.push(parseTernary()); }
|
||||
expect(")");
|
||||
var fn = node.value;
|
||||
node = { value: typeof fn === "function" ? fn.apply(node.obj, args) : undefined };
|
||||
} else break;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function parseUnary() {
|
||||
if (match("!")) return !parseUnary();
|
||||
if (match("-")) return -parseUnary();
|
||||
if (match("+")) return +parseUnary();
|
||||
return parsePostfix().value;
|
||||
}
|
||||
function parseMul() {
|
||||
var l = parseUnary();
|
||||
while (peek() && (is("*") || is("/") || is("%"))) {
|
||||
var op = next().value, r = parseUnary();
|
||||
l = op === "*" ? l * r : op === "/" ? l / r : l % r;
|
||||
}
|
||||
return l;
|
||||
}
|
||||
function parseAdd() {
|
||||
var l = parseMul();
|
||||
while (peek() && (is("+") || is("-"))) {
|
||||
var op = next().value, r = parseMul();
|
||||
l = op === "+" ? l + r : l - r;
|
||||
}
|
||||
return l;
|
||||
}
|
||||
function parseCmp() {
|
||||
var l = parseAdd();
|
||||
while (peek() && (is("<") || is(">") || is("<=") || is(">="))) {
|
||||
var op = next().value, r = parseAdd();
|
||||
l = op === "<" ? l < r : op === ">" ? l > r : op === "<=" ? l <= r : l >= r;
|
||||
}
|
||||
return l;
|
||||
}
|
||||
function parseEq() {
|
||||
var l = parseCmp();
|
||||
while (peek() && (is("==") || is("!=") || is("===") || is("!=="))) {
|
||||
var op = next().value, r = parseCmp();
|
||||
l = op === "==" ? l == r : op === "!=" ? l != r : op === "===" ? l === r : l !== r;
|
||||
}
|
||||
return l;
|
||||
}
|
||||
function parseAnd() {
|
||||
var l = parseEq();
|
||||
while (match("&&")) l = l && parseEq();
|
||||
return l;
|
||||
}
|
||||
function parseOr() {
|
||||
var l = parseAnd();
|
||||
while (match("||")) l = l || parseAnd();
|
||||
return l;
|
||||
}
|
||||
function parseTernary() {
|
||||
var c = parseOr();
|
||||
if (match("?")) { var a = parseTernary(); expect(":"); var b = parseTernary(); return c ? a : b; }
|
||||
return c;
|
||||
}
|
||||
|
||||
var value = parseTernary();
|
||||
if (index < tokens.length) throw new Error("Unexpected token '" + tokens[index].value + "'");
|
||||
return value;
|
||||
}
|
||||
|
||||
function tokenizeExpression(input) {
|
||||
var tokens = [];
|
||||
var i = 0;
|
||||
while (i < input.length) {
|
||||
var ch = input[i];
|
||||
if (/\s/.test(ch)) { i++; continue; }
|
||||
if (/[0-9]/.test(ch) || (ch === "." && /[0-9]/.test(input[i + 1]))) {
|
||||
var start = i++;
|
||||
while (i < input.length && /[0-9.]/.test(input[i])) i++;
|
||||
tokens.push({ type: "number", value: Number(input.slice(start, i)) });
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
var quote = ch, value = "";
|
||||
i++;
|
||||
while (i < input.length) {
|
||||
ch = input[i++];
|
||||
if (ch === quote) break;
|
||||
if (ch === "\\") {
|
||||
var esc = input[i++];
|
||||
value += esc === "n" ? "\n" : esc === "t" ? "\t" : esc || "";
|
||||
} else value += ch;
|
||||
}
|
||||
tokens.push({ type: "string", value: value });
|
||||
continue;
|
||||
}
|
||||
if (/[A-Za-z_$]/.test(ch)) {
|
||||
var s = i++;
|
||||
while (i < input.length && /[A-Za-z0-9_$]/.test(input[i])) i++;
|
||||
tokens.push({ type: "ident", value: input.slice(s, i) });
|
||||
continue;
|
||||
}
|
||||
var three = input.substr(i, 3);
|
||||
if (three === "===" || three === "!==") { tokens.push({ type: "op", value: three }); i += 3; continue; }
|
||||
var two = input.substr(i, 2);
|
||||
if (["==", "!=", "<=", ">=", "&&", "||"].indexOf(two) !== -1) {
|
||||
tokens.push({ type: "op", value: two });
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if ("()+-*/%!<>.,?:[]{}".indexOf(ch) !== -1) { tokens.push({ type: "op", value: ch }); i++; continue; }
|
||||
throw new Error("Unexpected character '" + ch + "'");
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function splitTopLevel(input, separator) {
|
||||
var parts = [];
|
||||
var start = 0;
|
||||
var depth = 0;
|
||||
var quote = "";
|
||||
for (var i = 0; i < input.length; i++) {
|
||||
var ch = input[i];
|
||||
if (quote) {
|
||||
if (ch === "\\") i++;
|
||||
else if (ch === quote) quote = "";
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === "(" || ch === "[" || ch === "{") depth++;
|
||||
else if (ch === ")" || ch === "]" || ch === "}") depth--;
|
||||
else if (ch === separator && depth === 0) {
|
||||
parts.push(input.slice(start, i).trim());
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
parts.push(input.slice(start).trim());
|
||||
return parts.filter(Boolean);
|
||||
}
|
||||
|
||||
function findTopLevel(input, needle) {
|
||||
var depth = 0;
|
||||
var quote = "";
|
||||
for (var i = 0; i < input.length; i++) {
|
||||
var ch = input[i];
|
||||
if (quote) {
|
||||
if (ch === "\\") i++;
|
||||
else if (ch === quote) quote = "";
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") quote = ch;
|
||||
else if (ch === "(" || ch === "[" || ch === "{") depth++;
|
||||
else if (ch === ")" || ch === "]" || ch === "}") depth--;
|
||||
else if (ch === needle && depth === 0) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function safeCsrUrl(id) {
|
||||
try {
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(id)) return null;
|
||||
return "/__wrnexus/csr?route=" + encodeURIComponent(location.pathname) + "&id=" + encodeURIComponent(id);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setupCsrFetch(el) {
|
||||
if (el.__wrnexusCsrFetch) return;
|
||||
el.__wrnexusCsrFetch = true;
|
||||
|
||||
var id = el.getAttribute("data-wrnexus-csr") || "";
|
||||
var url = safeCsrUrl(id);
|
||||
if (!url) {
|
||||
console.error("[wrnexus] blocked unsafe CSR binding '" + id + "'");
|
||||
return;
|
||||
}
|
||||
|
||||
var headers = { "accept": "text/plain" };
|
||||
var storage = localStorageSnapshotHeader();
|
||||
if (storage) headers["x-wrnexus-local-storage"] = storage;
|
||||
|
||||
fetch(url, { headers: headers })
|
||||
.then(function (res) {
|
||||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||
return res.text();
|
||||
})
|
||||
.then(function (text) {
|
||||
el.textContent = text;
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.error("[wrnexus] CSR fetch failed for binding '" + id + "'", err);
|
||||
el.textContent = "Failed to load";
|
||||
});
|
||||
}
|
||||
|
||||
function hydrateCsrFetches(root) {
|
||||
(root || document).querySelectorAll("[data-wrnexus-csr]").forEach(setupCsrFetch);
|
||||
}
|
||||
|
||||
function localStorageSnapshotHeader() {
|
||||
try {
|
||||
if (!("localStorage" in window)) return "";
|
||||
var values = {};
|
||||
for (var i = 0; i < window.localStorage.length; i++) {
|
||||
var key = window.localStorage.key(i);
|
||||
if (!key) continue;
|
||||
var value = window.localStorage.getItem(key);
|
||||
if (typeof value === "string") values[key] = value;
|
||||
}
|
||||
var encoded = encodeURIComponent(JSON.stringify(values));
|
||||
return encoded.length <= 12000 ? encoded : "";
|
||||
} catch (_) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
window.__wrnexusHydrateScopes = hydrateScopes;
|
||||
window.__wrnexusHydrateCsrFetches = hydrateCsrFetches;
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
hydrateScopes(document);
|
||||
hydrateCsrFetches(document);
|
||||
});
|
||||
} else {
|
||||
hydrateScopes(document);
|
||||
hydrateCsrFetches(document);
|
||||
}
|
||||
})();
|
||||
`.trim();
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Client realtime runtime, served at `/__wrnexus/realtime.js`.
|
||||
*
|
||||
* Two ways to use it — no hand-written WebSocket code either way:
|
||||
*
|
||||
* 1. Declarative (zero JS). Put `data-room="<name>"` on a container; the runtime
|
||||
* connects, appends incoming messages to `[data-room-log]` using a
|
||||
* `<template data-room-item="<type>">` (fields via `%field%`, HTML-escaped),
|
||||
* reflects connection state on `[data-room-status]`, and sends a
|
||||
* `<form data-room-send>`'s named fields as JSON on submit (fields marked
|
||||
* `data-room-reset` clear after send). Optional `data-room-user` identifies
|
||||
* the connection.
|
||||
*
|
||||
* 2. Programmatic: `const room = wire.room("chat"); room.on("chat", fn);
|
||||
* room.send({ type: "chat", text })`. Handles connect, JSON, reconnect.
|
||||
*
|
||||
* Rebinds on `wrnexus:navigated` (client-side nav) and closes rooms whose
|
||||
* container has left the page.
|
||||
*/
|
||||
|
||||
export const REALTIME_RUNTIME = String.raw`
|
||||
(function () {
|
||||
if (!("WebSocket" in window)) return;
|
||||
var wire = (window.wire = window.wire || {});
|
||||
if (wire.room) return; // already installed
|
||||
var open = {}; // name -> room connection
|
||||
|
||||
function openRoom(name, query) {
|
||||
if (open[name]) return open[name];
|
||||
var ws = null, queue = [], listeners = [], attempts = 0, timer = null, closed = false;
|
||||
|
||||
function url() {
|
||||
var proto = location.protocol === "https:" ? "wss" : "ws";
|
||||
var q = query ? "?" + query : "";
|
||||
return proto + "://" + location.host + "/realtime/" + name + q;
|
||||
}
|
||||
function emit(msg) {
|
||||
for (var i = 0; i < listeners.length; i++) {
|
||||
var l = listeners[i];
|
||||
if (!l.type || l.type === "*" || l.type === msg.type) {
|
||||
try { l.cb(msg); } catch (e) { console.error("[wrnexus] room '" + name + "' listener error", e); }
|
||||
}
|
||||
}
|
||||
}
|
||||
function connect() {
|
||||
ws = new WebSocket(url());
|
||||
ws.onopen = function () {
|
||||
attempts = 0;
|
||||
for (var i = 0; i < queue.length; i++) ws.send(queue[i]);
|
||||
queue = [];
|
||||
emit({ type: "__open" });
|
||||
};
|
||||
ws.onclose = function () {
|
||||
ws = null;
|
||||
emit({ type: "__close" });
|
||||
if (!closed) {
|
||||
var delay = Math.min(5000, 400 * Math.pow(2, attempts++));
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(connect, delay);
|
||||
}
|
||||
};
|
||||
ws.onerror = function () { emit({ type: "__error" }); };
|
||||
ws.onmessage = function (e) {
|
||||
var msg;
|
||||
try { msg = JSON.parse(e.data); } catch (_) { msg = { type: "__raw", data: e.data }; }
|
||||
emit(msg);
|
||||
};
|
||||
}
|
||||
|
||||
var api = {
|
||||
name: name,
|
||||
send: function (obj) {
|
||||
var payload = typeof obj === "string" ? obj : JSON.stringify(obj);
|
||||
if (ws && ws.readyState === 1) ws.send(payload);
|
||||
else queue.push(payload);
|
||||
return api;
|
||||
},
|
||||
on: function (type, cb) {
|
||||
if (typeof type === "function") { cb = type; type = "*"; }
|
||||
listeners.push({ type: type, cb: cb });
|
||||
return api;
|
||||
},
|
||||
close: function () { closed = true; clearTimeout(timer); if (ws) try { ws.close(); } catch (_) {} ws = null; delete open[name]; },
|
||||
};
|
||||
open[name] = api;
|
||||
connect();
|
||||
return api;
|
||||
}
|
||||
wire.room = openRoom;
|
||||
|
||||
// --- Declarative binding ---------------------------------------------------
|
||||
|
||||
// Fill %field% placeholders in a cloned template fragment WITHOUT innerHTML
|
||||
// (setting text/attr values, never parsing HTML) — so it works under a strict
|
||||
// Trusted-Types CSP, and message text can never be interpreted as markup.
|
||||
function subst(str, msg) {
|
||||
return str.replace(/%(\w+)%/g, function (_, k) {
|
||||
return msg[k] == null ? "" : String(msg[k]);
|
||||
});
|
||||
}
|
||||
function fillNode(node, msg) {
|
||||
if (node.nodeType === 3) {
|
||||
if (node.nodeValue.indexOf("%") !== -1) node.nodeValue = subst(node.nodeValue, msg);
|
||||
return;
|
||||
}
|
||||
if (node.nodeType === 1 && node.attributes) {
|
||||
for (var i = 0; i < node.attributes.length; i++) {
|
||||
var a = node.attributes[i];
|
||||
if (a.value.indexOf("%") !== -1) a.value = subst(a.value, msg);
|
||||
}
|
||||
}
|
||||
var kids = node.childNodes;
|
||||
for (var j = 0; j < kids.length; j++) fillNode(kids[j], msg);
|
||||
}
|
||||
|
||||
function bindContainer(el) {
|
||||
if (el.__wireRoomBound) return;
|
||||
el.__wireRoomBound = true;
|
||||
var name = el.getAttribute("data-room");
|
||||
var user = el.getAttribute("data-room-user");
|
||||
var room = openRoom(name, user ? "user=" + encodeURIComponent(user) : "");
|
||||
el.__wireRoom = room;
|
||||
|
||||
var log = el.querySelector("[data-room-log]");
|
||||
var status = el.querySelector("[data-room-status]");
|
||||
var templates = {};
|
||||
var tnodes = el.querySelectorAll("template[data-room-item]");
|
||||
for (var i = 0; i < tnodes.length; i++) {
|
||||
templates[tnodes[i].getAttribute("data-room-item") || ""] = tnodes[i];
|
||||
}
|
||||
|
||||
function setStatus(text, variant) {
|
||||
if (!status) return;
|
||||
status.textContent = text;
|
||||
if (status.hasAttribute("data-room-status-class")) {
|
||||
status.className = status.getAttribute("data-room-status-class") + " " + variant;
|
||||
}
|
||||
}
|
||||
|
||||
room.on("*", function (msg) {
|
||||
if (msg.type === "__open") return setStatus("connected", "is-connected");
|
||||
if (msg.type === "__close") return setStatus("disconnected", "is-disconnected");
|
||||
if (msg.type === "__error") return setStatus("error", "is-error");
|
||||
if (!log) return;
|
||||
var tpl = templates[msg.type];
|
||||
if (tpl == null) tpl = templates[""];
|
||||
if (tpl == null || !tpl.content) return; // no template for this type
|
||||
var frag = tpl.content.cloneNode(true);
|
||||
fillNode(frag, msg);
|
||||
log.appendChild(frag);
|
||||
log.scrollTop = log.scrollHeight;
|
||||
});
|
||||
|
||||
var form = el.querySelector("form[data-room-send]");
|
||||
if (form && !form.__wireRoomForm) {
|
||||
form.__wireRoomForm = true;
|
||||
form.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
var data = {};
|
||||
for (var i = 0; i < form.elements.length; i++) {
|
||||
var input = form.elements[i];
|
||||
if (input.name) data[input.name] = input.value;
|
||||
}
|
||||
room.send(data);
|
||||
for (var j = 0; j < form.elements.length; j++) {
|
||||
if (form.elements[j].hasAttribute("data-room-reset")) form.elements[j].value = "";
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function bindAll(root) {
|
||||
var containers = (root || document).querySelectorAll("[data-room]");
|
||||
var present = {};
|
||||
for (var i = 0; i < containers.length; i++) {
|
||||
present[containers[i].getAttribute("data-room")] = true;
|
||||
bindContainer(containers[i]);
|
||||
}
|
||||
// Close rooms whose container has left the page (client-side navigation).
|
||||
for (var nm in open) if (!present[nm]) open[nm].close();
|
||||
}
|
||||
|
||||
wire.bindRooms = bindAll;
|
||||
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", function () { bindAll(document); });
|
||||
else bindAll(document);
|
||||
window.addEventListener("wrnexus:navigated", function () { bindAll(document); });
|
||||
})();
|
||||
`.trim();
|
||||
@@ -0,0 +1,93 @@
|
||||
import { test, expect, beforeEach } from "bun:test";
|
||||
import { Window } from "happy-dom";
|
||||
import { NAV_RUNTIME } from "../src/nav-runtime.ts";
|
||||
|
||||
let win: any;
|
||||
let fetchCalls: { url: string; opts: any }[];
|
||||
let nextHtml: string;
|
||||
|
||||
function install(bodyHtml: string): void {
|
||||
win = new Window({ url: "https://example.test/" });
|
||||
win.document.body.innerHTML = bodyHtml;
|
||||
fetchCalls = [];
|
||||
nextHtml = "";
|
||||
const g = globalThis as any;
|
||||
g.window = win;
|
||||
g.document = win.document;
|
||||
g.history = win.history;
|
||||
g.location = win.location;
|
||||
g.DOMParser = win.DOMParser;
|
||||
g.CustomEvent = win.CustomEvent;
|
||||
g.fetch = win.fetch = (url: string, opts: any) => {
|
||||
fetchCalls.push({ url, opts });
|
||||
return Promise.resolve({
|
||||
redirected: false,
|
||||
url,
|
||||
headers: {
|
||||
get: (k: string) =>
|
||||
k.toLowerCase() === "content-type" ? "text/html; charset=utf-8" : null,
|
||||
},
|
||||
text: () => Promise.resolve(nextHtml),
|
||||
});
|
||||
};
|
||||
(0, eval)(NAV_RUNTIME);
|
||||
}
|
||||
|
||||
const flush = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
beforeEach(() => {
|
||||
const g = globalThis as any;
|
||||
for (const k of [
|
||||
"window",
|
||||
"document",
|
||||
"history",
|
||||
"location",
|
||||
"DOMParser",
|
||||
"CustomEvent",
|
||||
"fetch",
|
||||
]) {
|
||||
delete g[k];
|
||||
}
|
||||
});
|
||||
|
||||
test("intercepts an internal link click and swaps #app in place", async () => {
|
||||
install(`<div id="app"><h1>Home</h1><a href="/about" id="lnk">About</a></div>`);
|
||||
nextHtml =
|
||||
`<!doctype html><html><head><title>About</title></head>` +
|
||||
`<body><div id="app"><h1>About page</h1></div></body></html>`;
|
||||
win.document.getElementById("lnk").click();
|
||||
await flush();
|
||||
expect(fetchCalls.length).toBe(1);
|
||||
expect(fetchCalls[0]!.url).toContain("/about");
|
||||
expect(fetchCalls[0]!.opts.headers["x-wrnexus-nav"]).toBe("1");
|
||||
expect(win.document.getElementById("app").innerHTML).toContain("About page");
|
||||
expect(win.document.title).toBe("About");
|
||||
});
|
||||
|
||||
test("ignores cross-origin links (full navigation)", async () => {
|
||||
install(`<div id="app"><a href="https://other.test/x" id="lnk">x</a></div>`);
|
||||
win.document.getElementById("lnk").click();
|
||||
await flush();
|
||||
expect(fetchCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
test("ignores modified clicks so new-tab still works", async () => {
|
||||
install(`<div id="app"><a href="/about" id="lnk">x</a></div>`);
|
||||
win.document
|
||||
.getElementById("lnk")
|
||||
.dispatchEvent(
|
||||
new win.MouseEvent("click", { bubbles: true, cancelable: true, button: 0, metaKey: true }),
|
||||
);
|
||||
await flush();
|
||||
expect(fetchCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
test("exposes programmatic navigation", async () => {
|
||||
install(`<div id="app"><h1>Home</h1></div>`);
|
||||
nextHtml = `<html><head><title>Dash</title></head><body><div id="app"><h1>Dashboard</h1></div></body></html>`;
|
||||
expect(typeof win.__wrnexusNavigate).toBe("function");
|
||||
win.__wrnexusNavigate("/dashboard");
|
||||
await flush();
|
||||
expect(fetchCalls.length).toBe(1);
|
||||
expect(win.document.getElementById("app").innerHTML).toContain("Dashboard");
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { test, expect, beforeEach } from "bun:test";
|
||||
import { Window } from "happy-dom";
|
||||
import { REACTIVE_RUNTIME } from "../src/reactive-runtime.ts";
|
||||
|
||||
// Fresh DOM per test, with the runtime's globals bound.
|
||||
function mount(html: string): Window {
|
||||
const win = new Window() as unknown as Window & Record<string, unknown>;
|
||||
win.document.body.innerHTML = `<div id="app">${html}</div>`;
|
||||
(globalThis as Record<string, unknown>).window = win;
|
||||
(globalThis as Record<string, unknown>).document = win.document;
|
||||
(globalThis as Record<string, unknown>).NodeFilter = (
|
||||
win as unknown as { NodeFilter: unknown }
|
||||
).NodeFilter;
|
||||
(0, eval)(REACTIVE_RUNTIME);
|
||||
// Hydrate deterministically (auto-init waits on DOMContentLoaded, which the
|
||||
// test window may not fire). setupScope is idempotent, so this is safe.
|
||||
const w = win as unknown as { __wrnexusHydrateScopes?: (root: unknown) => void };
|
||||
w.__wrnexusHydrateScopes?.(win.document);
|
||||
return win as unknown as Window;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
delete (globalThis as Record<string, unknown>).window;
|
||||
delete (globalThis as Record<string, unknown>).document;
|
||||
});
|
||||
|
||||
test("hydrates {expr} mustaches from data-scope", () => {
|
||||
const win = mount(`<div data-scope="count: 0"><span>{count}, {count * 2}</span></div>`);
|
||||
expect(win.document.querySelector("span")!.textContent).toBe("0, 0");
|
||||
});
|
||||
|
||||
test("@event (data-on-click) mutates a signal and re-renders", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="count: 0"><button data-on-click="count++">{count}</button></div>`,
|
||||
);
|
||||
const btn = win.document.querySelector("button")!;
|
||||
expect(btn.textContent).toBe("0");
|
||||
btn.click();
|
||||
btn.click();
|
||||
expect(btn.textContent).toBe("2");
|
||||
});
|
||||
|
||||
test("nested scopes don't clobber each other (regression)", () => {
|
||||
// An empty outer scope must not touch inner scopes' values.
|
||||
const win = mount(
|
||||
`<div data-scope="">
|
||||
<div data-scope="count: 0"><button data-on-click="count++">A{count}</button></div>
|
||||
<div data-scope="count: 10"><button data-on-click="count++">B{count}</button></div>
|
||||
</div>`,
|
||||
);
|
||||
const [a, b] = Array.from(win.document.querySelectorAll("button"));
|
||||
expect(a!.textContent).toBe("A0");
|
||||
expect(b!.textContent).toBe("B10");
|
||||
a!.click();
|
||||
expect(a!.textContent).toBe("A1");
|
||||
expect(b!.textContent).toBe("B10"); // unchanged
|
||||
});
|
||||
|
||||
test("data-text binds an element's textContent to an expression", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="n: 3"><strong data-text="n * 3">?</strong><button data-on-click="n = 5">x</button></div>`,
|
||||
);
|
||||
expect(win.document.querySelector("strong")!.textContent).toBe("9");
|
||||
win.document.querySelector("button")!.click();
|
||||
expect(win.document.querySelector("strong")!.textContent).toBe("15");
|
||||
});
|
||||
|
||||
test("string scope values bind via data-text", () => {
|
||||
const win = mount(`<div data-scope="msg: 'hi'"><strong data-text="msg">?</strong></div>`);
|
||||
expect(win.document.querySelector("strong")!.textContent).toBe("hi");
|
||||
});
|
||||
|
||||
test("data-for renders a list of objects and reacts to array changes", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="todos: [{text: 'a'}, {text: 'b'}]">
|
||||
<ul><li data-for="t in todos" data-text="t.text"></li></ul>
|
||||
<button id="add" data-on-click="todos = todos.concat([{text: 'c'}])">add</button>
|
||||
<button id="clear" data-on-click="todos = []">clear</button>
|
||||
</div>`,
|
||||
);
|
||||
const items = () => Array.from(win.document.querySelectorAll("li"), (li) => li.textContent);
|
||||
expect(items()).toEqual(["a", "b"]);
|
||||
(win.document.getElementById("add") as unknown as HTMLElement).click();
|
||||
expect(items()).toEqual(["a", "b", "c"]);
|
||||
(win.document.getElementById("clear") as unknown as HTMLElement).click();
|
||||
expect(items()).toEqual([]);
|
||||
});
|
||||
|
||||
test("data-for exposes item + index, mustaches and member access", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="rows: [{name: 'x'}, {name: 'y'}]">
|
||||
<ul><li data-for="r, i in rows">{i}:{r.name}</li></ul>
|
||||
</div>`,
|
||||
);
|
||||
expect(Array.from(win.document.querySelectorAll("li"), (li) => li.textContent)).toEqual([
|
||||
"0:x",
|
||||
"1:y",
|
||||
]);
|
||||
});
|
||||
|
||||
test("expression evaluator: member access, ternary, comparison, calls", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="user: {name: 'Ada', age: 36}, items: [1, 2, 3]">
|
||||
<span id="a" data-text="user.name"></span>
|
||||
<span id="b" data-text="user.age > 30 ? 'senior' : 'junior'"></span>
|
||||
<span id="c" data-text="items.length"></span>
|
||||
</div>`,
|
||||
);
|
||||
expect(win.document.getElementById("a")!.textContent).toBe("Ada");
|
||||
expect(win.document.getElementById("b")!.textContent).toBe("senior");
|
||||
expect(win.document.getElementById("c")!.textContent).toBe("3");
|
||||
});
|
||||
|
||||
test("data-show toggles visibility on a reactive expression (tabs pattern)", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="tab: 0">
|
||||
<button data-on-click="tab = 1">go</button>
|
||||
<section id="a" data-show="tab === 0">A</section>
|
||||
<section id="b" data-show="tab === 1">B</section>
|
||||
</div>`,
|
||||
);
|
||||
const disp = (id: string) =>
|
||||
(win.document.getElementById(id) as unknown as HTMLElement).style.display;
|
||||
expect(disp("a")).toBe("");
|
||||
expect(disp("b")).toBe("none");
|
||||
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
||||
expect(disp("a")).toBe("none");
|
||||
expect(disp("b")).toBe("");
|
||||
});
|
||||
|
||||
test("independent signals in one scope update correctly (dependency tracking)", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="a: 0, b: 100">
|
||||
<span id="ta" data-text="a"></span>
|
||||
<span id="tb" data-text="b"></span>
|
||||
<button id="ba" data-on-click="a++">A</button>
|
||||
<button id="bb" data-on-click="b++">B</button>
|
||||
</div>`,
|
||||
);
|
||||
const ta = () => win.document.getElementById("ta")!.textContent;
|
||||
const tb = () => win.document.getElementById("tb")!.textContent;
|
||||
const click = (id: string) => (win.document.getElementById(id) as unknown as HTMLElement).click();
|
||||
expect([ta(), tb()]).toEqual(["0", "100"]);
|
||||
click("ba");
|
||||
click("ba");
|
||||
expect([ta(), tb()]).toEqual(["2", "100"]); // b untouched by a's changes
|
||||
click("bb");
|
||||
expect([ta(), tb()]).toEqual(["2", "101"]);
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { test, expect, beforeEach } from "bun:test";
|
||||
import { Window } from "happy-dom";
|
||||
import { REALTIME_RUNTIME } from "../src/realtime-runtime.ts";
|
||||
|
||||
/* A fake WebSocket that records instances + sent frames and lets tests drive events. */
|
||||
let sockets: FakeWS[];
|
||||
class FakeWS {
|
||||
url: string;
|
||||
readyState = 0;
|
||||
sent: string[] = [];
|
||||
onopen: (() => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
onmessage: ((e: { data: string }) => void) | null = null;
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
sockets.push(this);
|
||||
}
|
||||
send(data: string) {
|
||||
this.sent.push(data);
|
||||
}
|
||||
close() {
|
||||
this.readyState = 3;
|
||||
}
|
||||
fireOpen() {
|
||||
this.readyState = 1;
|
||||
this.onopen?.();
|
||||
}
|
||||
fireMessage(obj: unknown) {
|
||||
this.onmessage?.({ data: JSON.stringify(obj) });
|
||||
}
|
||||
}
|
||||
|
||||
function boot(bodyHtml: string) {
|
||||
sockets = [];
|
||||
const win = new Window({ url: "http://localhost/" }) as unknown as Window &
|
||||
Record<string, unknown>;
|
||||
win.document.body.innerHTML = bodyHtml;
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
g.window = win;
|
||||
g.document = win.document;
|
||||
g.location = win.location;
|
||||
g.WebSocket = FakeWS;
|
||||
(0, eval)(REALTIME_RUNTIME);
|
||||
return win as unknown as Window;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const k of ["window", "document", "location", "WebSocket"]) {
|
||||
delete (globalThis as Record<string, unknown>)[k];
|
||||
}
|
||||
});
|
||||
|
||||
const CHAT = `
|
||||
<div data-room="chat">
|
||||
<span data-room-status data-room-status-class="badge" class="badge">connecting…</span>
|
||||
<div data-room-log></div>
|
||||
<template data-room-item="message"><div class="msg"><strong>%user%</strong>: %text%</div></template>
|
||||
<template data-room-item="system"><div class="sys">%text%</div></template>
|
||||
<form data-room-send>
|
||||
<input name="user" value="Ada">
|
||||
<input name="text" value="hello" data-room-reset>
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
</div>`;
|
||||
|
||||
test("[data-room] connects to the right URL and reflects status", () => {
|
||||
const win = boot(CHAT);
|
||||
expect(sockets.length).toBe(1);
|
||||
expect(sockets[0]!.url).toBe("ws://localhost/realtime/chat");
|
||||
sockets[0]!.fireOpen();
|
||||
const status = win.document.querySelector("[data-room-status]")!;
|
||||
expect(status.textContent).toBe("connected");
|
||||
expect(status.className).toContain("is-connected");
|
||||
});
|
||||
|
||||
test("incoming messages render via the typed template, HTML-escaped", () => {
|
||||
const win = boot(CHAT);
|
||||
sockets[0]!.fireOpen();
|
||||
sockets[0]!.fireMessage({ type: "message", user: "<b>Ada</b>", text: "hi & bye" });
|
||||
sockets[0]!.fireMessage({ type: "system", text: "joined" });
|
||||
const log = win.document.querySelector("[data-room-log]")!;
|
||||
expect(log.querySelector(".msg strong")!.textContent).toBe("<b>Ada</b>"); // escaped, not parsed
|
||||
expect(log.querySelector(".msg")!.textContent).toBe("<b>Ada</b>: hi & bye");
|
||||
expect(log.querySelector(".sys")!.textContent).toBe("joined");
|
||||
});
|
||||
|
||||
test("submitting [data-room-send] sends JSON and clears reset fields", () => {
|
||||
const win = boot(CHAT);
|
||||
sockets[0]!.fireOpen();
|
||||
const form = win.document.querySelector("form[data-room-send]")! as unknown as HTMLFormElement;
|
||||
form.dispatchEvent(
|
||||
new (win as unknown as { Event: typeof Event }).Event("submit", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
expect(sockets[0]!.sent.length).toBe(1);
|
||||
expect(JSON.parse(sockets[0]!.sent[0]!)).toEqual({ user: "Ada", text: "hello" });
|
||||
// text field had data-room-reset → cleared; user field kept.
|
||||
const inputs = win.document.querySelectorAll("input");
|
||||
expect((inputs[0] as unknown as HTMLInputElement).value).toBe("Ada");
|
||||
expect((inputs[1] as unknown as HTMLInputElement).value).toBe("");
|
||||
});
|
||||
|
||||
test("programmatic wire.room() sends and receives", () => {
|
||||
const win = boot(`<div></div>`) as unknown as Window & {
|
||||
wire: {
|
||||
room: (n: string) => {
|
||||
on: (t: string, cb: (m: unknown) => void) => unknown;
|
||||
send: (o: unknown) => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
const got: unknown[] = [];
|
||||
const room = win.wire.room("lobby");
|
||||
room.on("ping", (m: unknown) => got.push(m));
|
||||
sockets[0]!.fireOpen();
|
||||
room.send({ type: "hello" });
|
||||
expect(JSON.parse(sockets[0]!.sent[0]!)).toEqual({ type: "hello" });
|
||||
sockets[0]!.fireMessage({ type: "ping", n: 1 });
|
||||
expect(got).toEqual([{ type: "ping", n: 1 }]);
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
# @wrnexus/db
|
||||
|
||||
> The database layer for WrNexus: TS models as the single source of truth for DDL, validation, and result typing, plus a driver-based `Db` client, migrations, and a sqlc-style query generator.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
|
||||
## Overview
|
||||
|
||||
`@wrnexus/db` is the server-side data layer. You describe tables as TypeScript
|
||||
models (the `v` column builder + `table()`); those models drive migrations,
|
||||
coerce raw DB rows into typed objects, and feed the query generator. A thin
|
||||
`Driver` interface is implemented by adapters for SQLite (`bun:sqlite`),
|
||||
Postgres/MySQL (`Bun.SQL`), and MongoDB. The `Db` client adds ergonomics —
|
||||
model-mapped `all`/`one`, transactions, `createTable`, pagination, and batched
|
||||
relation loading. A process-wide registry (`getDb`/`setDb`) exposes configured
|
||||
connections to pages and API routes. Reach for it whenever a WrNexus app needs
|
||||
persistence.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @wrnexus/db
|
||||
```
|
||||
|
||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
|
||||
|
||||
## API
|
||||
|
||||
The core entry (`@wrnexus/db`) is dependency-free; adapters and connectors live
|
||||
in subpaths so importing the core doesn't pull in every driver.
|
||||
|
||||
| Subpath | Exports |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `@wrnexus/db` | `v`, `table`, `Column`, `createDb`, `createTableSql`, the client registry (`setDb`/`getDb`/…), migrations, the query generator, and query helpers |
|
||||
| `@wrnexus/db/connect` | `connectFromConfig`, `resolveDbUrl`, `DbConfig` — resolve a config to a live SQL `Db` |
|
||||
| `@wrnexus/db/session` | `sqliteSessionStore` — a `bun:sqlite` session backend for `@wrnexus/core` |
|
||||
| `@wrnexus/db/sqlite` | `sqlite(url?)` driver |
|
||||
| `@wrnexus/db/postgres` | `postgres(url)` driver |
|
||||
| `@wrnexus/db/mysql` | `mysql(url)` driver |
|
||||
| `@wrnexus/db/mongo` | `mongo(url, dbName?)` document API |
|
||||
|
||||
### Schema — `v`, `table`, `Column`
|
||||
|
||||
`table(name, columns)` returns a `Model<T>`. Columns are built with `v`:
|
||||
|
||||
```ts
|
||||
import { v, table } from "@wrnexus/db";
|
||||
|
||||
const users = table("users", {
|
||||
id: v.id(), // auto-increment primary key
|
||||
email: v.text().unique(),
|
||||
name: v.text().optional(), // NULLable
|
||||
age: v.int().default(0),
|
||||
active: v.bool().default(true),
|
||||
createdAt: v.timestamp().default("now"), // CURRENT_TIMESTAMP
|
||||
});
|
||||
```
|
||||
|
||||
Column builders: `v.id`, `v.text` (alias `v.string`), `v.int`, `v.real` (alias
|
||||
`v.number`), `v.bool` (alias `v.boolean`), `v.timestamp`, `v.json`. `BaseType`
|
||||
values are `"id" | "text" | "int" | "real" | "bool" | "timestamp" | "json"`.
|
||||
|
||||
`Column` modifiers (chainable): `.optional()`, `.unique()`, `.default(value)`
|
||||
(use the sentinel `"now"` for a current-timestamp default), `.primaryKey()`,
|
||||
`.references(table, column = "id")`. `.coerce(raw)` converts a raw DB value to
|
||||
its JS type.
|
||||
|
||||
A `Model<T>` exposes: `name`, `columns`, `parse(row)` (coerces a raw row into a
|
||||
typed `T`; unknown columns pass through), and `describe()` (returns each
|
||||
column's `ColumnDef`, for migrations and the generator).
|
||||
|
||||
### Driver & client — `createDb`, `Db`, `Driver`
|
||||
|
||||
```ts
|
||||
createDb(driver: Driver): Db
|
||||
```
|
||||
|
||||
A `Driver` (implemented by adapters) exposes `dialect`, `query(sql, params?)`,
|
||||
`exec(sql, params?)`, `transaction(fn)`, and `close()`. `createDb` wraps it in a
|
||||
`Db`:
|
||||
|
||||
- `all<T>(sql, params?, model?)` — all rows, mapped through `model.parse` when a model is given.
|
||||
- `one<T>(sql, params?, model?)` — first row or `null`.
|
||||
- `exec(sql, params?)` — `Promise<ExecResult>` (`{ changes, lastInsertId? }`).
|
||||
- `tx(fn)` — run `fn(db)` in a transaction; rolls back on throw. Nested `tx` reuses the current transaction.
|
||||
- `createTable(model)` — runs the model's `CREATE TABLE IF NOT EXISTS` DDL.
|
||||
- `close()`.
|
||||
|
||||
Every query is parameterized (positional params). `createTableSql(model, dialect, ifNotExists?)`
|
||||
renders `CREATE TABLE` directly; `Dialect` is `"sqlite" | "postgres" | "mysql"`.
|
||||
|
||||
### Client registry — `getDb` / `setDb`
|
||||
|
||||
A process-wide registry the runtime configures at startup from `wrnexus.config.ts`
|
||||
(the `db` setting is the default; `databases.<name>` entries are named).
|
||||
|
||||
- `setDb(db)` / `setDb(name, db)` — set the default or a named connection.
|
||||
- `registerDb(name, db)` — alias of `setDb(name, db)`.
|
||||
- `getDb(name = "default")` — the default or a named `Db` (throws if unconfigured).
|
||||
- `hasDb(name?)`, `databaseNames()`, `closeDatabases()`.
|
||||
|
||||
```ts
|
||||
const users = await getDb().all("SELECT * FROM users");
|
||||
const events = await getDb("analytics").all("SELECT * FROM hits");
|
||||
```
|
||||
|
||||
### Adapters
|
||||
|
||||
- `@wrnexus/db/sqlite` — `sqlite(url = ":memory:")`. `url` may be `file:./dev.db`, a raw path, or `:memory:`. Built on `bun:sqlite`; no external service.
|
||||
- `@wrnexus/db/postgres` — `postgres(url)` (e.g. `postgres://user:pass@host:5432/db`, placeholders `$N`).
|
||||
- `@wrnexus/db/mysql` — `mysql(url)` (e.g. `mysql://user:pass@host:3306/db`, placeholders `?`). Postgres/MySQL both use Bun's native `Bun.SQL` client and its pooled `begin()` for transactions.
|
||||
- `@wrnexus/db/mongo` — `mongo(url, dbName?)`. A document API, not SQL: `db.collection(model)` returns a `MongoRepo<T>` with `find`, `findOne`, `insert`, `insertMany`, `update`, `delete`, `count`. Reads are coerced through `model.parse` (`_id` is mapped to `id`). The `mongodb` driver is imported lazily — install it to use Mongo.
|
||||
|
||||
### Migrations
|
||||
|
||||
Migrations are `.sql` files (in e.g. `app/db/migrations`), each split into
|
||||
`-- +up` and `-- +down` sections. A file with no markers is treated entirely as
|
||||
`up`. Applied names are recorded in a `_wire_migrations` table so each runs once.
|
||||
|
||||
- `parseMigration(name, content)` → `Migration` (`{ name, up, down }`).
|
||||
- `loadMigrations(dir)` — parse all `.sql` files, sorted by filename.
|
||||
- `appliedMigrations(db)` — applied names, oldest first.
|
||||
- `migrate(db, dir)` — apply all pending (each in a transaction); returns applied names.
|
||||
- `rollback(db, dir)` — roll back the most recent; returns its name or `null`.
|
||||
- `status(db, dir)` — `{ name, applied }[]` for every migration file.
|
||||
- `scaffoldMigration(dir, name, dialect, models?)` — write a new numbered migration; with `models` it generates `CREATE`/`DROP` for every table (referenced tables first via topological sort). Returns the file path.
|
||||
|
||||
### Query generator (sqlc-style)
|
||||
|
||||
Turns annotated SQL into typed TS functions; params and result types are
|
||||
inferred from the models, and rows map back through `model.parse` when the
|
||||
selected columns are model columns.
|
||||
|
||||
- `parseQueries(content)` → `QueryDef[]` from `-- name: X :one|:many|:exec` blocks.
|
||||
- `generateQueriesFile(queries, models, dialect)` → the `queries.gen.ts` source. `models` is a `ModelRef[]` (`{ varName, model }`). Rewrites `:name` placeholders to positional (`$N`/`?`) form.
|
||||
|
||||
`QueryKind` is `"one" | "many" | "exec"`.
|
||||
|
||||
### Query helpers
|
||||
|
||||
- `paginate(db, { sql, params?, countSql?, model? }, opts?)` — offset pagination. Pass the base SELECT **without** a LIMIT; it appends the page window and derives `total` via a COUNT subquery. `PageOptions`: `{ page?, perPage?, maxPerPage? }` (defaults page 1, perPage 20, maxPerPage 100). Returns `Paginated<T>` (`items, page, perPage, total, totalPages, hasNext, hasPrev`).
|
||||
- `loadRelated(db, parents, opts)` — load a relation for many parents in ONE query and attach it (no N+1). `RelationOptions`: `{ table, foreignKey, as, localKey?, single?, model? }` — `single: true` attaches one child (belongsTo), otherwise an array (hasMany). Table/foreign-key names are validated as identifiers.
|
||||
|
||||
### Session store
|
||||
|
||||
`@wrnexus/db/session` exports `sqliteSessionStore(path = "sessions.db")`, a
|
||||
persistent, process-shared `SessionBackend` (from `@wrnexus/core`) backed by
|
||||
`bun:sqlite` (WAL mode). Sessions survive restarts and are shared by every
|
||||
worker on the same file.
|
||||
|
||||
## Usage
|
||||
|
||||
Define models, connect, create tables, and query with typed results:
|
||||
|
||||
```ts
|
||||
import { v, table, createDb } from "@wrnexus/db";
|
||||
import { sqlite } from "@wrnexus/db/sqlite";
|
||||
|
||||
const users = table<{ id: number; email: string; name: string | null }>("users", {
|
||||
id: v.id(),
|
||||
email: v.text().unique(),
|
||||
name: v.text().optional(),
|
||||
createdAt: v.timestamp().default("now"),
|
||||
});
|
||||
|
||||
const db = createDb(sqlite("file:./dev.db"));
|
||||
await db.createTable(users);
|
||||
|
||||
await db.exec("INSERT INTO users (email) VALUES (?)", ["a@b.com"]);
|
||||
const list = await db.all("SELECT * FROM users", [], users); // rows typed + coerced
|
||||
const one = await db.one("SELECT * FROM users WHERE id = ?", [1], users);
|
||||
|
||||
await db.tx(async (tx) => {
|
||||
await tx.exec("UPDATE users SET name = ? WHERE id = ?", ["Ada", 1]);
|
||||
});
|
||||
```
|
||||
|
||||
Resolve a config to a live SQL `Db`, and register it:
|
||||
|
||||
```ts
|
||||
import { connectFromConfig } from "@wrnexus/db/connect";
|
||||
import { setDb, getDb } from "@wrnexus/db";
|
||||
|
||||
setDb(connectFromConfig({ driver: "sqlite", url: "file:./dev.db" }, process.cwd()));
|
||||
const rows = await getDb().all("SELECT * FROM users");
|
||||
```
|
||||
|
||||
Run migrations and paginate:
|
||||
|
||||
```ts
|
||||
import { migrate, paginate } from "@wrnexus/db";
|
||||
|
||||
await migrate(db, "app/db/migrations");
|
||||
const pageTwo = await paginate(
|
||||
db,
|
||||
{ sql: "SELECT * FROM users ORDER BY id", model: users },
|
||||
{ page: 2 },
|
||||
);
|
||||
```
|
||||
|
||||
MongoDB (document API):
|
||||
|
||||
```ts
|
||||
import { mongo } from "@wrnexus/db/mongo";
|
||||
|
||||
const mdb = await mongo(process.env.MONGO_URL!, "app");
|
||||
const repo = mdb.collection(users);
|
||||
await repo.insert({ email: "a@b.com" });
|
||||
const active = await repo.find({ active: true });
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
`connectFromConfig` (and the runtime) read a `DbConfig` (`{ driver, url }`)
|
||||
where `driver` is `sqlite | postgres | mysql`. `resolveDbUrl(url, appRoot?)`
|
||||
resolves a relative `file:`/`sqlite:` URL against the app root. MongoDB is not a
|
||||
SQL driver — use `@wrnexus/db/mongo` directly.
|
||||
|
||||
## Requirements / Notes
|
||||
|
||||
- **Bun-only.** Uses `bun:sqlite` (SQLite adapter + session store) and `Bun.SQL`
|
||||
(Postgres/MySQL). Migrations/scaffolding use `node:fs`/`node:path`.
|
||||
- Works with `@wrnexus/core` — `sqliteSessionStore` implements its
|
||||
`SessionBackend`; `getDb`/`setDb` are wired by the WrNexus runtime from
|
||||
`wrnexus.config.ts`.
|
||||
- The `mongodb` npm package is an optional, lazily-imported peer — install it
|
||||
only if you use `@wrnexus/db/mongo`. The core package stays dependency-free.
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@wrnexus/db",
|
||||
"version": "0.2.12",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./connect": "./src/connect.ts",
|
||||
"./session": "./src/session-store.ts",
|
||||
"./sqlite": "./src/adapters/sqlite.ts",
|
||||
"./postgres": "./src/adapters/postgres.ts",
|
||||
"./mysql": "./src/adapters/mysql.ts",
|
||||
"./mongo": "./src/adapters/mongo.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Postgres + MySQL adapters built on Bun's native SQL client (`Bun.SQL`) — no
|
||||
* external driver dependency. Both speak the same `Driver` interface; only the
|
||||
* dialect (and thus the DDL types + placeholder style) differ.
|
||||
*
|
||||
* `Bun.SQL` pools connections, so transactions use its managed `begin(fn)` to
|
||||
* keep BEGIN/…/COMMIT on one reserved connection.
|
||||
*/
|
||||
|
||||
import type { Dialect } from "../sql.ts";
|
||||
import type { Driver, Row, TxHandle } from "../driver.ts";
|
||||
|
||||
interface BunSqlClient {
|
||||
unsafe(query: string, params?: unknown[]): Promise<unknown>;
|
||||
begin<T>(fn: (tx: BunSqlClient) => Promise<T>): Promise<T>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
interface ExecMeta {
|
||||
count?: number;
|
||||
affectedRows?: number;
|
||||
lastInsertRowid?: number | bigint;
|
||||
insertId?: number | bigint;
|
||||
}
|
||||
|
||||
function runnerFor(client: BunSqlClient): TxHandle {
|
||||
return {
|
||||
async query(sql, params = []): Promise<Row[]> {
|
||||
const rows = (await client.unsafe(sql, params)) as Iterable<Row>;
|
||||
return Array.from(rows);
|
||||
},
|
||||
async exec(sql, params = []) {
|
||||
const meta = (await client.unsafe(sql, params)) as ExecMeta;
|
||||
const id = meta.lastInsertRowid ?? meta.insertId;
|
||||
return {
|
||||
changes: Number(meta.affectedRows ?? meta.count ?? 0),
|
||||
lastInsertId: id != null ? Number(id) : undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a Bun.sql-backed driver for the given connection URL + dialect. */
|
||||
export function bunSql(url: string, dialect: Dialect): Driver {
|
||||
const Ctor = (Bun as unknown as { SQL: new (u: string) => BunSqlClient }).SQL;
|
||||
const client = new Ctor(url);
|
||||
const runner = runnerFor(client);
|
||||
return {
|
||||
dialect,
|
||||
query: runner.query,
|
||||
exec: runner.exec,
|
||||
transaction(fn) {
|
||||
return client.begin((tx) => fn(runnerFor(tx)));
|
||||
},
|
||||
close() {
|
||||
return client.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** PostgreSQL adapter (`postgres://user:pass@host:5432/db`). Placeholders: `$N`. */
|
||||
export function postgres(url: string): Driver {
|
||||
return bunSql(url, "postgres");
|
||||
}
|
||||
|
||||
/** MySQL adapter (`mysql://user:pass@host:3306/db`). Placeholders: `?`. */
|
||||
export function mysql(url: string): Driver {
|
||||
return bunSql(url, "mysql");
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* MongoDB adapter. Mongo is a document store, not SQL, so it does NOT use the
|
||||
* SQL `Driver`/migrations/query-generator. Instead it exposes a small, typed
|
||||
* collection API keyed by your models — reads are still coerced through
|
||||
* `model.parse`, so results match your schema.
|
||||
*
|
||||
* The `mongodb` driver is imported lazily (install it to use Mongo); the core
|
||||
* `@wrnexus/db` stays dependency-free.
|
||||
*
|
||||
* const db = await mongo(process.env.MONGO_URL!, "app");
|
||||
* const repo = db.collection(users);
|
||||
* await repo.insert({ email, name });
|
||||
* const active = await repo.find({ active: true });
|
||||
*/
|
||||
|
||||
import type { Model } from "../schema.ts";
|
||||
|
||||
// Minimal shape of the `mongodb` driver we rely on (typed locally so this file
|
||||
// compiles without `mongodb` installed).
|
||||
interface MongoCollection {
|
||||
find(filter: object, options?: object): { toArray(): Promise<Record<string, unknown>[]> };
|
||||
findOne(filter: object): Promise<Record<string, unknown> | null>;
|
||||
insertOne(doc: object): Promise<{ insertedId: unknown }>;
|
||||
insertMany(docs: object[]): Promise<{ insertedCount: number }>;
|
||||
updateMany(
|
||||
filter: object,
|
||||
update: object,
|
||||
): Promise<{ matchedCount: number; modifiedCount: number }>;
|
||||
deleteMany(filter: object): Promise<{ deletedCount: number }>;
|
||||
countDocuments(filter: object): Promise<number>;
|
||||
}
|
||||
interface MongoDatabase {
|
||||
collection(name: string): MongoCollection;
|
||||
}
|
||||
interface MongoClientLike {
|
||||
connect(): Promise<unknown>;
|
||||
db(name?: string): MongoDatabase;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface FindOptions {
|
||||
sort?: Record<string, 1 | -1>;
|
||||
limit?: number;
|
||||
skip?: number;
|
||||
}
|
||||
|
||||
export interface MongoRepo<T> {
|
||||
find(filter?: Record<string, unknown>, options?: FindOptions): Promise<T[]>;
|
||||
findOne(filter: Record<string, unknown>): Promise<T | null>;
|
||||
insert(doc: Partial<T>): Promise<{ id: unknown }>;
|
||||
insertMany(docs: Partial<T>[]): Promise<{ count: number }>;
|
||||
update(
|
||||
filter: Record<string, unknown>,
|
||||
patch: Partial<T>,
|
||||
): Promise<{ matched: number; modified: number }>;
|
||||
delete(filter: Record<string, unknown>): Promise<{ deleted: number }>;
|
||||
count(filter?: Record<string, unknown>): Promise<number>;
|
||||
}
|
||||
|
||||
export interface MongoDb {
|
||||
collection<T>(model: Model<T>): MongoRepo<T>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Map Mongo's `_id` to `id` so documents line up with model columns. */
|
||||
function normalize(doc: Record<string, unknown>): Record<string, unknown> {
|
||||
if (doc && doc._id != null && doc.id == null) {
|
||||
const { _id, ...rest } = doc;
|
||||
return { id: String(_id), ...rest };
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
/** Connect to MongoDB and return a model-aware collection API. */
|
||||
export async function mongo(url: string, dbName?: string): Promise<MongoDb> {
|
||||
// Non-literal specifier so this compiles without `mongodb` installed.
|
||||
const specifier: string = "mongodb";
|
||||
const mod = (await import(specifier)) as { MongoClient: new (u: string) => MongoClientLike };
|
||||
const client = new mod.MongoClient(url);
|
||||
await client.connect();
|
||||
const database = client.db(dbName);
|
||||
|
||||
return {
|
||||
collection<T>(model: Model<T>): MongoRepo<T> {
|
||||
const col = database.collection(model.name);
|
||||
return {
|
||||
async find(filter = {}, options = {}) {
|
||||
const docs = await col.find(filter, options).toArray();
|
||||
return docs.map((d) => model.parse(normalize(d)));
|
||||
},
|
||||
async findOne(filter) {
|
||||
const doc = await col.findOne(filter);
|
||||
return doc ? model.parse(normalize(doc)) : null;
|
||||
},
|
||||
async insert(doc) {
|
||||
const r = await col.insertOne(doc as object);
|
||||
return { id: r.insertedId };
|
||||
},
|
||||
async insertMany(docs) {
|
||||
const r = await col.insertMany(docs as object[]);
|
||||
return { count: r.insertedCount };
|
||||
},
|
||||
async update(filter, patch) {
|
||||
const r = await col.updateMany(filter, { $set: patch });
|
||||
return { matched: r.matchedCount, modified: r.modifiedCount };
|
||||
},
|
||||
async delete(filter) {
|
||||
const r = await col.deleteMany(filter);
|
||||
return { deleted: r.deletedCount };
|
||||
},
|
||||
count(filter = {}) {
|
||||
return col.countDocuments(filter);
|
||||
},
|
||||
};
|
||||
},
|
||||
close() {
|
||||
return client.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { mysql } from "./bunsql.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export { postgres } from "./bunsql.ts";
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* SQLite adapter, built on Bun's zero-dependency `bun:sqlite`. Use a file URL
|
||||
* (`file:./dev.db`) or the default in-memory database (great for tests). This is
|
||||
* the reference adapter — it needs no external service to run.
|
||||
*/
|
||||
|
||||
import { Database } from "bun:sqlite";
|
||||
import type { Driver, ExecResult, Row, TxHandle } from "../driver.ts";
|
||||
|
||||
/** SQLite can only bind numbers/strings/bigints/null/blobs — coerce JS values. */
|
||||
function bind(params: unknown[]): unknown[] {
|
||||
return params.map((p) => {
|
||||
if (p === true) return 1;
|
||||
if (p === false) return 0;
|
||||
if (p === undefined) return null;
|
||||
if (p instanceof Date) return p.toISOString();
|
||||
return p;
|
||||
});
|
||||
}
|
||||
|
||||
/** Create a SQLite driver. `url` may be `file:./x.db`, a path, or `:memory:`. */
|
||||
export function sqlite(url = ":memory:"): Driver {
|
||||
const path = url.replace(/^(file:|sqlite:)/, "") || ":memory:";
|
||||
const database = new Database(path);
|
||||
database.exec("PRAGMA foreign_keys = ON;");
|
||||
|
||||
const runner: TxHandle = {
|
||||
async query(sql, params = []): Promise<Row[]> {
|
||||
return database.query(sql).all(...(bind(params) as never[])) as Row[];
|
||||
},
|
||||
async exec(sql, params = []): Promise<ExecResult> {
|
||||
if (params.length === 0) {
|
||||
database.exec(sql); // DDL / multi-statement
|
||||
return { changes: 0 };
|
||||
}
|
||||
const result = database.query(sql).run(...(bind(params) as never[]));
|
||||
return { changes: result.changes, lastInsertId: Number(result.lastInsertRowid) };
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
dialect: "sqlite",
|
||||
query: runner.query,
|
||||
exec: runner.exec,
|
||||
async transaction(fn) {
|
||||
database.exec("BEGIN");
|
||||
try {
|
||||
const result = await fn(runner);
|
||||
database.exec("COMMIT");
|
||||
return result;
|
||||
} catch (err) {
|
||||
database.exec("ROLLBACK");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
close() {
|
||||
database.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* A process-wide database registry. The framework configures it at server
|
||||
* startup from `wrnexus.config.ts`: the `db` setting becomes the **default**
|
||||
* connection, and each entry under `databases` becomes a **named** connection.
|
||||
* Pages and API routes then call `getDb()` for the default, or `getDb("<name>")`
|
||||
* for a named one, to run queries (including the generated typed functions).
|
||||
*
|
||||
* const users = await getDb().all("SELECT * FROM users"); // default db
|
||||
* const events = await getDb("analytics").all("SELECT * FROM hits"); // named db
|
||||
*/
|
||||
|
||||
import type { Db } from "./driver.ts";
|
||||
|
||||
const DEFAULT = "default";
|
||||
const registry = new Map<string, Db>();
|
||||
|
||||
/** Set the default database (called by the runtime at startup). */
|
||||
export function setDb(db: Db): Db;
|
||||
/** Set a named database (from `databases.<name>` in config). */
|
||||
export function setDb(name: string, db: Db): Db;
|
||||
export function setDb(a: string | Db, b?: Db): Db {
|
||||
const name = typeof a === "string" ? a : DEFAULT;
|
||||
const db = typeof a === "string" ? b! : a;
|
||||
registry.set(name, db);
|
||||
return db;
|
||||
}
|
||||
|
||||
/** Register a named database. Alias of `setDb(name, db)` for readability. */
|
||||
export function registerDb(name: string, db: Db): Db {
|
||||
return setDb(name, db);
|
||||
}
|
||||
|
||||
/** The default database, or a named one. Throws if it isn't configured. */
|
||||
export function getDb(name = DEFAULT): Db {
|
||||
const db = registry.get(name);
|
||||
if (!db) {
|
||||
throw new Error(
|
||||
name === DEFAULT
|
||||
? "No database configured. Add `db: { driver, url }` to wrnexus.config.ts."
|
||||
: `No database named '${name}'. Add it under \`databases\` in wrnexus.config.ts ` +
|
||||
`(e.g. databases: { ${name}: { driver, url } }).`,
|
||||
);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
/** Whether the default (or a named) database has been configured. */
|
||||
export function hasDb(name = DEFAULT): boolean {
|
||||
return registry.has(name);
|
||||
}
|
||||
|
||||
/** Names of all configured databases (the default appears as "default"). */
|
||||
export function databaseNames(): string[] {
|
||||
return [...registry.keys()];
|
||||
}
|
||||
|
||||
/** Close every configured database and clear the registry. */
|
||||
export async function closeDatabases(): Promise<void> {
|
||||
for (const db of registry.values()) await db.close();
|
||||
registry.clear();
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Resolve a `db` config (from wrnexus.config.ts) to a live SQL `Db`. Kept in a
|
||||
* subpath (`@wrnexus/db/connect`) so importing the core `@wrnexus/db` doesn't pull
|
||||
* in every adapter. MongoDB is not here — it uses a document API (`@wrnexus/db/mongo`).
|
||||
*/
|
||||
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { createDb, type Db } from "./index.ts";
|
||||
import { sqlite } from "./adapters/sqlite.ts";
|
||||
import { postgres } from "./adapters/postgres.ts";
|
||||
import { mysql } from "./adapters/mysql.ts";
|
||||
|
||||
export interface DbConfig {
|
||||
driver: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** Resolve a `file:`/`sqlite:` URL's relative path against the app root. */
|
||||
export function resolveDbUrl(url: string, appRoot?: string): string {
|
||||
const m = /^(?:file:|sqlite:\/\/|sqlite:)(.*)$/.exec(url);
|
||||
if (!m || !appRoot) return url;
|
||||
const path = m[1]!.replace(/^\.\//, "");
|
||||
return `file:${isAbsolute(path) ? path : join(appRoot, path)}`;
|
||||
}
|
||||
|
||||
/** Build the configured SQL database (resolving a file URL against `appRoot`). */
|
||||
export function connectFromConfig(config: DbConfig, appRoot?: string): Db {
|
||||
const url = resolveDbUrl(config.url, appRoot);
|
||||
switch (config.driver) {
|
||||
case "sqlite":
|
||||
return createDb(sqlite(url));
|
||||
case "postgres":
|
||||
return createDb(postgres(url));
|
||||
case "mysql":
|
||||
return createDb(mysql(url));
|
||||
default:
|
||||
throw new Error(
|
||||
`Database driver '${config.driver}' is not a SQL driver ` +
|
||||
`(use sqlite | postgres | mysql; MongoDB has a document API in @wrnexus/db/mongo).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* The database driver interface and the `Db` client built on top of it.
|
||||
*
|
||||
* Adapters (SQLite now; Postgres/MySQL/Mongo later) implement `Driver`. The
|
||||
* client adds ergonomics: `all`/`one` optionally map rows through a model's
|
||||
* `.parse` (so results match your schema), `tx` wraps work in a transaction, and
|
||||
* `createTable` runs a model's DDL. Every query is parameterized.
|
||||
*/
|
||||
|
||||
import { createTableSql, type Dialect } from "./sql.ts";
|
||||
import type { Model } from "./schema.ts";
|
||||
|
||||
export type Row = Record<string, unknown>;
|
||||
|
||||
export interface ExecResult {
|
||||
changes: number;
|
||||
lastInsertId?: number;
|
||||
}
|
||||
|
||||
/** The minimal query surface — the driver itself and each transaction expose it. */
|
||||
export interface TxHandle {
|
||||
/** Run a query returning rows (SELECT). Params are positional. */
|
||||
query(sql: string, params?: unknown[]): Promise<Row[]>;
|
||||
/** Run a statement (INSERT/UPDATE/DELETE/DDL). */
|
||||
exec(sql: string, params?: unknown[]): Promise<ExecResult>;
|
||||
}
|
||||
|
||||
export interface Driver extends TxHandle {
|
||||
dialect: Dialect;
|
||||
/**
|
||||
* Run `fn` inside a transaction on a single reserved connection, committing
|
||||
* on success and rolling back on throw. (Pooled drivers must reserve one
|
||||
* connection so BEGIN/…/COMMIT don't span connections.)
|
||||
*/
|
||||
transaction<T>(fn: (tx: TxHandle) => Promise<T>): Promise<T>;
|
||||
close(): void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface Db {
|
||||
driver: Driver;
|
||||
/** All matching rows, mapped through `model.parse` when a model is given. */
|
||||
all<T = Row>(sql: string, params?: unknown[], model?: Model<T>): Promise<T[]>;
|
||||
/** The first matching row (or null), mapped through `model.parse`. */
|
||||
one<T = Row>(sql: string, params?: unknown[], model?: Model<T>): Promise<T | null>;
|
||||
exec(sql: string, params?: unknown[]): Promise<ExecResult>;
|
||||
/** Run `fn` inside a transaction; rolls back if it throws. */
|
||||
tx<T>(fn: (db: Db) => Promise<T>): Promise<T>;
|
||||
/** Create a table from its model (`CREATE TABLE IF NOT EXISTS`). */
|
||||
createTable(model: Model): Promise<void>;
|
||||
close(): void | Promise<void>;
|
||||
}
|
||||
|
||||
/** Build a `Db` over a query runner (the driver at top level, or a transaction). */
|
||||
function dbOver(runner: TxHandle, driver: Driver): Db {
|
||||
const db: Db = {
|
||||
driver,
|
||||
async all(sql, params = [], model) {
|
||||
const rows = await runner.query(sql, params);
|
||||
return (model ? rows.map((r) => model.parse(r)) : rows) as never;
|
||||
},
|
||||
async one(sql, params = [], model) {
|
||||
const rows = await db.all(sql, params, model as never);
|
||||
return (rows[0] ?? null) as never;
|
||||
},
|
||||
exec(sql, params = []) {
|
||||
return runner.exec(sql, params);
|
||||
},
|
||||
async tx(fn) {
|
||||
// Top level opens a real transaction; inside one, reuse the current tx.
|
||||
if (runner === driver) return driver.transaction((tx) => fn(dbOver(tx, driver)));
|
||||
return fn(db);
|
||||
},
|
||||
async createTable(model) {
|
||||
await runner.exec(createTableSql(model, driver.dialect));
|
||||
},
|
||||
close() {
|
||||
return driver.close();
|
||||
},
|
||||
};
|
||||
return db;
|
||||
}
|
||||
|
||||
/** Build a `Db` client from a driver. */
|
||||
export function createDb(driver: Driver): Db {
|
||||
return dbOver(driver, driver);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* sqlc-style query generator. Annotated SQL in `app/db/queries/*.sql` becomes
|
||||
* typed TS functions whose params + results are inferred from the TS models and
|
||||
* whose rows are mapped back through `model.parse`.
|
||||
*
|
||||
* -- name: GetUserByEmail :one
|
||||
* SELECT * FROM users WHERE email = :email;
|
||||
*
|
||||
* → GetUserByEmail(db, { email: string }): Promise<{…} | null>
|
||||
*
|
||||
* Type inference is best-effort (comparisons + INSERT column lists + SELECT list
|
||||
* vs the model); anything it can't resolve becomes `unknown`.
|
||||
*/
|
||||
|
||||
import type { Column, Model } from "./schema.ts";
|
||||
import type { Dialect } from "./sql.ts";
|
||||
|
||||
export type QueryKind = "one" | "many" | "exec";
|
||||
|
||||
export interface QueryDef {
|
||||
name: string;
|
||||
kind: QueryKind;
|
||||
sql: string;
|
||||
}
|
||||
|
||||
/** A model plus the variable name it is exported under (for imports). */
|
||||
export interface ModelRef {
|
||||
varName: string;
|
||||
model: Model;
|
||||
}
|
||||
|
||||
/** Parse annotated queries from one `.sql` file's contents. */
|
||||
export function parseQueries(content: string): QueryDef[] {
|
||||
const out: QueryDef[] = [];
|
||||
const re = /--\s*name:\s*(\w+)\s*:(one|many|exec)\b[^\n]*\n([\s\S]*?)(?=--\s*name:|$)/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(content))) {
|
||||
out.push({
|
||||
name: m[1]!,
|
||||
kind: m[2]!.toLowerCase() as QueryKind,
|
||||
sql: m[3]!.trim().replace(/;\s*$/, ""),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Rewrite `:name` placeholders to positional params, keeping their order. */
|
||||
function toPositional(sql: string, dialect: Dialect): { sql: string; order: string[] } {
|
||||
const order: string[] = [];
|
||||
const rewritten = sql.replace(/:([A-Za-z_]\w*)/g, (_m, name: string) => {
|
||||
order.push(name);
|
||||
return dialect === "postgres" ? `$${order.length}` : "?";
|
||||
});
|
||||
return { sql: rewritten, order };
|
||||
}
|
||||
|
||||
function uniqueInOrder(names: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const n of names) {
|
||||
if (!seen.has(n)) {
|
||||
seen.add(n);
|
||||
out.push(n);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function tableOf(sql: string): string | undefined {
|
||||
const from = /\bFROM\s+["`]?(\w+)["`]?/i.exec(sql);
|
||||
if (from) return from[1];
|
||||
const into = /\bINTO\s+["`]?(\w+)["`]?/i.exec(sql);
|
||||
if (into) return into[1];
|
||||
const upd = /\bUPDATE\s+["`]?(\w+)["`]?/i.exec(sql);
|
||||
return upd?.[1];
|
||||
}
|
||||
|
||||
function tsOutput(col: Column): string {
|
||||
switch (col.def.type) {
|
||||
case "id":
|
||||
case "int":
|
||||
case "real":
|
||||
return "number";
|
||||
case "bool":
|
||||
return "boolean";
|
||||
case "timestamp":
|
||||
return "Date";
|
||||
case "json":
|
||||
return "unknown";
|
||||
default:
|
||||
return "string";
|
||||
}
|
||||
}
|
||||
|
||||
function tsInput(col: Column): string {
|
||||
return col.def.type === "timestamp" ? "string | Date" : tsOutput(col);
|
||||
}
|
||||
|
||||
interface SelectCol {
|
||||
name: string;
|
||||
/** A type forced by an aggregate (e.g. COUNT → number), overriding the model. */
|
||||
forced?: string;
|
||||
}
|
||||
|
||||
/** Split a SELECT list on top-level commas (respecting `fn(a, b)`). */
|
||||
function splitTopLevel(list: string): string[] {
|
||||
const out: string[] = [];
|
||||
let depth = 0;
|
||||
let cur = "";
|
||||
for (const ch of list) {
|
||||
if (ch === "(") depth++;
|
||||
else if (ch === ")") depth--;
|
||||
if (ch === "," && depth === 0) {
|
||||
out.push(cur);
|
||||
cur = "";
|
||||
} else cur += ch;
|
||||
}
|
||||
if (cur.trim()) out.push(cur);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Parse the SELECT list into columns, or null for `SELECT *`. */
|
||||
function selectColumns(sql: string): SelectCol[] | null {
|
||||
const m = /SELECT\s+([\s\S]*?)\s+FROM\b/i.exec(sql);
|
||||
if (!m) return null;
|
||||
const list = m[1]!.trim();
|
||||
if (list === "*") return null;
|
||||
return splitTopLevel(list).map((seg): SelectCol => {
|
||||
const s = seg.trim();
|
||||
const alias = /\s+AS\s+["`]?(\w+)["`]?$/i.exec(s);
|
||||
const name = alias ? alias[1]! : s.split(".").pop()!.replace(/["`]/g, "");
|
||||
const forced = /\b(count|sum|avg|min|max|total)\s*\(/i.test(s) ? "number" : undefined;
|
||||
return { name, forced };
|
||||
});
|
||||
}
|
||||
|
||||
/** True when every selected column is a plain model column (so `model.parse` fits). */
|
||||
function columnsMatchModel(cols: SelectCol[] | null, model: Model | undefined): boolean {
|
||||
if (!model) return false;
|
||||
if (cols === null) return true; // SELECT * → full model row
|
||||
return cols.every((c) => !c.forced && !!model.columns[c.name]);
|
||||
}
|
||||
|
||||
function resultType(cols: SelectCol[] | null, model: Model | undefined): string {
|
||||
if (cols === null) {
|
||||
if (!model) return "Record<string, unknown>";
|
||||
return `{ ${Object.entries(model.columns)
|
||||
.map(([k, col]) => `${k}: ${tsOutput(col)}`)
|
||||
.join("; ")} }`;
|
||||
}
|
||||
return `{ ${cols
|
||||
.map(
|
||||
(c) =>
|
||||
`${c.name}: ${c.forced ?? (model && model.columns[c.name] ? tsOutput(model.columns[c.name]!) : "unknown")}`,
|
||||
)
|
||||
.join("; ")} }`;
|
||||
}
|
||||
|
||||
/** Find the column a `:param` is compared to / inserted into, if any. */
|
||||
function paramColumn(param: string, sql: string): string | undefined {
|
||||
const op = "(?:=|!=|<>|<=|>=|<|>|LIKE)";
|
||||
const cmp1 = new RegExp(`(\\w+)\\s*${op}\\s*:${param}\\b`, "i").exec(sql);
|
||||
if (cmp1) return cmp1[1];
|
||||
const cmp2 = new RegExp(`:${param}\\b\\s*${op}\\s*(\\w+)`, "i").exec(sql);
|
||||
if (cmp2) return cmp2[1];
|
||||
const ins = /INSERT\s+INTO\s+\w+\s*\(([^)]*)\)\s*VALUES\s*\(([^)]*)\)/i.exec(sql);
|
||||
if (ins) {
|
||||
const cols = ins[1]!.split(",").map((s) => s.trim().replace(/["`]/g, ""));
|
||||
const vals = ins[2]!.split(",").map((s) => s.trim());
|
||||
const idx = vals.indexOf(`:${param}`);
|
||||
if (idx >= 0 && cols[idx]) return cols[idx];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function inferParamType(param: string, sql: string, model: Model | undefined): string {
|
||||
if (!model) return "unknown";
|
||||
const col = paramColumn(param, sql);
|
||||
const column = col ? model.columns[col] : undefined;
|
||||
return column ? tsInput(column) : "unknown";
|
||||
}
|
||||
|
||||
/** Generate the full `queries.gen.ts` source. */
|
||||
export function generateQueriesFile(
|
||||
queries: QueryDef[],
|
||||
models: ModelRef[],
|
||||
dialect: Dialect,
|
||||
): string {
|
||||
const byTable = new Map(models.map((m) => [m.model.name, m]));
|
||||
const usedModels = new Set<string>();
|
||||
const blocks: string[] = [];
|
||||
|
||||
for (const q of queries) {
|
||||
const { sql, order } = toPositional(q.sql, dialect);
|
||||
const sqlLit = JSON.stringify(sql);
|
||||
const positional = `[${order.map((n) => `args.${n}`).join(", ")}]`;
|
||||
const params = uniqueInOrder(order);
|
||||
const table = tableOf(q.sql);
|
||||
const ref = table ? byTable.get(table) : undefined;
|
||||
|
||||
const argFields = params.map((p) => `${p}: ${inferParamType(p, q.sql, ref?.model)}`);
|
||||
const sig = argFields.length ? `db: Db, args: { ${argFields.join("; ")} }` : "db: Db";
|
||||
|
||||
if (q.kind === "exec") {
|
||||
blocks.push(
|
||||
`export async function ${q.name}(${sig}): Promise<ExecResult> {\n return db.exec(${sqlLit}, ${positional});\n}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const cols = selectColumns(q.sql);
|
||||
const row = resultType(cols, ref?.model);
|
||||
const ret = q.kind === "one" ? `${row} | null` : `${row}[]`;
|
||||
const method = q.kind === "one" ? "one" : "all";
|
||||
// Only map through the model when the selected columns are model columns.
|
||||
const passModel = !!ref && columnsMatchModel(cols, ref.model);
|
||||
let modelArg = "";
|
||||
if (passModel && ref) {
|
||||
usedModels.add(ref.varName);
|
||||
modelArg = `, ${ref.varName}`;
|
||||
}
|
||||
blocks.push(
|
||||
`export async function ${q.name}(${sig}): Promise<${ret}> {\n return (await db.${method}(${sqlLit}, ${positional}${modelArg})) as ${ret};\n}`,
|
||||
);
|
||||
}
|
||||
|
||||
const imports = [`import type { Db, ExecResult } from "@wrnexus/db";`];
|
||||
if (usedModels.size > 0) {
|
||||
imports.push(`import { ${[...usedModels].sort().join(", ")} } from "./schema.ts";`);
|
||||
}
|
||||
return `// AUTO-GENERATED by \`wrnexus db generate\` — do not edit.\n${imports.join("\n")}\n\n${blocks.join("\n\n")}\n`;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @wrnexus/db — the data layer core: TS models (source of truth for DDL,
|
||||
* validation, and result typing), the `Driver` interface, and the `Db` client.
|
||||
*
|
||||
* Adapters are imported from subpaths, e.g. `@wrnexus/db/sqlite`. Migrations and
|
||||
* the sqlc-style query generator build on this core in later phases.
|
||||
*/
|
||||
|
||||
export { v, table, Column } from "./schema.ts";
|
||||
export type { Model, Columns, ColumnDef, BaseType } from "./schema.ts";
|
||||
export { createDb } from "./driver.ts";
|
||||
export type { Db, Driver, Row, ExecResult, TxHandle } from "./driver.ts";
|
||||
export { setDb, getDb, hasDb, registerDb, databaseNames, closeDatabases } from "./client.ts";
|
||||
export { createTableSql } from "./sql.ts";
|
||||
export type { Dialect } from "./sql.ts";
|
||||
export {
|
||||
parseMigration,
|
||||
loadMigrations,
|
||||
appliedMigrations,
|
||||
migrate,
|
||||
rollback,
|
||||
status,
|
||||
scaffoldMigration,
|
||||
} from "./migrate.ts";
|
||||
export type { Migration } from "./migrate.ts";
|
||||
export { parseQueries, generateQueriesFile } from "./generate.ts";
|
||||
export type { QueryDef, QueryKind, ModelRef } from "./generate.ts";
|
||||
export { paginate, loadRelated } from "./query.ts";
|
||||
export type { Paginated, PageOptions, RelationOptions } from "./query.ts";
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Migration runner. Migrations are `.sql` files in `app/db/migrations`, each
|
||||
* split into `-- +up` and `-- +down` sections. Applied migrations are recorded
|
||||
* in a `_wire_migrations` table so they run exactly once, newest-last.
|
||||
*
|
||||
* `scaffoldMigration(..., models)` writes an initial migration straight from the
|
||||
* TS models — the source of truth — so you don't hand-write the first schema.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { Db } from "./driver.ts";
|
||||
import { createTableSql, type Dialect } from "./sql.ts";
|
||||
import type { Model } from "./schema.ts";
|
||||
|
||||
export interface Migration {
|
||||
name: string;
|
||||
up: string;
|
||||
down: string;
|
||||
}
|
||||
|
||||
const MIGRATIONS_TABLE = "_wire_migrations";
|
||||
|
||||
/** Split a migration file into its `up` and `down` SQL sections. */
|
||||
export function parseMigration(name: string, content: string): Migration {
|
||||
return { name, up: section(content, "up"), down: section(content, "down") };
|
||||
}
|
||||
|
||||
function section(content: string, which: "up" | "down"): string {
|
||||
const marker = new RegExp(`^--\\s*\\+${which}\\b.*$`, "mi");
|
||||
const match = marker.exec(content);
|
||||
if (!match) {
|
||||
// A file with no markers at all is treated entirely as `up`.
|
||||
return which === "up" && !/^--\s*\+(up|down)\b/im.test(content) ? content.trim() : "";
|
||||
}
|
||||
const from = content.indexOf("\n", match.index);
|
||||
const rest = content.slice(from === -1 ? content.length : from + 1);
|
||||
const next = /^--\s*\+(up|down)\b/im.exec(rest);
|
||||
return (next ? rest.slice(0, next.index) : rest).trim();
|
||||
}
|
||||
|
||||
/** Load and parse all migration files in a directory, sorted by filename. */
|
||||
export function loadMigrations(dir: string): Migration[] {
|
||||
if (!existsSync(dir)) return [];
|
||||
return readdirSync(dir)
|
||||
.filter((f) => f.endsWith(".sql"))
|
||||
.sort()
|
||||
.map((f) => parseMigration(f.replace(/\.sql$/, ""), readFileSync(join(dir, f), "utf8")));
|
||||
}
|
||||
|
||||
async function ensureTable(db: Db): Promise<void> {
|
||||
await db.exec(
|
||||
`CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Names of already-applied migrations, oldest first. */
|
||||
export async function appliedMigrations(db: Db): Promise<string[]> {
|
||||
await ensureTable(db);
|
||||
const rows = await db.all<{ name: string }>(
|
||||
`SELECT name FROM ${MIGRATIONS_TABLE} ORDER BY applied_at, name`,
|
||||
);
|
||||
return rows.map((r) => r.name);
|
||||
}
|
||||
|
||||
/** Apply all pending migrations (each in a transaction). Returns applied names. */
|
||||
export async function migrate(db: Db, dir: string): Promise<string[]> {
|
||||
const applied = new Set(await appliedMigrations(db));
|
||||
const pending = loadMigrations(dir).filter((m) => !applied.has(m.name));
|
||||
const done: string[] = [];
|
||||
for (const m of pending) {
|
||||
await db.tx(async (tx) => {
|
||||
if (m.up) await tx.exec(m.up);
|
||||
await tx.exec(`INSERT INTO ${MIGRATIONS_TABLE} (name) VALUES (?)`, [m.name]);
|
||||
});
|
||||
done.push(m.name);
|
||||
}
|
||||
return done;
|
||||
}
|
||||
|
||||
/** Roll back the most recently applied migration. Returns its name, or null. */
|
||||
export async function rollback(db: Db, dir: string): Promise<string | null> {
|
||||
const applied = await appliedMigrations(db);
|
||||
const last = applied[applied.length - 1];
|
||||
if (!last) return null;
|
||||
const migration = loadMigrations(dir).find((m) => m.name === last);
|
||||
await db.tx(async (tx) => {
|
||||
if (migration?.down) await tx.exec(migration.down);
|
||||
await tx.exec(`DELETE FROM ${MIGRATIONS_TABLE} WHERE name = ?`, [last]);
|
||||
});
|
||||
return last;
|
||||
}
|
||||
|
||||
/** Full status: every migration file with whether it has been applied. */
|
||||
export async function status(db: Db, dir: string): Promise<{ name: string; applied: boolean }[]> {
|
||||
const applied = new Set(await appliedMigrations(db));
|
||||
return loadMigrations(dir).map((m) => ({ name: m.name, applied: applied.has(m.name) }));
|
||||
}
|
||||
|
||||
/** Order models so a referenced table is created before the table referencing it. */
|
||||
function topoSort(models: Model[]): Model[] {
|
||||
const byName = new Map(models.map((m) => [m.name, m]));
|
||||
const sorted: Model[] = [];
|
||||
const visited = new Set<string>();
|
||||
const visit = (m: Model): void => {
|
||||
if (visited.has(m.name)) return;
|
||||
visited.add(m.name);
|
||||
for (const column of Object.values(m.columns)) {
|
||||
const ref = column.def.references;
|
||||
if (ref && ref.table !== m.name && byName.has(ref.table)) visit(byName.get(ref.table)!);
|
||||
}
|
||||
sorted.push(m);
|
||||
};
|
||||
for (const m of models) visit(m);
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function nextNumber(dir: string): number {
|
||||
if (!existsSync(dir)) return 1;
|
||||
let max = 0;
|
||||
for (const f of readdirSync(dir)) {
|
||||
const m = /^(\d+)/.exec(f);
|
||||
if (m) max = Math.max(max, Number(m[1]));
|
||||
}
|
||||
return max + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a new migration file. With `models`, the `up`/`down` are generated from
|
||||
* the TS models (create/drop every table); otherwise empty stubs are written.
|
||||
* Returns the created file path.
|
||||
*/
|
||||
export function scaffoldMigration(
|
||||
dir: string,
|
||||
name: string,
|
||||
dialect: Dialect,
|
||||
models: Model[] = [],
|
||||
): string {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const num = String(nextNumber(dir)).padStart(4, "0");
|
||||
const slug =
|
||||
name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "") || "migration";
|
||||
const file = join(dir, `${num}_${slug}.sql`);
|
||||
|
||||
let up = "";
|
||||
let down = "";
|
||||
if (models.length > 0) {
|
||||
const ordered = topoSort(models); // referenced tables first
|
||||
up = ordered.map((m) => createTableSql(m, dialect)).join("\n\n");
|
||||
const quote = dialect === "mysql" ? (s: string) => `\`${s}\`` : (s: string) => `"${s}"`;
|
||||
down = ordered
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((m) => `DROP TABLE IF EXISTS ${quote(m.name)};`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
writeFileSync(file, `-- +up\n${up}\n\n-- +down\n${down}\n`, "utf8");
|
||||
return file;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Query ergonomics built on the `Db` client: offset pagination and a batched
|
||||
* relation loader (avoids N+1). Both are dialect-aware — placeholders follow the
|
||||
* driver's style (`$N` for Postgres, `?` for SQLite/MySQL).
|
||||
*/
|
||||
|
||||
import type { Db, Row } from "./driver.ts";
|
||||
import type { Model } from "./schema.ts";
|
||||
import type { Dialect } from "./sql.ts";
|
||||
|
||||
const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
function placeholder(dialect: Dialect, index: number): string {
|
||||
return dialect === "postgres" ? `$${index}` : "?";
|
||||
}
|
||||
|
||||
function assertIdent(name: string, what: string): void {
|
||||
if (!IDENT_RE.test(name)) throw new Error(`Unsafe ${what}: ${JSON.stringify(name)}`);
|
||||
}
|
||||
|
||||
// --- Pagination ------------------------------------------------------------
|
||||
|
||||
export interface PageOptions {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
/** Upper bound on perPage. Default 100. */
|
||||
maxPerPage?: number;
|
||||
}
|
||||
|
||||
export interface Paginated<T> {
|
||||
items: T[];
|
||||
page: number;
|
||||
perPage: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNext: boolean;
|
||||
hasPrev: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginate a SELECT. Pass the base query WITHOUT a LIMIT; the helper appends the
|
||||
* page window and derives the total with a COUNT over the same query.
|
||||
*
|
||||
* await paginate(db, { sql: "SELECT * FROM users ORDER BY name", model: users }, { page: 2 })
|
||||
*/
|
||||
export async function paginate<T = Row>(
|
||||
db: Db,
|
||||
query: { sql: string; params?: unknown[]; countSql?: string; model?: Model<T> },
|
||||
opts: PageOptions = {},
|
||||
): Promise<Paginated<T>> {
|
||||
const dialect = db.driver.dialect;
|
||||
const params = query.params ?? [];
|
||||
const maxPerPage = opts.maxPerPage ?? 100;
|
||||
const page = Math.max(1, Math.floor(opts.page ?? 1));
|
||||
const perPage = Math.min(maxPerPage, Math.max(1, Math.floor(opts.perPage ?? 20)));
|
||||
const offset = (page - 1) * perPage;
|
||||
|
||||
const countSql = query.countSql ?? `SELECT COUNT(*) AS n FROM (${query.sql}) AS __wire_sub`;
|
||||
const countRow = await db.one<{ n: number | string }>(countSql, params);
|
||||
const total = Number(countRow?.n ?? 0);
|
||||
|
||||
const limitPh = placeholder(dialect, params.length + 1);
|
||||
const offsetPh = placeholder(dialect, params.length + 2);
|
||||
const items = await db.all<T>(
|
||||
`${query.sql} LIMIT ${limitPh} OFFSET ${offsetPh}`,
|
||||
[...params, perPage, offset],
|
||||
query.model,
|
||||
);
|
||||
|
||||
const totalPages = perPage > 0 ? Math.ceil(total / perPage) : 0;
|
||||
return {
|
||||
items,
|
||||
page,
|
||||
perPage,
|
||||
total,
|
||||
totalPages,
|
||||
hasNext: page < totalPages,
|
||||
hasPrev: page > 1,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Relations (batched, no N+1) -------------------------------------------
|
||||
|
||||
export interface RelationOptions<C> {
|
||||
/** Parent field whose value matches the child's foreign key. Default "id". */
|
||||
localKey?: string;
|
||||
/** Child table to load from. */
|
||||
table: string;
|
||||
/** Child column that references the parent. */
|
||||
foreignKey: string;
|
||||
/** Property name to attach on each parent. */
|
||||
as: string;
|
||||
/** true → attach a single child (belongsTo); false → an array (hasMany). */
|
||||
single?: boolean;
|
||||
/** Map child rows through a model. */
|
||||
model?: Model<C>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a relation for a set of parent rows in ONE query and attach it to each
|
||||
* parent (no N+1). Returns the same parents, each with `opts.as` populated.
|
||||
*
|
||||
* await loadRelated(db, users, { table: "posts", foreignKey: "userId", as: "posts" })
|
||||
*/
|
||||
export async function loadRelated<P extends Row, C extends Row = Row>(
|
||||
db: Db,
|
||||
parents: P[],
|
||||
opts: RelationOptions<C>,
|
||||
): Promise<(P & Record<string, C | C[] | null>)[]> {
|
||||
const localKey = opts.localKey ?? "id";
|
||||
assertIdent(opts.table, "table name");
|
||||
assertIdent(opts.foreignKey, "foreign key");
|
||||
|
||||
const results = parents as (P & Record<string, C | C[] | null>)[];
|
||||
if (parents.length === 0) return results;
|
||||
|
||||
const keys = [...new Set(parents.map((p) => p[localKey]).filter((k) => k != null))];
|
||||
if (keys.length === 0) {
|
||||
for (const parent of results)
|
||||
(parent as Record<string, unknown>)[opts.as] = opts.single ? null : [];
|
||||
return results;
|
||||
}
|
||||
|
||||
const dialect = db.driver.dialect;
|
||||
const placeholders = keys.map((_, i) => placeholder(dialect, i + 1)).join(", ");
|
||||
const children = await db.all<C>(
|
||||
`SELECT * FROM ${opts.table} WHERE ${opts.foreignKey} IN (${placeholders})`,
|
||||
keys,
|
||||
opts.model,
|
||||
);
|
||||
|
||||
const grouped = new Map<unknown, C[]>();
|
||||
for (const child of children) {
|
||||
const fk = (child as Row)[opts.foreignKey];
|
||||
const bucket = grouped.get(fk);
|
||||
if (bucket) bucket.push(child);
|
||||
else grouped.set(fk, [child]);
|
||||
}
|
||||
|
||||
for (const parent of results) {
|
||||
const matches = grouped.get(parent[localKey]) ?? [];
|
||||
(parent as Record<string, unknown>)[opts.as] = opts.single ? (matches[0] ?? null) : matches;
|
||||
}
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Database models — the single source of truth for a table's shape.
|
||||
*
|
||||
* A model defined with `table()` + the `v` column builder drives (1) DDL for
|
||||
* migrations, (2) coercion/validation of DB rows into typed objects
|
||||
* (`model.parse`), and later (3) the types the sqlc-style query generator emits.
|
||||
* Column types are dialect-neutral; each adapter maps them to its own SQL types.
|
||||
*/
|
||||
|
||||
export type BaseType = "id" | "text" | "int" | "real" | "bool" | "timestamp" | "json";
|
||||
|
||||
export interface ColumnDef {
|
||||
type: BaseType;
|
||||
/** NOT NULL unless `.optional()` was called. Ids are implicitly not-null. */
|
||||
notNull: boolean;
|
||||
primaryKey: boolean;
|
||||
autoIncrement: boolean;
|
||||
unique: boolean;
|
||||
/** Literal default, or the sentinel "now" for a current-timestamp default. */
|
||||
default?: unknown;
|
||||
references?: { table: string; column: string };
|
||||
}
|
||||
|
||||
/** A fluent column definition. Chain modifiers, then hand it to `table()`. */
|
||||
export class Column {
|
||||
readonly def: ColumnDef;
|
||||
constructor(type: BaseType, overrides: Partial<ColumnDef> = {}) {
|
||||
this.def = {
|
||||
type,
|
||||
notNull: true,
|
||||
primaryKey: false,
|
||||
autoIncrement: false,
|
||||
unique: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
optional(): this {
|
||||
this.def.notNull = false;
|
||||
return this;
|
||||
}
|
||||
unique(): this {
|
||||
this.def.unique = true;
|
||||
return this;
|
||||
}
|
||||
default(value: unknown): this {
|
||||
this.def.default = value;
|
||||
return this;
|
||||
}
|
||||
primaryKey(): this {
|
||||
this.def.primaryKey = true;
|
||||
return this;
|
||||
}
|
||||
references(table: string, column = "id"): this {
|
||||
this.def.references = { table, column };
|
||||
return this;
|
||||
}
|
||||
/** Coerce a raw DB value into its JS type (used by `model.parse`). */
|
||||
coerce(raw: unknown): unknown {
|
||||
if (raw === null || raw === undefined) return this.def.notNull ? raw : null;
|
||||
switch (this.def.type) {
|
||||
case "id":
|
||||
case "int":
|
||||
return typeof raw === "bigint" ? Number(raw) : Number(raw);
|
||||
case "real":
|
||||
return Number(raw);
|
||||
case "bool":
|
||||
return raw === true || raw === 1 || raw === "1" || raw === "true";
|
||||
case "timestamp":
|
||||
return raw instanceof Date ? raw : new Date(raw as string | number);
|
||||
case "json":
|
||||
return typeof raw === "string" ? safeJson(raw) : raw;
|
||||
default:
|
||||
return String(raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function safeJson(value: string): unknown {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Column builders. `v.id()` is an auto-increment primary key. */
|
||||
export const v = {
|
||||
id: () => new Column("id", { primaryKey: true, autoIncrement: true }),
|
||||
text: () => new Column("text"),
|
||||
string: () => new Column("text"),
|
||||
int: () => new Column("int"),
|
||||
number: () => new Column("real"),
|
||||
real: () => new Column("real"),
|
||||
bool: () => new Column("bool"),
|
||||
boolean: () => new Column("bool"),
|
||||
timestamp: () => new Column("timestamp"),
|
||||
json: () => new Column("json"),
|
||||
};
|
||||
|
||||
export type Columns = Record<string, Column>;
|
||||
|
||||
export interface Model<T = Record<string, unknown>> {
|
||||
name: string;
|
||||
columns: Columns;
|
||||
/** Coerce a raw DB row into a typed object (unknown columns pass through). */
|
||||
parse(row: Record<string, unknown>): T;
|
||||
/** Column definitions, for migrations and the query generator. */
|
||||
describe(): Record<string, ColumnDef>;
|
||||
}
|
||||
|
||||
/** Define a table model from a name and a map of columns. */
|
||||
export function table<T = Record<string, unknown>>(name: string, columns: Columns): Model<T> {
|
||||
return {
|
||||
name,
|
||||
columns,
|
||||
parse(row) {
|
||||
const out: Record<string, unknown> = { ...row };
|
||||
for (const [key, column] of Object.entries(columns)) {
|
||||
if (key in row) out[key] = column.coerce(row[key]);
|
||||
}
|
||||
return out as T;
|
||||
},
|
||||
describe() {
|
||||
const out: Record<string, ColumnDef> = {};
|
||||
for (const [key, column] of Object.entries(columns)) out[key] = column.def;
|
||||
return out;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* A persistent, process-shared session backend built on `bun:sqlite` (sync, so
|
||||
* it satisfies `SessionBackend` without a load/save wrapper). Sessions survive
|
||||
* restarts and are shared by every worker pointed at the same file.
|
||||
*
|
||||
* import { setSessionBackend } from "@wrnexus/core";
|
||||
* import { sqliteSessionStore } from "@wrnexus/db/session";
|
||||
* setSessionBackend(sqliteSessionStore("./sessions.db"));
|
||||
*/
|
||||
|
||||
import { Database } from "bun:sqlite";
|
||||
import type { SessionBackend, SessionEntry } from "@wrnexus/core";
|
||||
|
||||
export function sqliteSessionStore(path = "sessions.db"): SessionBackend {
|
||||
const db = new Database(path);
|
||||
db.run("PRAGMA journal_mode = WAL");
|
||||
db.run(
|
||||
"CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, data TEXT NOT NULL, expiresAt INTEGER NOT NULL)",
|
||||
);
|
||||
const getStmt = db.query("SELECT data, expiresAt FROM sessions WHERE id = ?");
|
||||
const setStmt = db.query(
|
||||
"INSERT INTO sessions (id, data, expiresAt) VALUES (?, ?, ?) " +
|
||||
"ON CONFLICT(id) DO UPDATE SET data = excluded.data, expiresAt = excluded.expiresAt",
|
||||
);
|
||||
const delStmt = db.query("DELETE FROM sessions WHERE id = ?");
|
||||
const gcStmt = db.query("DELETE FROM sessions WHERE expiresAt <= ?");
|
||||
|
||||
return {
|
||||
get(id): SessionEntry | undefined {
|
||||
const row = getStmt.get(id) as { data: string; expiresAt: number } | null;
|
||||
if (!row) return undefined;
|
||||
try {
|
||||
return { data: JSON.parse(row.data) as Record<string, unknown>, expiresAt: row.expiresAt };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
set(id, entry) {
|
||||
setStmt.run(id, JSON.stringify(entry.data), entry.expiresAt);
|
||||
},
|
||||
delete(id) {
|
||||
delStmt.run(id);
|
||||
},
|
||||
gc(now) {
|
||||
gcStmt.run(now);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* SQL rendering shared by adapters and the migration runner. Column types are
|
||||
* dialect-neutral in the model; this maps them to each dialect's SQL types and
|
||||
* renders `CREATE TABLE`. (Postgres/MySQL land in later phases; the mappings are
|
||||
* here so the model layer is already portable.)
|
||||
*/
|
||||
|
||||
import type { ColumnDef, Model } from "./schema.ts";
|
||||
|
||||
export type Dialect = "sqlite" | "postgres" | "mysql";
|
||||
|
||||
function sqlType(def: ColumnDef, dialect: Dialect): string {
|
||||
if (def.type === "id") {
|
||||
if (dialect === "postgres") return "SERIAL";
|
||||
if (dialect === "mysql") return "INT AUTO_INCREMENT";
|
||||
return "INTEGER";
|
||||
}
|
||||
switch (def.type) {
|
||||
case "int":
|
||||
return "INTEGER";
|
||||
case "real":
|
||||
return dialect === "mysql" ? "DOUBLE" : "REAL";
|
||||
case "bool":
|
||||
return dialect === "postgres" ? "BOOLEAN" : "INTEGER";
|
||||
case "timestamp":
|
||||
return dialect === "sqlite" ? "TEXT" : "TIMESTAMP";
|
||||
case "json":
|
||||
return dialect === "postgres" ? "JSONB" : "TEXT";
|
||||
default:
|
||||
return dialect === "mysql" ? "VARCHAR(255)" : "TEXT";
|
||||
}
|
||||
}
|
||||
|
||||
function quoteId(id: string, dialect: Dialect): string {
|
||||
return dialect === "mysql" ? `\`${id}\`` : `"${id}"`;
|
||||
}
|
||||
|
||||
function renderDefault(value: unknown, dialect: Dialect): string {
|
||||
if (value === "now") return "CURRENT_TIMESTAMP";
|
||||
if (typeof value === "number") return String(value);
|
||||
if (typeof value === "boolean") return dialect === "postgres" ? String(value) : value ? "1" : "0";
|
||||
return `'${String(value).replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
/** Render `CREATE TABLE` for a model in the given dialect. */
|
||||
export function createTableSql(model: Model, dialect: Dialect, ifNotExists = true): string {
|
||||
const cols: string[] = [];
|
||||
for (const [name, column] of Object.entries(model.columns)) {
|
||||
const def = column.def;
|
||||
const parts = [quoteId(name, dialect), sqlType(def, dialect)];
|
||||
if (def.primaryKey) {
|
||||
parts.push(
|
||||
dialect === "sqlite" && def.type === "id" ? "PRIMARY KEY AUTOINCREMENT" : "PRIMARY KEY",
|
||||
);
|
||||
}
|
||||
if (def.notNull && !def.primaryKey) parts.push("NOT NULL");
|
||||
if (def.unique && !def.primaryKey) parts.push("UNIQUE");
|
||||
if (def.default !== undefined) parts.push(`DEFAULT ${renderDefault(def.default, dialect)}`);
|
||||
if (def.references) {
|
||||
parts.push(
|
||||
`REFERENCES ${quoteId(def.references.table, dialect)}(${quoteId(def.references.column, dialect)})`,
|
||||
);
|
||||
}
|
||||
cols.push(" " + parts.join(" "));
|
||||
}
|
||||
const head = `CREATE TABLE ${ifNotExists ? "IF NOT EXISTS " : ""}${quoteId(model.name, dialect)}`;
|
||||
return `${head} (\n${cols.join(",\n")}\n);`;
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
v,
|
||||
table,
|
||||
createDb,
|
||||
createTableSql,
|
||||
parseMigration,
|
||||
migrate,
|
||||
status,
|
||||
rollback,
|
||||
parseQueries,
|
||||
generateQueriesFile,
|
||||
} from "../src/index.ts";
|
||||
import { sqlite } from "../src/adapters/sqlite.ts";
|
||||
import { bunSql } from "../src/adapters/bunsql.ts";
|
||||
|
||||
const users = table<{ id: number; email: string; name: string; active: boolean }>("users", {
|
||||
id: v.id(),
|
||||
email: v.string().unique(),
|
||||
name: v.string(),
|
||||
active: v.boolean().default(true),
|
||||
});
|
||||
|
||||
test("createTableSql renders dialect-specific DDL", () => {
|
||||
expect(createTableSql(users, "sqlite")).toContain("INTEGER PRIMARY KEY AUTOINCREMENT");
|
||||
const pg = createTableSql(users, "postgres");
|
||||
expect(pg).toContain("SERIAL PRIMARY KEY");
|
||||
expect(pg).toContain("BOOLEAN");
|
||||
});
|
||||
|
||||
test("model.parse coerces DB rows to typed values", () => {
|
||||
const row = users.parse({ id: "1", email: "a@b.com", name: "Ann", active: 1 });
|
||||
expect(row.id).toBe(1);
|
||||
expect(row.active).toBe(true);
|
||||
});
|
||||
|
||||
for (const [label, driver] of [
|
||||
["bun:sqlite", () => sqlite()],
|
||||
["Bun.sql/sqlite", () => bunSql("sqlite://:memory:", "sqlite")],
|
||||
] as const) {
|
||||
test(`CRUD + transactions [${label}]`, async () => {
|
||||
const db = createDb(driver());
|
||||
await db.createTable(users);
|
||||
await db.exec("INSERT INTO users (email, name, active) VALUES (?, ?, ?)", [
|
||||
"a@b.com",
|
||||
"Ann",
|
||||
true,
|
||||
]);
|
||||
// rollback
|
||||
try {
|
||||
await db.tx(async (t) => {
|
||||
await t.exec("INSERT INTO users (email, name) VALUES (?, ?)", ["x@y.com", "X"]);
|
||||
throw new Error("boom");
|
||||
});
|
||||
} catch {
|
||||
/* expected */
|
||||
}
|
||||
// commit
|
||||
await db.tx(async (t) => {
|
||||
await t.exec("INSERT INTO users (email, name) VALUES (?, ?)", ["c@d.com", "Cy"]);
|
||||
});
|
||||
const rows = await db.all("SELECT * FROM users ORDER BY id", [], users);
|
||||
expect(rows.map((r) => r.name)).toEqual(["Ann", "Cy"]);
|
||||
expect(typeof rows[0]!.active).toBe("boolean");
|
||||
db.close();
|
||||
});
|
||||
}
|
||||
|
||||
test("migration runner: parse, migrate, status, rollback", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "wire-mig-"));
|
||||
writeFileSync(
|
||||
join(dir, "0001_init.sql"),
|
||||
"-- +up\nCREATE TABLE t (id INTEGER PRIMARY KEY, n TEXT);\n-- +down\nDROP TABLE t;",
|
||||
);
|
||||
const parsed = parseMigration(
|
||||
"0001_init",
|
||||
"-- +up\nCREATE TABLE t (id INTEGER);\n-- +down\nDROP TABLE t;",
|
||||
);
|
||||
expect(parsed.up).toContain("CREATE TABLE t");
|
||||
expect(parsed.down).toContain("DROP TABLE t");
|
||||
|
||||
const db = createDb(sqlite());
|
||||
expect(await migrate(db, dir)).toEqual(["0001_init"]);
|
||||
expect(await migrate(db, dir)).toEqual([]); // idempotent
|
||||
expect((await status(db, dir))[0]).toEqual({ name: "0001_init", applied: true });
|
||||
const tablesAfter = await db.all<{ name: string }>(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='t'",
|
||||
);
|
||||
expect(tablesAfter.length).toBe(1);
|
||||
expect(await rollback(db, dir)).toBe("0001_init");
|
||||
expect((await status(db, dir))[0]!.applied).toBe(false);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test("query generator infers params and result types", () => {
|
||||
const q = parseQueries(
|
||||
"-- name: GetByEmail :one\nSELECT * FROM users WHERE email = :email;\n" +
|
||||
"-- name: CountActive :one\nSELECT COUNT(*) AS n FROM users WHERE active = :active;\n" +
|
||||
"-- name: Create :exec\nINSERT INTO users (email, name) VALUES (:email, :name);",
|
||||
);
|
||||
expect(q.map((x) => x.name)).toEqual(["GetByEmail", "CountActive", "Create"]);
|
||||
const code = generateQueriesFile(q, [{ varName: "users", model: users }], "sqlite");
|
||||
expect(code).toContain("GetByEmail(db: Db, args: { email: string })");
|
||||
expect(code).toContain(
|
||||
"CountActive(db: Db, args: { active: boolean }): Promise<{ n: number } | null>",
|
||||
);
|
||||
expect(code).toContain(
|
||||
"Create(db: Db, args: { email: string; name: string }): Promise<ExecResult>",
|
||||
);
|
||||
});
|
||||
|
||||
test("paginate returns a page window with correct metadata", async () => {
|
||||
const { paginate } = await import("../src/index.ts");
|
||||
const db = createDb(sqlite());
|
||||
await db.createTable(users);
|
||||
for (let i = 1; i <= 25; i++) {
|
||||
await db.exec("INSERT INTO users (email, name, active) VALUES (?, ?, ?)", [
|
||||
`u${i}@x.com`,
|
||||
`U${i}`,
|
||||
true,
|
||||
]);
|
||||
}
|
||||
const p2 = await paginate(
|
||||
db,
|
||||
{ sql: "SELECT * FROM users ORDER BY id", model: users },
|
||||
{ page: 2, perPage: 10 },
|
||||
);
|
||||
expect(p2.total).toBe(25);
|
||||
expect(p2.totalPages).toBe(3);
|
||||
expect(p2.items.length).toBe(10);
|
||||
expect(p2.items[0]!.name).toBe("U11");
|
||||
expect(p2.hasNext).toBe(true);
|
||||
expect(p2.hasPrev).toBe(true);
|
||||
|
||||
const p3 = await paginate(
|
||||
db,
|
||||
{ sql: "SELECT * FROM users ORDER BY id" },
|
||||
{ page: 3, perPage: 10 },
|
||||
);
|
||||
expect(p3.items.length).toBe(5);
|
||||
expect(p3.hasNext).toBe(false);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test("loadRelated batches children onto parents (no N+1)", async () => {
|
||||
const { loadRelated } = await import("../src/index.ts");
|
||||
const db = createDb(sqlite());
|
||||
await db.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)");
|
||||
await db.exec("CREATE TABLE posts (id INTEGER PRIMARY KEY, userId INTEGER, title TEXT)");
|
||||
await db.exec("INSERT INTO users (id, name) VALUES (1, 'Ann'), (2, 'Bob')");
|
||||
await db.exec(
|
||||
"INSERT INTO posts (id, userId, title) VALUES (1, 1, 'a'), (2, 1, 'b'), (3, 2, 'c')",
|
||||
);
|
||||
|
||||
const parents = await db.all<{ id: number; name: string }>("SELECT * FROM users ORDER BY id");
|
||||
const withPosts = await loadRelated(db, parents, {
|
||||
table: "posts",
|
||||
foreignKey: "userId",
|
||||
as: "posts",
|
||||
});
|
||||
expect((withPosts[0]!.posts as unknown[]).length).toBe(2);
|
||||
expect((withPosts[1]!.posts as unknown[]).length).toBe(1);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test("loadRelated rejects unsafe identifiers", async () => {
|
||||
const { loadRelated } = await import("../src/index.ts");
|
||||
const db = createDb(sqlite());
|
||||
await expect(
|
||||
loadRelated(db, [{ id: 1 }], {
|
||||
table: "posts; DROP TABLE users",
|
||||
foreignKey: "userId",
|
||||
as: "x",
|
||||
}),
|
||||
).rejects.toThrow("Unsafe table name");
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test("sqliteSessionStore persists sessions (get/set/delete/gc)", async () => {
|
||||
const { sqliteSessionStore } = await import("../src/session-store.ts");
|
||||
const store = sqliteSessionStore(":memory:");
|
||||
expect(store.get("s1")).toBeUndefined();
|
||||
store.set("s1", { data: { user: 7 }, expiresAt: Date.now() + 10_000 });
|
||||
expect(store.get("s1")!.data).toEqual({ user: 7 });
|
||||
store.set("s1", { data: { user: 8 }, expiresAt: Date.now() + 10_000 }); // upsert
|
||||
expect(store.get("s1")!.data).toEqual({ user: 8 });
|
||||
store.set("old", { data: {}, expiresAt: Date.now() - 1 });
|
||||
store.gc!(Date.now());
|
||||
expect(store.get("old")).toBeUndefined();
|
||||
store.delete("s1");
|
||||
expect(store.get("s1")).toBeUndefined();
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Live Postgres/MySQL integration tests. These are GATED on env vars so the
|
||||
* normal `bun test` run stays green without a database:
|
||||
*
|
||||
* WRNEXUS_PG_URL=postgres://… WRNEXUS_MYSQL_URL=mysql://… bun test packages/db
|
||||
*
|
||||
* The `bun run test:db:live` script spins up both via docker-compose, sets the
|
||||
* env vars, runs this file, and tears the containers down.
|
||||
*/
|
||||
import { test, expect } from "bun:test";
|
||||
import { v, table, createDb, createTableSql, paginate, type Dialect } from "../src/index.ts";
|
||||
import { bunSql } from "../src/adapters/bunsql.ts";
|
||||
|
||||
const users = table<{ id: number; email: string; name: string; active: boolean }>("users", {
|
||||
id: v.id(),
|
||||
email: v.string().unique(),
|
||||
name: v.string(),
|
||||
active: v.boolean().default(true),
|
||||
});
|
||||
|
||||
const targets: { dialect: Dialect; url: string }[] = [];
|
||||
if (process.env.WRNEXUS_PG_URL)
|
||||
targets.push({ dialect: "postgres", url: process.env.WRNEXUS_PG_URL });
|
||||
if (process.env.WRNEXUS_MYSQL_URL)
|
||||
targets.push({ dialect: "mysql", url: process.env.WRNEXUS_MYSQL_URL });
|
||||
|
||||
const ph = (dialect: Dialect, i: number) => (dialect === "postgres" ? `$${i}` : "?");
|
||||
|
||||
if (targets.length === 0) {
|
||||
test.skip("live PG/MySQL (set WRNEXUS_PG_URL / WRNEXUS_MYSQL_URL to run)", () => {});
|
||||
} else {
|
||||
for (const { dialect, url } of targets) {
|
||||
test(`${dialect}: DDL + CRUD + transaction + pagination`, async () => {
|
||||
const db = createDb(bunSql(url, dialect));
|
||||
try {
|
||||
await db.exec("DROP TABLE IF EXISTS users");
|
||||
await db.exec(createTableSql(users, dialect));
|
||||
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
await db.exec(
|
||||
`INSERT INTO users (email, name, active) VALUES (${ph(dialect, 1)}, ${ph(dialect, 2)}, ${ph(dialect, 3)})`,
|
||||
[`u${i}@x.com`, `U${i}`, true],
|
||||
);
|
||||
}
|
||||
|
||||
const count = await db.one<{ n: number | string }>("SELECT COUNT(*) AS n FROM users");
|
||||
expect(Number(count?.n)).toBe(5);
|
||||
|
||||
// Transaction rollback leaves the table unchanged.
|
||||
await db
|
||||
.tx(async (t) => {
|
||||
await t.exec(
|
||||
`INSERT INTO users (email, name, active) VALUES (${ph(dialect, 1)}, ${ph(dialect, 2)}, ${ph(dialect, 3)})`,
|
||||
["rollback@x.com", "R", true],
|
||||
);
|
||||
throw new Error("rollback");
|
||||
})
|
||||
.catch(() => {});
|
||||
const after = await db.one<{ n: number | string }>("SELECT COUNT(*) AS n FROM users");
|
||||
expect(Number(after?.n)).toBe(5);
|
||||
|
||||
const page = await paginate(
|
||||
db,
|
||||
{ sql: "SELECT * FROM users ORDER BY id", model: users },
|
||||
{ page: 1, perPage: 2 },
|
||||
);
|
||||
expect(page.total).toBe(5);
|
||||
expect(page.totalPages).toBe(3);
|
||||
expect(page.items.length).toBe(2);
|
||||
|
||||
await db.exec("DROP TABLE IF EXISTS users");
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import {
|
||||
createDb,
|
||||
setDb,
|
||||
getDb,
|
||||
hasDb,
|
||||
registerDb,
|
||||
databaseNames,
|
||||
closeDatabases,
|
||||
} from "../src/index.ts";
|
||||
import { sqlite } from "../src/adapters/sqlite.ts";
|
||||
|
||||
test("multi-database registry: default + named connections", async () => {
|
||||
await closeDatabases(); // isolate from any prior state
|
||||
|
||||
const main = createDb(sqlite(":memory:"));
|
||||
const analytics = createDb(sqlite(":memory:"));
|
||||
|
||||
setDb(main); // default
|
||||
registerDb("analytics", analytics); // named
|
||||
|
||||
expect(getDb()).toBe(main);
|
||||
expect(getDb("analytics")).toBe(analytics);
|
||||
expect(hasDb()).toBe(true);
|
||||
expect(hasDb("analytics")).toBe(true);
|
||||
expect(hasDb("missing")).toBe(false);
|
||||
expect(databaseNames().sort()).toEqual(["analytics", "default"]);
|
||||
|
||||
// Each connection is independent — a table in one is not in the other.
|
||||
await main.exec("CREATE TABLE a (id INTEGER)");
|
||||
await analytics.exec("CREATE TABLE b (id INTEGER)");
|
||||
await getDb().exec("INSERT INTO a (id) VALUES (1)");
|
||||
await getDb("analytics").exec("INSERT INTO b (id) VALUES (2)");
|
||||
expect((await getDb().all("SELECT id FROM a")).length).toBe(1);
|
||||
expect((await getDb("analytics").all("SELECT id FROM b")).length).toBe(1);
|
||||
|
||||
await closeDatabases();
|
||||
expect(hasDb()).toBe(false);
|
||||
expect(databaseNames()).toEqual([]);
|
||||
});
|
||||
|
||||
test("getDb throws a helpful error for an unknown named database", async () => {
|
||||
await closeDatabases();
|
||||
setDb(createDb(sqlite(":memory:")));
|
||||
expect(() => getDb("nope")).toThrow(/No database named 'nope'/);
|
||||
await closeDatabases();
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
# @wrnexus/dev-server
|
||||
|
||||
> The WrNexus HTTP + WebSocket server runtime — request dispatch, SSR document assembly, live-reload (HMR), and the portable production handler.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
|
||||
## Overview
|
||||
|
||||
This package is the server runtime that powers a WrNexus app in both development and production. A single **request runtime** (`createHandlers`) owns HTTP/WebSocket dispatch and SSR document assembly; it knows nothing about _how_ modules and assets are produced, so the dev and prod entry points wire in different backends: dev uses dynamic module loading plus on-the-fly bundling and injects a live-reload client; prod uses a static, pre-built manifest with cache-immutable assets. The package also ships a multi-app **gateway** (route several apps by `Host` header behind one port) and a portable `node:http` adapter for WinterCG hosts. It is entirely server-side and Bun-native (`Bun.serve`, `Bun.file`, `Bun.gzipSync`).
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @wrnexus/dev-server
|
||||
```
|
||||
|
||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported for the full server; the `node:http` adapter is for WinterCG embedding only).
|
||||
|
||||
## API
|
||||
|
||||
### Main entry (`@wrnexus/dev-server`)
|
||||
|
||||
| Export | Kind | Purpose |
|
||||
| --------------------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `startServer(opts: ServeOptions)` | `Promise<RunningServer>` | Start the dev server on `Bun.serve`: builds the router, connects/migrates databases, wires assets + HMR, and starts the file watcher. |
|
||||
| `createHandlers(deps: RuntimeDeps)` | `Handlers` | The shared request runtime (fetch + websocket handlers). Re-exported from `runtime.ts`. |
|
||||
| `createProductionServer(manifest, opts)` | `Bun.Server` | Start the production server from a precompiled manifest. |
|
||||
| `createProductionHandlers(manifest, opts)` | `Handlers` | Build the portable prod fetch/websocket handlers with no server bound (the deployment-adapter seam). |
|
||||
| `startGateway(opts: GatewayOptions)` | `Promise<RunningGateway>` | Boot multiple apps as child processes and route by `Host`. |
|
||||
| `toRequest`, `writeResponse`, `nodeListener`, `serveNode` | functions | `node:http` ↔ WinterCG `Request`/`Response` adapter. |
|
||||
| `RESTART_EXIT_CODE` | `number` (`97`) | Exit code the dev child uses to ask the supervisor for a fresh process. |
|
||||
| `STYLES_HREF`, `HMR_CLIENT_JS` | constants | The global stylesheet URL and the inline HMR client script. |
|
||||
|
||||
Exported types: `ServeOptions`, `RunningServer`, `RuntimeDeps`, `AssetServer`, `WsData`, `GatewayApp`, `GatewayOptions`, `GatewayAuth`, `GatewaySecurity`, `RunningGateway`, `FetchHandler`.
|
||||
|
||||
### `startServer(opts)`
|
||||
|
||||
```ts
|
||||
interface ServeOptions {
|
||||
appDir: string; // absolute/relative path to the app/ dir
|
||||
port?: number; // default 3000
|
||||
hostname?: string; // default "localhost"
|
||||
mode?: Mode; // "development" | "production"; default "development"
|
||||
hmr?: boolean; // inject live-reload client; default (mode === "development")
|
||||
styleEntry?: string | null; // resolved absolute path to the global CSS entry
|
||||
stylesConfig?: StylesConfig; // custom styles processor (e.g. Tailwind/PostCSS)
|
||||
head?: string; // raw HTML appended to every page <head>
|
||||
seo?: SeoConfig; // global SEO defaults
|
||||
security?: SecurityConfig; // security headers + CORS policy
|
||||
theme?: ThemeConfig; // design-token theme (merged over built-in light/dark)
|
||||
i18n?: I18nConfig; // default language + supported locales
|
||||
db?: { driver: string; url: string }; // default db → getDb(); dev auto-migrates
|
||||
databases?: Record<string, { driver: string; url: string }>; // named dbs → getDb("<name>")
|
||||
realtime?: { scale?: boolean; redisUrl?: string }; // bridge rooms over Redis across processes
|
||||
}
|
||||
|
||||
interface RunningServer {
|
||||
port: number;
|
||||
hostname: string;
|
||||
url: string;
|
||||
router: Router;
|
||||
stop(): void;
|
||||
}
|
||||
```
|
||||
|
||||
In development, `startServer` also connects `app/db/migrations` (and `app/db/<name>/migrations`) and auto-applies migrations, then starts an in-process file watcher. CSS edits hot-swap live; any other server change triggers `process.exit(RESTART_EXIT_CODE)` so the dev supervisor (`@wrnexus/cli`) respawns the process with fresh modules.
|
||||
|
||||
### `createHandlers(deps)`
|
||||
|
||||
The core runtime shared by dev and prod. It handles CORS preflight, `/healthz` and `/__wrnexus/health`, request-body size limits (413), HMR socket upgrades (`/__wrnexus/hmr`), realtime WebSocket upgrades (`defineRoom` default export or a raw `websocket` export), the middleware pipeline, API routes (`/api/*`), framework assets (`/__wrnexus/*`), public assets, and full SSR page rendering (component mounts, layouts, slots, i18n markers, per-page script selection, ETag/304, gzip).
|
||||
|
||||
```ts
|
||||
interface RuntimeDeps {
|
||||
mode: Mode;
|
||||
hmr: boolean; // inject the live-reload client into pages
|
||||
router: Router;
|
||||
loadModule(file: string): Promise<Record<string, unknown>>;
|
||||
getMiddleware(): Promise<Middleware[]>;
|
||||
assets: AssetServer; // serves /__wrnexus/* (islands, reactive, hmr)
|
||||
hasStyles?: boolean; // inject the global stylesheet link
|
||||
hasUi?: boolean; // inject the Wire UI stylesheet (/__wrnexus/ui.css)
|
||||
theme?: ResolvedTheme; // enables /__wrnexus/theme.css + <html data-theme>
|
||||
i18n?: ResolvedI18n; // enables ctx.t, <html lang>, {t:key} markers
|
||||
inlineStyles?: string; // inline small prod stylesheets into <head>
|
||||
assetVersion?: string; // cache-busting ?v= on framework asset URLs
|
||||
head?: string; // raw HTML appended to every page <head>
|
||||
seo?: SeoConfig;
|
||||
security?: SecurityConfig;
|
||||
maxBodyBytes?: number; // 413 above this; default 10 MB
|
||||
hub?: HmrHub; // browser HMR sockets (dev only)
|
||||
realtimeBus?: RealtimeBus; // cross-process room bridge (Redis pub/sub)
|
||||
}
|
||||
|
||||
interface Handlers {
|
||||
fetch(req: Request, server: UpgradeServer): Promise<Response | undefined>;
|
||||
websocket: { open; message; close; drain };
|
||||
}
|
||||
```
|
||||
|
||||
`WsData` is the per-connection socket tag — a discriminated union of `{ kind: "realtime"; handler }`, `{ kind: "room"; meta }`, or `{ kind: "hmr" }`.
|
||||
|
||||
### `createProductionServer(manifest, opts)` / `createProductionHandlers(manifest, opts)`
|
||||
|
||||
Production runs the _same_ request runtime as dev, but with no filesystem scan and no runtime bundling. `wrnexus build` emits an entry that statically imports every route/component/layout module and passes them as a `ProdManifest`; the route-matching tables are rebuilt from the raw patterns.
|
||||
|
||||
```ts
|
||||
interface ProdManifest {
|
||||
pages: { raw: string; mod: RouteModule }[];
|
||||
api: { raw: string; mod: RouteModule }[];
|
||||
realtime: { raw: string; mod: RouteModule }[];
|
||||
middleware: Middleware[];
|
||||
components: { name: string; mod: RouteModule }[];
|
||||
layouts: { name: string; mod: RouteModule }[];
|
||||
}
|
||||
|
||||
interface ProdOptions {
|
||||
stylesPath?: string;
|
||||
inlineStyles?: string;
|
||||
reactivePath?: string;
|
||||
themePath?: string;
|
||||
themeJsPath?: string;
|
||||
theme?: ResolvedTheme;
|
||||
uiCssPath?: string;
|
||||
schemasJs?: string;
|
||||
i18n?: ResolvedI18n;
|
||||
db?: { driver: string; url: string };
|
||||
databases?: Record<string, { driver: string; url: string }>;
|
||||
realtime?: { scale?: boolean; redisUrl?: string };
|
||||
assetVersion?: string;
|
||||
publicDir?: string;
|
||||
head?: string;
|
||||
seo?: SeoConfig;
|
||||
security?: SecurityConfig;
|
||||
port?: number;
|
||||
hostname?: string;
|
||||
maxBodyBytes?: number;
|
||||
}
|
||||
```
|
||||
|
||||
`createProductionServer` also loads the `.env` cascade for the `production` profile, installs `SIGTERM`/`SIGINT` graceful shutdown, and binds `0.0.0.0` (port from `opts.port` or `$PORT`, default 3000). Migrations are **not** run here — apply them first (`wrnexus db migrate`). `createProductionHandlers` returns the bare handlers for edge/serverless/`node:http` deployment.
|
||||
|
||||
### `startGateway(opts)` — multi-app gateway
|
||||
|
||||
Serves several apps behind one port and routes each request to the right app by its `Host` header. Each app runs as its own child process (full isolation); the gateway is a thin host-based reverse proxy for HTTP and WebSocket. Apps communicate at runtime via `@wrnexus/pubsub` (use the Redis driver so messages cross processes).
|
||||
|
||||
```ts
|
||||
interface GatewayOptions {
|
||||
port?: number; // default 3000
|
||||
hostname?: string; // default "localhost"
|
||||
mode?: "development" | "production";
|
||||
apps: GatewayApp[];
|
||||
security?: GatewaySecurity;
|
||||
}
|
||||
|
||||
interface GatewayApp {
|
||||
name: string; // app id (for logs)
|
||||
dir: string; // app root (contains app/ + wrnexus.config.ts)
|
||||
domains: string[]; // host names routed here
|
||||
port?: number; // fixed internal port; else assigned
|
||||
auth?: GatewayAuth; // per-app edge access control
|
||||
}
|
||||
|
||||
interface GatewayAuth {
|
||||
basic?: { user: string; pass: string } | Array<{ user: string; pass: string }>;
|
||||
allowIps?: string[]; // exact-match IP allowlist
|
||||
forward?: { url: string }; // forward-auth (SSO): 2xx allows
|
||||
}
|
||||
|
||||
interface GatewaySecurity {
|
||||
trustedHostsOnly?: boolean; // 404 unknown hosts instead of first app
|
||||
rateLimit?: { max: number; windowMs?: number }; // global by client IP (429)
|
||||
headers?: boolean; // add baseline edge security headers
|
||||
forwardedHeaders?: boolean; // set X-Forwarded-* (default true)
|
||||
accessLog?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
The gateway exposes `/__gateway/health` (JSON list of routed apps) and returns a `RunningGateway` (`{ port, url, stop() }`).
|
||||
|
||||
### `node:http` adapter (from `./adapters/node.ts`)
|
||||
|
||||
For embedding the WinterCG handler behind an existing Node server or a WinterCG host. Note the full app still needs Bun-compatible globals (`Bun.file`, `bun:sqlite`, etc.); only the `Request`/`Response` conversion is fully portable.
|
||||
|
||||
```ts
|
||||
type FetchHandler = (req: Request) => Response | undefined | Promise<Response | undefined>;
|
||||
|
||||
toRequest(req: IncomingMessage, opts?): Promise<Request>
|
||||
writeResponse(res: ServerResponse, response: Response): Promise<void> // preserves multiple Set-Cookie
|
||||
nodeListener(handler: FetchHandler, opts?): (req, res) => Promise<void>
|
||||
serveNode(handler: FetchHandler, opts?): Promise<Server>
|
||||
```
|
||||
|
||||
### Subpath export: `@wrnexus/dev-server/serve-entry`
|
||||
|
||||
The child process the dev supervisor launches:
|
||||
|
||||
```bash
|
||||
bun run serve-entry.ts <appDir> <port> <mode>
|
||||
```
|
||||
|
||||
It loads the optional `wrnexus.config.ts`, resolves the style entry, calls `startServer`, and prints the route table (Pages / API / Realtime / Components). Because it runs in its own process, every restart re-imports all route modules fresh — that is how the supervisor delivers live reload of edited server code. `startGateway` resolves this entry via `import.meta.resolve("@wrnexus/dev-server/serve-entry")` to spawn each dev app.
|
||||
|
||||
## Usage
|
||||
|
||||
### Programmatic dev server
|
||||
|
||||
```ts
|
||||
import { startServer } from "@wrnexus/dev-server";
|
||||
|
||||
const server = await startServer({
|
||||
appDir: "./app",
|
||||
port: 3000,
|
||||
mode: "development",
|
||||
theme: {/* design tokens */},
|
||||
db: { driver: "sqlite", url: "file:./data/app.db" },
|
||||
});
|
||||
|
||||
console.log(`Running at ${server.url}`);
|
||||
// server.stop();
|
||||
```
|
||||
|
||||
### Production server from a build manifest
|
||||
|
||||
```ts
|
||||
import { createProductionServer } from "@wrnexus/dev-server";
|
||||
import { manifest } from "./dist/manifest.js"; // generated by `wrnexus build`
|
||||
|
||||
createProductionServer(manifest, {
|
||||
stylesPath: "./dist/styles.css",
|
||||
reactivePath: "./dist/reactive.js",
|
||||
assetVersion: process.env.BUILD_ID,
|
||||
db: { driver: "postgres", url: process.env.DATABASE_URL! },
|
||||
port: Number(process.env.PORT) || 3000,
|
||||
});
|
||||
```
|
||||
|
||||
### Embedding the handler on `node:http`
|
||||
|
||||
```ts
|
||||
import { createProductionHandlers, serveNode } from "@wrnexus/dev-server";
|
||||
|
||||
const handlers = createProductionHandlers(manifest, opts);
|
||||
await serveNode(handlers.fetch, { port: 8080 });
|
||||
```
|
||||
|
||||
### Multi-app gateway
|
||||
|
||||
```ts
|
||||
import { startGateway } from "@wrnexus/dev-server";
|
||||
|
||||
await startGateway({
|
||||
port: 3000,
|
||||
apps: [
|
||||
{ name: "web", dir: "./apps/web", domains: ["localhost", "web.localhost"] },
|
||||
{
|
||||
name: "admin",
|
||||
dir: "./apps/admin",
|
||||
domains: ["admin.localhost"],
|
||||
auth: { basic: { user: "root", pass: "s3cret" } },
|
||||
},
|
||||
],
|
||||
security: { trustedHostsOnly: true, rateLimit: { max: 600 } },
|
||||
});
|
||||
```
|
||||
|
||||
## Framework asset routes
|
||||
|
||||
The runtime serves these framework-owned paths (dev builds them live; prod serves pre-built/immutable versions):
|
||||
|
||||
- `/__wrnexus/nav.js`, `/__wrnexus/reactive.js`, `/__wrnexus/realtime.js` — client runtimes
|
||||
- `/__wrnexus/validate.js`, `/__wrnexus/schemas.js`, `/__wrnexus/i18n.js` — validation + i18n runtimes
|
||||
- `/__wrnexus/theme.css`, `/__wrnexus/theme.js`, `/__wrnexus/ui.css`, `/__wrnexus/styles.css` — styles
|
||||
- `/__wrnexus/hmr` — dev-only HMR WebSocket
|
||||
- `/__wrnexus/csr` — server-evaluated CSR bindings for browser-side API fetches
|
||||
|
||||
Pages get only the scripts they use: `nav.js` always, `reactive.js` when a page has a `data-scope`/CSR fetch, plus theme/validation/i18n/realtime runtimes when the relevant markup is present.
|
||||
|
||||
## Requirements / Notes
|
||||
|
||||
- **Bun-only.** Uses `Bun.serve` (HTTP + WebSocket), `Bun.file`, and `Bun.gzipSync`. The full app also relies on `bun:sqlite` / `Bun.SQL` via `@wrnexus/db`.
|
||||
- Orchestrates the whole framework: `@wrnexus/core` (context, security, realtime registry), `@wrnexus/router`, `@wrnexus/ssr` (`renderDocument`), `@wrnexus/csr` (client runtimes), `@wrnexus/compiler` (`.wrn` → TS), `@wrnexus/styles`, `@wrnexus/ui`, `@wrnexus/validation`, `@wrnexus/i18n`, `@wrnexus/db`, and `@wrnexus/pubsub` (Redis-backed cross-process realtime).
|
||||
- `.wrn` files are compiled to TypeScript into a hidden sibling `.wrnexus/` cache dir and dynamically imported; the module cache means each edited server module needs a fresh process (dev) — hence the restart-on-change model.
|
||||
- Responses are gzipped when the client accepts it and the body is a buffered, compressible payload ≥ 1 KB; streaming/SSE responses opt out via `Cache-Control: no-transform`.
|
||||
</content>
|
||||
|
||||
</invoke>
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.2.12",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./serve-entry": "./src/serve-entry.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
"@wrnexus/router": "workspace:*",
|
||||
"@wrnexus/ssr": "workspace:*",
|
||||
"@wrnexus/csr": "workspace:*",
|
||||
"@wrnexus/compiler": "workspace:*",
|
||||
"@wrnexus/styles": "workspace:*",
|
||||
"@wrnexus/ui": "workspace:*",
|
||||
"@wrnexus/validation": "workspace:*",
|
||||
"@wrnexus/i18n": "workspace:*",
|
||||
"@wrnexus/db": "workspace:*",
|
||||
"@wrnexus/pubsub": "workspace:*",
|
||||
"@wrnexus/uploader": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* node:http adapter — bridge a WinterCG `fetch(request) => Response` handler
|
||||
* onto a Node HTTP server, with no external dependencies. Converts a Node
|
||||
* `IncomingMessage` into a web `Request` and writes a web `Response` back into a
|
||||
* `ServerResponse` (preserving multiple `Set-Cookie` headers).
|
||||
*
|
||||
* Caveat: the production handler uses Bun-native APIs (Bun.file for assets,
|
||||
* Bun.serve for websockets, Bun.SQL / bun:sqlite for the database), so running
|
||||
* the FULL app under plain Node needs Bun-compatible globals. This adapter is
|
||||
* for WinterCG hosts and for embedding the handler behind an existing
|
||||
* `node:http` server; the Request/Response conversion itself is fully portable.
|
||||
*/
|
||||
|
||||
import type { IncomingMessage, ServerResponse, Server } from "node:http";
|
||||
|
||||
export type FetchHandler = (req: Request) => Response | undefined | Promise<Response | undefined>;
|
||||
|
||||
/** Convert a Node IncomingMessage into a web Request (buffers the body). */
|
||||
export async function toRequest(
|
||||
req: IncomingMessage,
|
||||
opts: { origin?: string } = {},
|
||||
): Promise<Request> {
|
||||
const method = req.method ?? "GET";
|
||||
const host = req.headers.host ?? "localhost";
|
||||
const proto = (asString(req.headers["x-forwarded-proto"]) ?? "http").split(",")[0]!.trim();
|
||||
const origin = opts.origin ?? `${proto}://${host}`;
|
||||
const url = new URL(req.url ?? "/", origin);
|
||||
|
||||
const headers = new Headers();
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (value === undefined) continue;
|
||||
if (Array.isArray(value)) for (const v of value) headers.append(key, v);
|
||||
else headers.set(key, value);
|
||||
}
|
||||
|
||||
const hasBody = method !== "GET" && method !== "HEAD";
|
||||
const body = hasBody ? ((await readBody(req)) as BodyInit) : undefined;
|
||||
return new Request(url, { method, headers, body });
|
||||
}
|
||||
|
||||
function asString(value: string | string[] | undefined): string | undefined {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
function readBody(req: IncomingMessage): Promise<Uint8Array> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (c: Buffer) => chunks.push(c));
|
||||
req.on("end", () => resolve(new Uint8Array(Buffer.concat(chunks))));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** Write a web Response into a Node ServerResponse. */
|
||||
export async function writeResponse(res: ServerResponse, response: Response): Promise<void> {
|
||||
const headers: Record<string, string | string[]> = {};
|
||||
response.headers.forEach((value, key) => {
|
||||
if (key.toLowerCase() !== "set-cookie") headers[key] = value;
|
||||
});
|
||||
// Multiple Set-Cookie headers must stay separate (Headers.forEach joins them).
|
||||
const getSetCookie = (response.headers as { getSetCookie?: () => string[] }).getSetCookie;
|
||||
const cookies = typeof getSetCookie === "function" ? getSetCookie.call(response.headers) : [];
|
||||
if (cookies.length) headers["set-cookie"] = cookies;
|
||||
|
||||
res.writeHead(response.status, headers);
|
||||
|
||||
if (response.body) {
|
||||
const reader = response.body.getReader();
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
res.write(value);
|
||||
}
|
||||
} else {
|
||||
const buf = new Uint8Array(await response.arrayBuffer());
|
||||
if (buf.length) res.write(buf);
|
||||
}
|
||||
res.end();
|
||||
}
|
||||
|
||||
/** A `node:http` request listener that dispatches to a fetch handler. */
|
||||
export function nodeListener(handler: FetchHandler, opts: { origin?: string } = {}) {
|
||||
return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
||||
try {
|
||||
const response = await handler(await toRequest(req, opts));
|
||||
if (!response) {
|
||||
// A missing response means the handler expected a protocol upgrade
|
||||
// (e.g. a WebSocket), which this HTTP adapter does not perform.
|
||||
res.writeHead(426, { "content-type": "text/plain" });
|
||||
res.end("Upgrade Required");
|
||||
return;
|
||||
}
|
||||
await writeResponse(res, response);
|
||||
} catch (err) {
|
||||
if (!res.headersSent) res.writeHead(500, { "content-type": "text/plain" });
|
||||
res.end("Internal Server Error");
|
||||
console.error("[wrnexus] node adapter error:", err);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Create and start a `node:http` server for a fetch handler. */
|
||||
export async function serveNode(
|
||||
handler: FetchHandler,
|
||||
opts: { port?: number; hostname?: string } = {},
|
||||
): Promise<Server> {
|
||||
const { createServer } = await import("node:http");
|
||||
const server = createServer(nodeListener(handler, {}));
|
||||
const port = opts.port ?? 3000;
|
||||
server.listen(port, opts.hostname ?? "0.0.0.0");
|
||||
console.log(`WrNexus (node adapter) listening on http://localhost:${port}`);
|
||||
return server;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user