diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 00000000..0b125dd2 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,50 @@ +name: Quality + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + quality: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: editors/vscode/package-lock.json + - name: Install framework dependencies + run: bun install --frozen-lockfile + - name: Install editor dependencies + run: npm ci --prefix editors/vscode + - name: Typecheck + run: bun run typecheck + - name: Lint + run: bun run lint + - name: Formatting + run: bun run format:check + - name: Package and application tests + run: bun run test:all + - name: Package contracts + run: bun run check:public-api && bun run check:ui-visual && bun run audit:packages && bun run test:package-kits && bun run validate:staging + - name: Stage and test publishable packages + if: matrix.os == 'ubuntu-latest' + run: bun run stage:packages && bun run test:staged-consumers + - name: Framework validation + run: bun run validate:0.8 + - name: Dependency audit + run: bun audit + - name: Editor dependency audit + run: npm audit --prefix editors/vscode --audit-level=high diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..1e98846d --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +@wrnexus:registry=https://registry.npmjs.org/ +audit=true +fund=false diff --git a/.prettierignore b/.prettierignore index e6a6fd4d..50482156 100644 --- a/.prettierignore +++ b/.prettierignore @@ -10,9 +10,14 @@ bun.lockb # Generated code (queries.gen.ts, routes.gen.ts, etc.) **/*.gen.ts +**/*.generated.d.ts # Bundled .wire compiler for the VS Code extension (generated) editors/vscode/src/compiler.cjs +editors/vscode/src/language-server.cjs +editors/vscode/src/extension.bundle.cjs +docs/public-api-0.8.json +docs/ui-visual-contract-0.8.json *.svg **/.vscodeignore diff --git a/.publish/ai/README.md b/.publish/ai/README.md index 05bd4c2e..94a2cd1c 100644 --- a/.publish/ai/README.md +++ b/.publish/ai/README.md @@ -1,5 +1,9 @@ # @wrnexus/ai +Provider-neutral AI orchestration for OpenAI, Anthropic, Google and local OpenAI-compatible models, +with streaming, structured output, tools, embeddings, vector search/RAG, conversation persistence, +templates, guardrails, usage events, fallback, rate limits and evaluation reports. + > A tiny, zero-dependency Claude (Anthropic) client for WrNexus apps — generate and stream text with Claude from any server-side code. Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework. @@ -120,6 +124,34 @@ try { } ``` +### Multi-provider client + +`createAIClient` adds named-provider selection and fallback, capability discovery, +validated JSON output, validated tool execution, abort-aware exponential retries, +and per-provider circuit breakers. Attempt events intentionally contain metadata +only: prompts, credentials, and raw model responses are never passed to telemetry. + +```ts +import { anthropicProvider, createAIClient } from "@wrnexus/ai"; + +const ai = createAIClient({ + providers: [anthropicProvider()], + retry: { attempts: 3, baseDelayMs: 100, maxDelayMs: 2_000 }, + circuitBreaker: { failureThreshold: 5, resetAfterMs: 30_000 }, +}); + +const result = await ai.generateObject<{ title: string }>("Return a JSON title", { + validate: (value): value is { title: string } => + typeof value === "object" && value !== null && "title" in value, +}); +``` + +Providers can return normalized `usage` (`inputTokens`, `outputTokens`, +`totalTokens`, and `costUsd`) and `toolCalls`. Use `executeTools` with a named, +validated tool registry; unknown tools and invalid arguments are rejected before +application code runs. `deterministicAIProvider` supplies ordered or computed +offline responses for tests and examples without API keys or network calls. + ## Usage ### Return generated JSON from an API route diff --git a/.publish/ai/package.json b/.publish/ai/package.json index bb84f519..ef12a427 100644 --- a/.publish/ai/package.json +++ b/.publish/ai/package.json @@ -1,9 +1,25 @@ { "name": "@wrnexus/ai", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.", "license": "MIT", + "repository": { + "type": "git", + "url": "https://git.workroot.in/WorkRoot/WRNexusJS.git", + "directory": "packages/ai" + }, + "homepage": "https://wrnexusjs.dev/packages/ai", + "bugs": { + "url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues" + }, + "keywords": [ + "wrnexus", + "bun", + "typescript", + "ai" + ], + "sideEffects": false, "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -18,9 +34,14 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./platform": { + "types": "./dist/platform.d.ts", + "import": "./dist/platform.js" } }, "files": [ - "dist" + "dist", + "README.md" ] } diff --git a/.publish/authz/package.json b/.publish/authz/package.json index 71609734..4712cef3 100644 --- a/.publish/authz/package.json +++ b/.publish/authz/package.json @@ -1,9 +1,25 @@ { "name": "@wrnexus/authz", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "description": "@wrnexus/authz — part of the WrNexus framework.", "license": "MIT", + "repository": { + "type": "git", + "url": "https://git.workroot.in/WorkRoot/WRNexusJS.git", + "directory": "packages/authz" + }, + "homepage": "https://wrnexusjs.dev/packages/authz", + "bugs": { + "url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues" + }, + "keywords": [ + "wrnexus", + "bun", + "typescript", + "authz" + ], + "sideEffects": false, "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -21,6 +37,7 @@ } }, "files": [ - "dist" + "dist", + "README.md" ] } diff --git a/.publish/cli/README.md b/.publish/cli/README.md index 9b5cc3f0..591332fb 100644 --- a/.publish/cli/README.md +++ b/.publish/cli/README.md @@ -1,5 +1,18 @@ # @wrnexus/cli +Production parity commands: + +```bash +wrnexus build . +wrnexus preview . --port=3000 +wrnexus dev . --production-runtime +``` + +`preview` refuses to start without `dist/server.js` and executes that exact +artifact with the production profile. Production-runtime development rebuilds +the same minified artifact after app, public, or configuration changes and +keeps the last good server running when a rebuild fails. + > The `wrnexus` command-line tool that scaffolds, runs, builds, tests, and manages WrNexus apps. Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework. @@ -26,6 +39,30 @@ bunx wrnexus dev ## Commands +### Local production services + +`wrnexus dev . --services` starts the application and the bounded local database, +cache, mail, SMS, webhook, storage, queue, cron, authentication and metrics simulator. +It generates a localhost/`*.localhost` development certificate under +`.wrnexus/certificates/` and serves both the application and service console over HTTPS. +Trust that certificate locally to remove the browser warning. Use `--services-http` only +when an external development proxy already terminates TLS. + +### Exact production runtime with live updates + +`wrnexus dev . --production-runtime` rebuilds and executes `dist/server.js` with +production resolution, serialization, caching, headers and assets. The supervisor keeps +the last good process when a build fails. On a successful rebuild the opt-in production +HMR socket reconnects, requests the new document and morphs it into the browser; ordinary +`wrnexus preview` and deployed production servers never include that client. + +### API platform + +`wrnexus api generate [app-dir]` (or `api docs`) derives operations from file routes and +emits `generated/api/openapi.json`, safe static documentation, Postman collection, curl examples, +and TypeScript, JavaScript, Java, Go and Python SDKs. Generate one client with +`wrnexus sdk generate [app-dir]`. + Every command accepts an optional `[app-dir]` (defaults to `.`). Commands that read config or `.env` also accept `--profile=` (see [Profiles](#profiles)). | Command | Purpose | @@ -46,10 +83,17 @@ Every command accepts an optional `[app-dir]` (defaults to `.`). Commands that r | `wrnexus db ` | Database migrations and tooling (see [db](#wrnexus-db)). | | `wrnexus test [app-dir] [--watch]` | Run the app's tests via `bun test` (defaults to the `test` profile). | | `wrnexus profiles [app-dir]` | List config profiles and their `.env` files, marking the active one. | +| `wrnexus compatibility check` | Check whether behavior defaults are explicitly pinned and current. | +| `wrnexus compatibility explain` | Explain configured, effective, and current compatibility behavior. | +| `wrnexus compatibility upgrade` | Back up config and explicitly opt into reviewed current behavior. | | `wrnexus help` | Print usage. | `wrnexus g` is an alias for `wrnexus generate`. +Compatibility upgrades never happen implicitly. New applications pin +`compatibilityDate` and `frameworkBehaviour`; existing applications use +`wrnexus compatibility explain` before the backed-up, idempotent upgrade command. + ### `wrnexus dev` Supervises a child dev-server process (from `@wrnexus/dev-server`). The child owns file watching and HMR: CSS and client-island edits update the live page over a WebSocket with no restart; when a server module changes, the child exits with a restart code and the supervisor respawns it (the browser reconnects and morphs in the new HTML). On startup it regenerates typed DB queries and typed routes (best effort). Use `--port=` to change the port (default `3000`). @@ -76,7 +120,9 @@ bun dist/server.js # PORT env var optional ### `wrnexus create` -Scaffolds a new app from an inline (dependency-free) template — `package.json`, `.gitignore`, config, and starter `app/` files. Use `npm run dev` during development, `npm run build && npm start` for production, or `npm run production` to build and start in one command. The generated production server currently requires Bun even when npm is used to manage packages and scripts. +Scaffolds a complete v0.8 app from an inline template. The generated project includes strict TypeScript, ESLint and Prettier, editor recommendations, environment templates, database migrations, locales, schemas, tests, API/middleware/realtime examples, Tailwind and Iconify, PWA/mobile defaults, and the framework package kits. Its `wrnexus.config.ts` documents the current imports, types, stores, performance, observability, tenancy, build, navigation, theme, i18n, database, storage, realtime, security, and profile configuration. + +Use `bun run dev` during development, `bun run check` for the complete typecheck/lint/test/format gate, `bun run build && bun run start` for production, or `bun run production` to build and start in one command. ### `wrnexus update` @@ -162,7 +208,7 @@ wrnexus db status --db=analytics ### `wrnexus workspace` and `wrnexus gateway` -`workspace ` scaffolds a monorepo: several WrNexus apps under `apps/*` and shared libraries under `packages/*`, plus a `wrnexus.workspace.ts` that maps each app to the domains it serves. `gateway` runs every app behind one port and routes by `Host` header, with optional per-app auth and gateway-wide security (trusted hosts, rate limit, security headers, access log). +`workspace ` scaffolds a monorepo: complete v0.8 apps under `apps/*`, shared libraries under `packages/*`, root TypeScript/lint/format/editor/environment tooling, and a `wrnexus.workspace.ts` that maps each app to the domains it serves. `gateway` runs every app behind one port and routes by `Host` header, with optional per-app auth and gateway-wide security (trusted hosts, rate limit, security headers, access log). Newly added workspace apps use the same current scaffold. ```bash wrnexus workspace acme @@ -241,6 +287,12 @@ wrnexus update --latest wrnexus doctor ``` +Use `wrnexus doctor --fix [app-dir]` to apply conservative repairs before the +health check: create missing `app/pages` and a default config, align skewed +`@wrnexus/*` dependency ranges, record the current migration marker, and format +only syntax-valid `.wrn` files. Invalid JSON or WRN sources are reported/skipped +instead of overwritten; repeat runs are idempotent. + ## Profiles Pass `--profile=` 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.`, `.env..local`) into `process.env`. diff --git a/.publish/cli/package.json b/.publish/cli/package.json index 6ca2aa1b..04b4274a 100644 --- a/.publish/cli/package.json +++ b/.publish/cli/package.json @@ -1,9 +1,25 @@ { "name": "@wrnexus/cli", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "description": "@wrnexus/cli — part of the WrNexus framework.", "license": "MIT", + "repository": { + "type": "git", + "url": "https://git.workroot.in/WorkRoot/WRNexusJS.git", + "directory": "packages/cli" + }, + "homepage": "https://wrnexusjs.dev/packages/cli", + "bugs": { + "url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues" + }, + "keywords": [ + "wrnexus", + "bun", + "typescript", + "cli" + ], + "sideEffects": false, "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -28,20 +44,26 @@ "wrnexus": "./dist/index.js" }, "dependencies": { - "@wrnexus/core": "^0.7.0", - "@wrnexus/router": "^0.7.0", - "@wrnexus/csr": "^0.7.0", - "@wrnexus/compiler": "^0.7.0", - "@wrnexus/styles": "^0.7.0", - "@wrnexus/dev-server": "^0.7.0", - "@wrnexus/ui": "^0.7.0", - "@wrnexus/validation": "^0.7.0", - "@wrnexus/i18n": "^0.7.0", - "@wrnexus/db": "^0.7.0", - "@wrnexus/plugin": "^0.7.0", - "@wrnexus/syntax": "^0.7.0" + "@wrnexus/core": "^0.8.0", + "@wrnexus/router": "^0.8.0", + "@wrnexus/csr": "^0.8.0", + "@wrnexus/compiler": "^0.8.0", + "@wrnexus/styles": "^0.8.0", + "@wrnexus/dev-server": "^0.8.0", + "@wrnexus/ui": "^0.8.0", + "@wrnexus/validation": "^0.8.0", + "@wrnexus/i18n": "^0.8.0", + "@wrnexus/mcp": "^0.8.0", + "@wrnexus/playground": "^0.8.0", + "@wrnexus/db": "^0.8.0", + "@wrnexus/plugin": "^0.8.0", + "@wrnexus/syntax": "^0.8.0", + "@wrnexus/typecheck": "^0.8.0", + "@wrnexus/security": "^0.8.0", + "selfsigned": "^5.5.0" }, "files": [ - "dist" + "dist", + "README.md" ] } diff --git a/.publish/compiler/README.md b/.publish/compiler/README.md index 7baf52ee..4fec3363 100644 --- a/.publish/compiler/README.md +++ b/.publish/compiler/README.md @@ -1,9 +1,44 @@ # @wrnexus/compiler +## Partial-static rendering + +Pages can select `render = "partial-static"` and divide their view with `` and +`` boundaries. The compiler emits a build-only shell renderer that never evaluates +dynamic-boundary children. `wrnexus build` expands static component mounts into +`dist/partial-shells.json`, records byte/region evidence in `build-report.json`, and embeds +the shell in the production route manifest. At request time the production runtime retains +request-aware layouts, locale/theme metadata and security nonces while streaming dynamic +regions into stable placeholders. + > Compiler for the `.wrn` language — tokenizes, parses, and lowers `.wrn` page and component files to TypeScript. Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework. +Production adapters use `analyzeRuntimeImports` before bundling. Edge, worker, +service-worker, and browser targets reject Node filesystem, TCP, and process +modules with `WRN-RUNTIME-CAPABILITY`. Package manifests can declare supported +`wrnexus.runtimes` and required `wrnexus.requires` capabilities; discovery fails +when the selected deployment cannot satisfy them. + +## Server actions + +```wrn +action createUser using CreateUserSchema { + const user = await users.create(input) + invalidate("users") + return user +} + +view { +
...
+} +``` + +The compiler produces a schema-aware server registry, a fully inferred action +client, and progressively enhanced form metadata. The shared runtime performs +validation, authentication/permission checks, CSRF verification, serialization, +invalidation reporting, and browser lifecycle events. + ## Overview `@wrnexus/compiler` turns `.wrn` source into TypeScript that targets the framework's runtime primitives. A `.wrn` file declares either a `page` (a route) or a `component` (a reusable, prop-driven fragment) with blocks for `state`, `view` (plain HTML), `seo`, `style`, `functions`, `api`, `ssr`/`client` data bindings, and `realtime` websocket handlers. The pipeline is `source → Lexer → parse() → PageAst → generate() → TypeScript`. It is a build/server-side library — the WrNexus dev loader calls it to compile `.wrn` files on the fly, surfacing `ParseError` as a readable error page. diff --git a/.publish/compiler/package.json b/.publish/compiler/package.json index 362f4be3..8be5a863 100644 --- a/.publish/compiler/package.json +++ b/.publish/compiler/package.json @@ -1,9 +1,25 @@ { "name": "@wrnexus/compiler", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "description": "@wrnexus/compiler — part of the WrNexus framework.", "license": "MIT", + "repository": { + "type": "git", + "url": "https://git.workroot.in/WorkRoot/WRNexusJS.git", + "directory": "packages/compiler" + }, + "homepage": "https://wrnexusjs.dev/packages/compiler", + "bugs": { + "url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues" + }, + "keywords": [ + "wrnexus", + "bun", + "typescript", + "compiler" + ], + "sideEffects": false, "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -21,10 +37,13 @@ } }, "dependencies": { - "@wrnexus/syntax": "^0.7.0", - "@wrnexus/store": "^0.7.0" + "@wrnexus/csr": "^0.8.0", + "@wrnexus/syntax": "^0.8.0", + "@wrnexus/store": "^0.8.0", + "@wrnexus/validation": "^0.8.0" }, "files": [ - "dist" + "dist", + "README.md" ] } diff --git a/.publish/core/README.md b/.publish/core/README.md index e338b6b5..0a08481e 100644 --- a/.publish/core/README.md +++ b/.publish/core/README.md @@ -110,6 +110,37 @@ instances. The default store is process-local memory. (default `console.log`), `requestIdKey` (default `"requestId"`), `now`. `RequestRecord` = `{ time, id, method, path, status, durationMs }`. +### Resilience — `@wrnexus/core` + +`resilientCall` standardizes cancellation-aware timeouts, controlled retries, +fixed or exponential backoff, fallback responses, circuit breaking, and bounded +concurrency. Reuse a declarative circuit/bulkhead options object, or an explicit +`CircuitBreaker`/`Bulkhead` instance, wherever calls must share health and +capacity state. + +```ts +import { resilientCall } from "@wrnexus/core"; + +const paymentCircuit = { failures: 5, resetAfter: "30s" } as const; + +const status = await resilientCall({ + timeout: "5s", + retries: 3, + retryDelay: "100ms", + backoff: "exponential", + circuitBreaker: paymentCircuit, + bulkhead: { concurrency: 20, queue: 100 }, + run: (signal) => paymentProvider.checkStatus({ signal }), + fallback: () => ({ state: "unavailable" }), +}); +``` + +`CircuitBreaker.snapshot()` reports `closed`, `open`, or `half-open`, failure +and success counts, and the remaining retry delay for health endpoints and +development tooling. Fail-fast conditions use stable `WRN-RESILIENCE-*` codes. +Core's existing `HealthRegistry`, `withIdempotency`, and pluggable stores/locks +cover health reporting, idempotent requests, and distributed coordination. + ### Caching — `@wrnexus/core` | Export | Kind | Notes | diff --git a/.publish/core/package.json b/.publish/core/package.json index b65f36b9..591bb4db 100644 --- a/.publish/core/package.json +++ b/.publish/core/package.json @@ -1,9 +1,25 @@ { "name": "@wrnexus/core", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "description": "@wrnexus/core — part of the WrNexus framework.", "license": "MIT", + "repository": { + "type": "git", + "url": "https://git.workroot.in/WorkRoot/WRNexusJS.git", + "directory": "packages/core" + }, + "homepage": "https://wrnexusjs.dev/packages/core", + "bugs": { + "url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues" + }, + "keywords": [ + "wrnexus", + "bun", + "typescript", + "core" + ], + "sideEffects": false, "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -29,6 +45,7 @@ } }, "files": [ - "dist" + "dist", + "README.md" ] } diff --git a/.publish/csr/README.md b/.publish/csr/README.md index cb42e418..7a0288eb 100644 --- a/.publish/csr/README.md +++ b/.publish/csr/README.md @@ -1,5 +1,34 @@ # @wrnexus/csr +## Navigation state preservation + +Pages can opt into restoration across client navigation: + +```wrn +page Users { + navigation { + preserve = ["filters", "pagination", "scroll", "tabs", "expanded"] + } +} +``` + +Form-like categories restore named inputs, selects, and textareas. Password, +file, hidden, CSRF/token/secret/credential fields, and elements marked +`data-no-preserve` are never saved. For tab, expanded, or component UI state, +mark stable elements with `data-wrn-preserve="key"`; their value and ARIA +selected/expanded state are restored. State is scoped to pathname plus query. + +## Typed server actions + +`createActionClient(route, name)` supports programmatic calls. +Schema-backed WRN actions also export `__wrnexusActionClients`, whose input and +output are inferred automatically. Enhanced forms expose +`data-wrn-action-state="pending|success|error"` and dispatch bubbling +`wrnexus:action:optimistic`, `:pending`, `:success`, and `:error` events. +Success details contain returned data and invalidated cache tags; error details +contain field errors. Without JavaScript, the same form posts to its page and +receives a 303 redirect or accessible validation response. + > 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. @@ -49,16 +78,21 @@ getRealtimeRuntime(): string // → REALTIME_RUNTIME 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-="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. index and `key item.id`) | Per-item rendering; stable keys preserve DOM identity during reorder | -| `data-key="item.id"` | Alternative key declaration for `data-for` templates | -| `{{expr}}` or `{expr}` | Interpolation inside text nodes and attribute values | -| `data-wrnexus-csr="id"` | Target for a generated CSR fetch binding (fetches `/__wrnexus/csr?...`) | +| Directive | Purpose | +| ---------------------------------- | ---------------------------------------------------- | +| `data-scope="count: 0, name: 'x'"` | Declare reactive state on a subtree | +| `data-on-="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 while preserving interactive state | + +Compiled conditional rendering and dynamic component cases omit inactive elements from the live +DOM. `data-show` is a visibility directive for stateful controls and keeps its element mounted. +Neither mechanism is authorization: never place secrets in client-rendered branches. Authorize on +the server and return only data the current request may access. +| `data-for="item in list"` (opt. index and `key item.id`) | Per-item rendering; stable keys preserve DOM identity during reorder | +| `data-key="item.id"` | Alternative key declaration for `data-for` templates | +| `{{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. diff --git a/.publish/csr/package.json b/.publish/csr/package.json index 47d4498c..ad0293f2 100644 --- a/.publish/csr/package.json +++ b/.publish/csr/package.json @@ -1,9 +1,25 @@ { "name": "@wrnexus/csr", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "description": "@wrnexus/csr — part of the WrNexus framework.", "license": "MIT", + "repository": { + "type": "git", + "url": "https://git.workroot.in/WorkRoot/WRNexusJS.git", + "directory": "packages/csr" + }, + "homepage": "https://wrnexusjs.dev/packages/csr", + "bugs": { + "url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues" + }, + "keywords": [ + "wrnexus", + "bun", + "typescript", + "csr" + ], + "sideEffects": false, "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -21,9 +37,10 @@ } }, "dependencies": { - "@wrnexus/core": "^0.7.0" + "@wrnexus/core": "^0.8.0" }, "files": [ - "dist" + "dist", + "README.md" ] } diff --git a/.publish/db/README.md b/.publish/db/README.md index 0d4495ff..ee7f906b 100644 --- a/.publish/db/README.md +++ b/.publish/db/README.md @@ -1,5 +1,14 @@ # @wrnexus/db +## Rollout-safe migrations + +Run `wrnexus db check` in CI before deployment. The analyzer reports stable +diagnostics for drops, renames, type changes, new/enforced required columns, +and potentially blocking index creation, with an expand/backfill/switch/contract +recommendation. `wrnexus db migrate` blocks critical issues in pending +migrations. `--allow-breaking` is an explicit operator override; already-applied +migrations do not block later releases. + > 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. @@ -85,7 +94,8 @@ A `Driver` (implemented by adapters) exposes `dialect`, `query(sql, params?)`, - `exec(sql, params?)` — `Promise` (`{ 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()`. +- `close()` — idempotently rejects new top-level work, drains active queries and + transactions, then closes the underlying pool. Every query is parameterized (positional params). `createTableSql(model, dialect, ifNotExists?)` renders `CREATE TABLE` directly; `Dialect` is `"sqlite" | "postgres" | "mysql"`. @@ -98,7 +108,9 @@ A process-wide registry the runtime configures at startup from `wrnexus.config.t - `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()`. +- `hasDb(name?)`, `databaseNames()`, `closeDatabases()`. Registry shutdown clears + registrations first, attempts every open database, and reports close failures + together with `AggregateError` instead of leaking later pools. ```ts const users = await getDb().all("SELECT * FROM users"); @@ -121,8 +133,8 @@ Migrations are `.sql` files (in e.g. `app/db/migrations`), each split into - `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`. +- `migrate(db, dir, options?)` — apply all pending (each in a transaction); returns applied names. +- `rollback(db, dir, options?)` — 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. @@ -199,6 +211,21 @@ const pageTwo = await paginate( ); ``` +For deployments, `{ dryRun: true }` reports pending names without applying +their SQL, `signal` cancels safely between migrations, and the default +database-backed lock prevents concurrent deploy runners. A live lock produces +`WRN-DB-MIGRATION-LOCKED`; crash-stale locks expire after `lockTimeoutMs` (five +minutes by default). Disable it with `lock: false` only when an external deploy +coordinator already guarantees exclusivity. + +```ts +const pending = await migrate(db, "app/db/migrations", { dryRun: true }); +await migrate(db, "app/db/migrations", { + signal: shutdownController.signal, + lockTimeoutMs: 10 * 60_000, +}); +``` + MongoDB (document API): ```ts @@ -226,3 +253,47 @@ SQL driver — use `@wrnexus/db/mongo` directly. `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. + +## Repository and transaction helpers + +Repositories accept an immutable equality `scope`, normally `{ column: "tenant_id", value: +ctx.tenant.id }`. The scope is injected into every read, count, update and delete, while create +overwrites any caller-supplied tenant value. This makes accidental cross-tenant CRUD through the +repository API fail closed. + +```ts +import { createRepository, retryTransaction, databaseHealth, batch } from "@wrnexus/db"; + +const users = createRepository(db, { + table: "users", + allowedColumns: ["email", "name", "active"], +}); + +const user = await users.require(42); +await users.update(42, { active: true }); +``` + +Repository identifiers are validated, writes may be restricted to an allowlist, and values always use query parameters. Infrastructure packages remain helper-only and do not add UI dependencies to server code. + +## 0.8 repository and transaction helpers + +```ts +import { createRepository, databaseHealth, firstOrThrow, retryTransaction } from "@wrnexus/db"; + +const usersRepo = createRepository(db, { + table: "users", + allowedColumns: ["email", "name", "active"], + maxListLimit: 250, +}); + +const users = await usersRepo.all({ + orderBy: "name", + direction: "asc", + limit: 50, + offset: 0, +}); +``` + +Repository SQL identifiers are validated and values remain parameterized. Placeholder generation is dialect-aware: PostgreSQL uses `$1`, `$2`, and SQLite/MySQL use `?`. List limits are bounded. + +`retryTransaction()` retries recognized serialization, deadlock, and database-lock errors by default. Supply `shouldRetry` for application-specific retryable errors; ordinary validation or business errors are not retried automatically. diff --git a/.publish/db/package.json b/.publish/db/package.json index bbd44a41..b3ffcd4a 100644 --- a/.publish/db/package.json +++ b/.publish/db/package.json @@ -1,9 +1,25 @@ { "name": "@wrnexus/db", - "version": "0.7.0", + "version": "0.8.0", "type": "module", - "description": "@wrnexus/db — part of the WrNexus framework.", + "description": "Typed database drivers, migrations, instrumentation, repositories, pagination, and transaction helpers.", "license": "MIT", + "repository": { + "type": "git", + "url": "https://git.workroot.in/WorkRoot/WRNexusJS.git", + "directory": "packages/db" + }, + "homepage": "https://wrnexusjs.dev/packages/db", + "bugs": { + "url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues" + }, + "keywords": [ + "wrnexus", + "bun", + "typescript", + "db" + ], + "sideEffects": false, "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -45,6 +61,7 @@ } }, "files": [ - "dist" + "dist", + "README.md" ] } diff --git a/.publish/dev-server/README.md b/.publish/dev-server/README.md index 7d910ce3..9159b79e 100644 --- a/.publish/dev-server/README.md +++ b/.publish/dev-server/README.md @@ -66,6 +66,12 @@ interface RunningServer { In development, `startServer` also connects `app/db/migrations` (and `app/db//migrations`) and auto-applies migrations, then starts an in-process file watcher. CSS edits hot-swap live. Page, component, layout, API, middleware, realtime, schema, locale, and public-asset edits invalidate only their cached modules, rescan routes where necessary, and morph fresh HTML through the existing HMR WebSocket. The server process and active gateway stay running. +`getWrnCompileMetrics()` exposes cumulative content-addressed compiler cache +`hits`, `misses`, successful `compilations`, `errors`, `totalDurationMs`, and +`lastDurationMs` for the DevToolbar or custom diagnostics. Tests and embedded +servers can call `resetWrnCompileMetrics()` to establish a fresh measurement +window. + ### `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). @@ -296,7 +302,9 @@ Pages get only the scripts they use: `nav.js` always, `reactive.js` when a page - **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. +- `.wrn` files compile into a content-addressed hidden `.wrnexus/` cache. Targeted + invalidation gives changed modules a fresh import identity without restarting + the development server. - 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`. diff --git a/.publish/dev-server/package.json b/.publish/dev-server/package.json index 87ebf0b1..938ee393 100644 --- a/.publish/dev-server/package.json +++ b/.publish/dev-server/package.json @@ -1,9 +1,25 @@ { "name": "@wrnexus/dev-server", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "description": "@wrnexus/dev-server — part of the WrNexus framework.", "license": "MIT", + "repository": { + "type": "git", + "url": "https://git.workroot.in/WorkRoot/WRNexusJS.git", + "directory": "packages/dev-server" + }, + "homepage": "https://wrnexusjs.dev/packages/dev-server", + "bugs": { + "url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues" + }, + "keywords": [ + "wrnexus", + "bun", + "typescript", + "dev-server" + ], + "sideEffects": false, "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -25,25 +41,28 @@ } }, "dependencies": { - "@wrnexus/core": "^0.7.0", - "@wrnexus/dev-toolbar": "^0.7.0", - "@wrnexus/router": "^0.7.0", - "@wrnexus/ssr": "^0.7.0", - "@wrnexus/csr": "^0.7.0", - "@wrnexus/compiler": "^0.7.0", - "@wrnexus/styles": "^0.7.0", - "@wrnexus/ui": "^0.7.0", - "@wrnexus/validation": "^0.7.0", - "@wrnexus/i18n": "^0.7.0", - "@wrnexus/db": "^0.7.0", - "@wrnexus/pubsub": "^0.7.0", - "@wrnexus/uploader": "^0.7.0", - "@wrnexus/plugin": "^0.7.0", - "@wrnexus/store": "^0.7.0", - "@wrnexus/security": "^0.7.0", - "@wrnexus/observability": "^0.7.0" + "@wrnexus/core": "^0.8.0", + "@wrnexus/dev-toolbar": "^0.8.0", + "@wrnexus/router": "^0.8.0", + "@wrnexus/ssr": "^0.8.0", + "@wrnexus/csr": "^0.8.0", + "@wrnexus/compiler": "^0.8.0", + "@wrnexus/styles": "^0.8.0", + "@wrnexus/ui": "^0.8.0", + "@wrnexus/validation": "^0.8.0", + "@wrnexus/i18n": "^0.8.0", + "@wrnexus/db": "^0.8.0", + "@wrnexus/pubsub": "^0.8.0", + "@wrnexus/uploader": "^0.8.0", + "@wrnexus/plugin": "^0.8.0", + "@wrnexus/store": "^0.8.0", + "@wrnexus/security": "^0.8.0", + "@wrnexus/observability": "^0.8.0", + "@wrnexus/cache": "^0.8.0", + "@wrnexus/pwa": "^0.8.0" }, "files": [ - "dist" + "dist", + "README.md" ] } diff --git a/.publish/dev-toolbar/README.md b/.publish/dev-toolbar/README.md index 7b07b04c..76e4164c 100644 --- a/.publish/dev-toolbar/README.md +++ b/.publish/dev-toolbar/README.md @@ -7,6 +7,9 @@ Development-only page quality toolbar for WRNexusJS. - Runtime, resource and unhandled promise error capture - Accessibility, SEO, image, media, color, HTML, form, link, responsive and security checks - Performance and network observations +- First-class application tabs for runtime, stores, cache, accessibility, SEO, performance, + security, images, links, and JavaScript +- Plugin-contributed applications with badges, descriptions, issue feeds, and structured data - Element highlighting and issue filtering - Server-side issue collector - Development-only asset strings for direct serving by `@wrnexus/dev-server` @@ -21,3 +24,7 @@ Serve `DEV_TOOLBAR_RUNTIME` at `/__wrnexus/dev-toolbar.js` and `DEV_TOOLBAR_CSS` ``` The browser runtime exposes `window.__wrnexusDevToolbar`. + +Plugin panels returned through `devToolbarPanels()` are automatically added to the application +strip. Their issue category is filterable, and structured `data` is rendered as escaped diagnostic +content so a plugin never needs to inject toolbar HTML. diff --git a/.publish/dev-toolbar/package.json b/.publish/dev-toolbar/package.json index 2a9f5e30..0fa593ef 100644 --- a/.publish/dev-toolbar/package.json +++ b/.publish/dev-toolbar/package.json @@ -1,9 +1,25 @@ { "name": "@wrnexus/dev-toolbar", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "description": "@wrnexus/dev-toolbar — part of the WrNexus framework.", "license": "MIT", + "repository": { + "type": "git", + "url": "https://git.workroot.in/WorkRoot/WRNexusJS.git", + "directory": "packages/dev-toolbar" + }, + "homepage": "https://wrnexusjs.dev/packages/dev-toolbar", + "bugs": { + "url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues" + }, + "keywords": [ + "wrnexus", + "bun", + "typescript", + "dev-toolbar" + ], + "sideEffects": false, "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -37,6 +53,7 @@ } }, "files": [ - "dist" + "dist", + "README.md" ] } diff --git a/.publish/encryption/README.md b/.publish/encryption/README.md index c4bd707f..e58e9196 100644 --- a/.publish/encryption/README.md +++ b/.publish/encryption/README.md @@ -1,80 +1,67 @@ # @wrnexus/encryption -> Dependency-free crypto helpers for WrNexus: authenticated symmetric encryption (AES-256-GCM), hashing, and HMAC signing. +Authenticated encryption, hashing, HMAC, key rotation, and optional encrypted HTTP exchanges for WRNexusJS. -Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework. +## Core helpers -## Overview +- `generateKey()` — random 256-bit AES key encoded as base64. +- `deriveKey(password, salt)` — PBKDF2-derived AES key. +- `encrypt(plaintext, key)` / `decrypt(payload, key)` — AES-256-GCM. +- `sha256(data)` — SHA-256 digest. +- `hmacSign(data, secret)` / `hmacVerify(...)` — HMAC-SHA256. +- `createKeyring(keys)` — active/previous key management. +- `seal()` / `open()` — versioned ciphertext with key ID. -This package provides small, focused cryptographic primitives for server-side use: encrypting secrets/tokens/database fields at rest with AES-256-GCM, deriving keys from passwords via PBKDF2, computing SHA-256 digests, and signing/verifying payloads with HMAC-SHA256. It is built entirely on the standard **Web Crypto API** (`crypto.subtle`) plus `btoa`/`atob` and `TextEncoder`/`TextDecoder` — no third-party dependencies. Reach for it whenever you need to protect sensitive values or verify webhook signatures. All functions are `async` (Web Crypto is promise-based). - -## Installation - -```bash -bun add @wrnexus/encryption -``` - -> 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 keys are exchanged as **base64 strings** and all digests/signatures as **hex strings**. - -| Export | Signature | Description | -| ------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | -| `generateKey` | `() => Promise` | Generate a random 256-bit AES key, base64-encoded. Store it as a secret. | -| `deriveKey` | `(password: string, salt: string) => Promise` | Derive a base64 AES-256 key from a password + salt using PBKDF2 (100,000 iterations, SHA-256). | -| `encrypt` | `(plaintext: string, key: string) => Promise` | AES-256-GCM encrypt a string. Returns base64 of `iv(12 bytes) ‖ ciphertext+tag`. A fresh random IV is used each call. | -| `decrypt` | `(payload: string, key: string) => Promise` | Decrypt a value produced by `encrypt`. Throws if the key is wrong or the data was tampered with. | -| `sha256` | `(data: string) => Promise` | SHA-256 hex digest of a string (e.g. content hashing, dedup keys). | -| `hmacSign` | `(data: string, secret: string) => Promise` | HMAC-SHA256 hex signature of `data` with `secret` (e.g. signing webhooks). | -| `hmacVerify` | `(data: string, secret: string, signature: string) => Promise` | Constant-time verify of an HMAC-SHA256 hex signature. | - -Notes: - -- `generateKey` produces a 32-byte (256-bit) key via `crypto.getRandomValues`. -- `encrypt`/`decrypt` require a base64-encoded 256-bit key; anything else throws `"Encryption key must be a base64 256-bit key"`. -- `decrypt` throws `"Invalid ciphertext"` if the payload is shorter than the 12-byte IV, and the underlying Web Crypto call throws on any authentication (tag) mismatch. -- `hmacVerify` compares in constant time (length check plus XOR accumulation) to avoid timing leaks. - -## Usage - -Symmetric encryption of a secret at rest: +## Encrypted HTTP envelope ```ts -import { generateKey, encrypt, decrypt } from "@wrnexus/encryption"; +import { + createEncryptedRequest, + createKeyring, + createMemoryReplayStore, + decryptEncryptedResponse, + encryptedExchange, +} from "@wrnexus/encryption"; -const key = await generateKey(); // store this safely (env/secret manager) +const keyring = createKeyring([{ id: "2026-08", secret: process.env.API_BODY_KEY!, active: true }]); -const box = await encrypt("card #1234", key); // opaque base64 string, safe to persist -const plain = await decrypt(box, key); // "card #1234" +const replayStore = createMemoryReplayStore(); + +// Server middleware. +app.use( + encryptedExchange({ + keyring, + replayStore, + maxAgeMs: 60_000, + maxBodyBytes: 1_048_576, + }), +); + +// Controlled service/native client. +const request = await createEncryptedRequest( + "https://api.example.com/private/report", + { reportId: "report-1" }, + { method: "POST", keyring }, +); +const response = await fetch(request); +const result = await decryptEncryptedResponse(response, request, { keyring }); ``` -Deriving a key from a user password instead of a random key: +The envelope binds authenticated ciphertext to: -```ts -import { deriveKey, encrypt } from "@wrnexus/encryption"; +- HTTP method +- URL path and query +- request ID +- timestamp and expiry window +- encryption key ID +- optional replay-store consumption -const key = await deriveKey("correct horse battery staple", "per-user-salt"); -const box = await encrypt("secret note", key); -``` +`encryptedBody()` decrypts request bodies only. `encryptedExchange()` also encrypts successful downstream responses while allowing application exceptions to propagate normally. `encryptedFetch()` provides a convenient controlled-client call. -Hashing and webhook signature verification: +## Security boundary -```ts -import { sha256, hmacSign, hmacVerify } from "@wrnexus/encryption"; +Encrypted HTTP bodies **do not replace TLS/HTTPS**. Always use HTTPS. -const digest = await sha256("some content"); // 64-char hex string +This layer is appropriate for service-to-service traffic, native/mobile applications, controlled agents, and selected fields protected with server-managed keys. It cannot conceal data from an end user when browser JavaScript receives the decryption key. Never ship a long-lived server encryption key to a browser. -const signature = await hmacSign(rawBody, webhookSecret); -const ok = await hmacVerify(rawBody, webhookSecret, incomingSignatureHeader); -if (!ok) throw new Error("Invalid webhook signature"); -``` - -## Requirements / Notes - -- **Bun-only.** Relies on the Web Crypto API (`crypto.subtle`, `crypto.getRandomValues`) and the global `btoa`/`atob`, `TextEncoder`/`TextDecoder` — all available in Bun's runtime. -- **No dependencies.** The package has an empty dependency set; nothing is bundled beyond standard runtime APIs. -- Algorithms: AES-256-GCM (encryption), PBKDF2 with 100k SHA-256 iterations (key derivation), SHA-256 (digest), HMAC-SHA256 (signing). -- Keep generated/derived keys and HMAC secrets out of source control; treat them as first-class secrets. +Use a shared replay store such as Redis in multi-instance deployments. The memory replay store is process-local. diff --git a/.publish/encryption/package.json b/.publish/encryption/package.json index 895cedb4..1ac434e4 100644 --- a/.publish/encryption/package.json +++ b/.publish/encryption/package.json @@ -1,9 +1,25 @@ { "name": "@wrnexus/encryption", - "version": "0.7.0", + "version": "0.8.0", "type": "module", - "description": "@wrnexus/encryption — part of the WrNexus framework.", + "description": "Authenticated encryption, key rotation, hashing, and optional application-layer encrypted HTTP envelopes.", "license": "MIT", + "repository": { + "type": "git", + "url": "https://git.workroot.in/WorkRoot/WRNexusJS.git", + "directory": "packages/encryption" + }, + "homepage": "https://wrnexusjs.dev/packages/encryption", + "bugs": { + "url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues" + }, + "keywords": [ + "wrnexus", + "bun", + "typescript", + "encryption" + ], + "sideEffects": false, "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -20,7 +36,11 @@ "import": "./dist/index.js" } }, + "dependencies": { + "@wrnexus/core": "^0.8.0" + }, "files": [ - "dist" + "dist", + "README.md" ] } diff --git a/.publish/helpers/package.json b/.publish/helpers/package.json index 46c0349f..1b06f04c 100644 --- a/.publish/helpers/package.json +++ b/.publish/helpers/package.json @@ -1,9 +1,25 @@ { "name": "@wrnexus/helpers", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "description": "Safe convenience helpers for WrNexus request contexts and common application flows.", "license": "MIT", + "repository": { + "type": "git", + "url": "https://git.workroot.in/WorkRoot/WRNexusJS.git", + "directory": "packages/helpers" + }, + "homepage": "https://wrnexusjs.dev/packages/helpers", + "bugs": { + "url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues" + }, + "keywords": [ + "wrnexus", + "bun", + "typescript", + "helpers" + ], + "sideEffects": false, "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -21,9 +37,10 @@ } }, "dependencies": { - "@wrnexus/core": "^0.7.0" + "@wrnexus/core": "^0.8.0" }, "files": [ - "dist" + "dist", + "README.md" ] } diff --git a/.publish/i18n/README.md b/.publish/i18n/README.md index afb8e863..e173a22e 100644 --- a/.publish/i18n/README.md +++ b/.publish/i18n/README.md @@ -1,167 +1,93 @@ # @wrnexus/i18n -> Per-request translations plus locale-aware number, date, and currency formatting for WrNexus apps. +Recursive locale loading, fallback resolution, SSR/browser translations, locale formatting, and language UI blocks for WRNexusJS. -Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework. +## Locale files -## Overview +Both layouts can be used together: -`@wrnexus/i18n` loads locale files from `app/locales/.json`, resolves the -active language for each request (cookie → `Accept-Language` → default), and -builds a `t(key, params)` translator used both in server code and in `.wrn` -views. It also ships Intl-based formatting helpers and a tiny client runtime that -wires up a language switcher. Translation lookup, language resolution, and HTML -marker rewriting run server-side; only the small `I18N_RUNTIME` snippet runs in -the browser. - -## Installation - -```bash -bun add @wrnexus/i18n +```text +app/locales/en.json +app/locales/en/common.json +app/locales/en/auth.json +app/locales/mr/common.json ``` -> Private package — the machine must be authenticated to the `wrnexus` npm org -> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported). - -## API - -### Loading & resolving - -| Export | Signature | Description | -| ------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -| `loadLocales` | `(dir: string) => Record` | Reads every `.json` in `dir` into a `{ lang: messages }` map. Missing dir → `{}`; a bad file is warned and skipped. | -| `resolveI18n` | `(messages: Record, config?: I18nConfig) => ResolvedI18n` | Merges loaded messages + config into a resolved bundle (default lang, supported langs, messages). | -| `resolveLang` | `(i18n: ResolvedI18n, cookieValue: string \| undefined, acceptLanguage: string \| null) => string` | Picks the active language: matching cookie → best `Accept-Language` tag (falls back to base tag, e.g. `en-US` → `en`) → `i18n.default`. | -| `makeT` | `(i18n: ResolvedI18n, lang: string) => TFunction` | Builds a translator resolving current language → default → the key itself, with `{param}` interpolation. | - -### Types & constants - -| Export | Kind | Notes | -| -------------- | ----------- | ------------------------------------------------------------------------------ | -| `Messages` | `type` | `Record` — a locale's messages (supports nested/dotted keys). | -| `I18nConfig` | `interface` | `{ default?: string; locales?: string[] }`. | -| `ResolvedI18n` | `interface` | `{ default: string; langs: string[]; messages: Record }`. | -| `LANG_COOKIE` | `const` | `"wire-lang"` — the cookie the language is read from / written to. | -| `I18N_JS_HREF` | `const` | `"/__wrnexus/i18n.js"` — URL the client runtime is served at. | - -### HTML & client runtime - -| Export | Signature | Description | -| ---------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `translateHtml` | `(html: string, t: TFunction) => string` | Rewrites markers in rendered HTML: `t:="key"` → `=""` (attribute-escaped) and `` → element text becomes the translation (HTML-escaped). No-op unless a marker is present. | -| `renderI18nData` | `(i18n: ResolvedI18n, lang: string) => string` | JS snippet setting `window.__wireI18n = { lang, langs, default }` for the client switcher. | -| `I18N_RUNTIME` | `const string` | Browser IIFE that binds `[data-wire-lang-set="es"]` clicks and `select[data-wire-lang]` changes to set the `wire-lang` cookie and reload. Exposes `window.__wireLang.set(lang)`. | - -### Formatting helpers (re-exported from `./format.ts`) - -| Export | Signature | Example | -| -------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------- | -| `formatNumber` | `(value: number, lang: string, options?: Intl.NumberFormatOptions) => string` | `1234.5 → "1,234.5"` | -| `formatCurrency` | `(value: number, currency: string, lang: string) => string` | `9.99, "USD" → "$9.99"` | -| `formatDate` | `(value: Date \| number \| string, lang: string, options?: Intl.DateTimeFormatOptions) => string` | defaults to `{ dateStyle: "medium" }` | -| `formatRelativeTime` | `(value: number, unit: Intl.RelativeTimeFormatUnit, lang: string) => string` | `-3, "day" → "3 days ago"` (`numeric: "auto"`) | -| `plural` | `(count: number, forms: Partial>, lang: string) => string` | picks CLDR form; `#` is replaced by `count` | - -## Usage - -### Server: load, resolve, translate +Namespaced files become keys such as `common.save` and `auth.signIn`. ```ts -import { - loadLocales, - resolveI18n, - resolveLang, - makeT, - translateHtml, - LANG_COOKIE, -} from "@wrnexus/i18n"; +import { loadLocales, makeT, resolveI18n, resolveLang } from "@wrnexus/i18n"; -// app/locales/en.json, app/locales/es.json -const messages = loadLocales("app/locales"); -const i18n = resolveI18n(messages, { default: "en", locales: ["en", "es"] }); +const i18n = resolveI18n(loadLocales("app/locales", { strict: true }), { + default: "en", + locales: ["en", "mr", "hi"], + fallbacks: { "mr-IN": ["mr", "en"] }, + cookie: { name: "wire-lang", sameSite: "Lax", secure: true }, +}); -// Per request: -const lang = resolveLang(i18n, req.cookies?.[LANG_COOKIE], req.headers.get("accept-language")); +const lang = resolveLang(i18n, cookieValue, request.headers.get("accept-language")); const t = makeT(i18n, lang); - -t("nav.home"); // dotted key → "Home" -t("greeting", { name: "Ada" }); // "Hello, {name}" → "Hello, Ada" - -// After rendering a .wrn view, resolve translation markers in the HTML: -const finalHtml = translateHtml(renderedHtml, t); +t("common.hello", { name: "Ajay" }); ``` -`app/locales/en.json`: +## Resolution behavior -```json -{ - "nav": { "home": "Home" }, - "greeting": "Hello, {name}" -} -``` +- normalized BCP-47-style locale names +- cookie preference +- weighted `Accept-Language` +- wildcard language ranges +- regional base fallback +- explicit fallback chains +- configured default language +- automatic RTL for Arabic, Hebrew, Persian, Urdu, and related languages -### Views: translation markers +Locale JSON is size-limited and rejects prototype-pollution keys. Recursive namespace collisions are resolved safely. + +## Views and runtime ```html -

Home

+

Dashboard

``` -`translateHtml` replaces the element text for `data-t` and the attribute value for -any `t:` (e.g. `t:placeholder`, `t:aria-label`). +Text and translated attributes are resolved during SSR. Active/fallback messages are serialized safely for the language runtime, which rebinds `data-t` markers after client navigation. -### Client: language switcher +Enable `i18nPlugin()` to use: -```ts -import { renderI18nData, I18N_RUNTIME, I18N_JS_HREF } from "@wrnexus/i18n"; +- `` +- `` -// In the document : -const head = ` - - -`; +`LanguageSwitcher` renders a native `select[data-wire-lang]`. The packaged runtime validates the +selection against the configured locales, writes the configured language cookie, updates the +document `lang`/`dir` attributes, emits `wrnexus:language-change`, and reloads so the next SSR +request uses the same cookie. No application-owned browser script is required. -// Serve I18N_RUNTIME at I18N_JS_HREF; then in markup: -// -// -``` +## Formatting -### Formatting +- `formatNumber` +- `formatCurrency` +- `formatDate` +- `formatRelativeTime` +- `plural` +- `createLocaleFormatter` +- `translationCoverage` + Localization tooling can extract statically discoverable `t("key")`, + `i18n.t("key")`, and `data-i18n="key"` usage, compare every locale with a + reference, and create layout-stressing pseudo-locales: ```ts import { - formatNumber, - formatCurrency, - formatDate, - formatRelativeTime, - plural, + auditLocaleKeys, + createPseudoLocale, + extractTranslationKeysFromFiles, } from "@wrnexus/i18n"; -formatNumber(1234.5, lang); // "1,234.5" -formatCurrency(9.99, "USD", lang); // "$9.99" -formatDate(Date.now(), lang); // "Jul 4, 2026" -formatRelativeTime(-3, "day", lang); // "3 days ago" -plural(2, { one: "# item", other: "# items" }, lang); // "2 items" +const used = extractTranslationKeysFromFiles(sourceFiles); +const coverage = auditLocaleKeys(messages, "en"); +const enXA = createPseudoLocale(messages.en); +const arXB = createPseudoLocale(messages.en, { rtl: true }); ``` -## Configuration - -`resolveI18n` accepts an `I18nConfig`: - -- `default` — fallback language; used when nothing else matches. Ignored if it has - no loaded messages, in which case the first supported language is used. -- `locales` — explicit supported-language list; defaults to the loaded locale names. - -Language resolution order at request time (`resolveLang`): a supported `wire-lang` -cookie value → the first matching `Accept-Language` tag (or its base subtag) → the -resolved default. - -## Requirements / Notes - -- **Bun-only.** Locale loading uses `node:fs` (`existsSync`, `readdirSync`, - `readFileSync`) and `node:path`; formatting relies on the platform `Intl` APIs. -- Works with [`@wrnexus/core`](../core) — `TFunction` (the `t(key, params)` type) - comes from core, and the resolved translator is exposed as `ctx.t` / `ctx.lang` - in request handling. -- Nested message objects are supported: keys are looked up whole first, then split - on `.` to walk the object tree. +Pseudo-localization preserves interpolation placeholders and markup tags. RTL +pseudo output uses Unicode direction controls, while runtime direction detection +continues to derive `rtl` from Arabic and other RTL language subtags. diff --git a/.publish/i18n/package.json b/.publish/i18n/package.json index fd740463..4ddf96b6 100644 --- a/.publish/i18n/package.json +++ b/.publish/i18n/package.json @@ -1,9 +1,25 @@ { "name": "@wrnexus/i18n", - "version": "0.7.0", + "version": "0.8.0", "type": "module", - "description": "@wrnexus/i18n — part of the WrNexus framework.", + "description": "Locale loading, fallback resolution, SSR/browser translations, formatters, and WRNexusJS language components.", "license": "MIT", + "repository": { + "type": "git", + "url": "https://git.workroot.in/WorkRoot/WRNexusJS.git", + "directory": "packages/i18n" + }, + "homepage": "https://wrnexusjs.dev/packages/i18n", + "bugs": { + "url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues" + }, + "keywords": [ + "wrnexus", + "bun", + "typescript", + "i18n" + ], + "sideEffects": false, "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -18,12 +34,28 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" - } + }, + "./plugin": { + "types": "./dist/plugin.d.ts", + "import": "./dist/plugin.js" + }, + "./components/*": "./components/*" }, "dependencies": { - "@wrnexus/core": "^0.7.0" + "@wrnexus/core": "^0.8.0", + "@wrnexus/plugin": "^0.8.0", + "@wrnexus/ui": "^0.8.0" + }, + "wrnexus": { + "plugin": { + "plugin": "./dist/plugin.js", + "export": "default", + "factory": true + } }, "files": [ - "dist" + "dist", + "README.md", + "components" ] } diff --git a/.publish/jwt/README.md b/.publish/jwt/README.md index 386278e2..2b5b0903 100644 --- a/.publish/jwt/README.md +++ b/.publish/jwt/README.md @@ -130,3 +130,62 @@ app.use(jwtAuth({ secret: process.env.JWT_SECRET!, required: false })); - Integrates with [`@wrnexus/core`](../core) for `Context`, `Middleware`, and `ctx.user`; it complements the framework's cookie/session auth with a stateless bearer-token flow for API and mobile clients. + +## Access, refresh, scope, and cookie helpers + +```ts +import { + createAccessToken, + createRefreshToken, + verifyAccessToken, + verifyRefreshToken, + extractBearerToken, + requireScopes, + jwtCookie, +} from "@wrnexus/jwt"; +``` + +The helpers add explicit `type: "access" | "refresh"` claims, scope checks, refresh-token family metadata, no-store token responses, and secure cookie defaults. `__Host-` cookies are rejected unless they use `Path=/` and `Secure`; `SameSite=None` is rejected without `Secure`. + +## 0.8 helper kit + +```ts +import { + createTokenPair, + verifyAccessToken, + verifyRefreshToken, + extractBearerToken, + readJwtCookie, + jwtCookie, + clearJwtCookie, + requireScopes, +} from "@wrnexus/jwt"; + +const pair = await createTokenPair(user.id, { + accessSecret: process.env.JWT_ACCESS_SECRET!, + refreshSecret: process.env.JWT_REFRESH_SECRET!, + scopes: ["profile:read"], + family: sessionFamily, +}); +``` + +The helper kit validates `__Host-` cookie invariants, cookie names and paths, `SameSite=None` security, typed access/refresh token types, scope requirements, and no-store token responses. +In addition to local HS256 secrets/keyrings, the package verifies standards-based +RS256 tokens through bounded remote JWKS caches: + +```ts +import { createRemoteJwks, verifyJwtWithJwks } from "@wrnexus/jwt"; + +const jwks = createRemoteJwks("https://issuer.example/.well-known/jwks.json"); +const claims = await verifyJwtWithJwks(token, jwks, { + issuer: "https://issuer.example", + audience: "my-api", + maxAge: 300, +}); +``` + +JWKS URLs must use HTTPS. Responses have key-count/byte limits, accept only +RS256 signing RSA keys, deduplicate concurrent refreshes, cache imported public +keys, and force an immediate refresh for an unknown `kid` so issuer rotation +does not wait for cache expiry. Never use decoded-but-unverified claims for an +authorization decision. diff --git a/.publish/jwt/package.json b/.publish/jwt/package.json index 4655b47f..5fdc9726 100644 --- a/.publish/jwt/package.json +++ b/.publish/jwt/package.json @@ -1,9 +1,25 @@ { "name": "@wrnexus/jwt", - "version": "0.7.0", + "version": "0.8.0", "type": "module", - "description": "@wrnexus/jwt — part of the WrNexus framework.", + "description": "HS256 JSON Web Tokens, key rotation, access/refresh helpers, scopes, cookies, and auth middleware.", "license": "MIT", + "repository": { + "type": "git", + "url": "https://git.workroot.in/WorkRoot/WRNexusJS.git", + "directory": "packages/jwt" + }, + "homepage": "https://wrnexusjs.dev/packages/jwt", + "bugs": { + "url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues" + }, + "keywords": [ + "wrnexus", + "bun", + "typescript", + "jwt" + ], + "sideEffects": false, "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -20,7 +36,11 @@ "import": "./dist/index.js" } }, + "dependencies": { + "@wrnexus/core": "^0.8.0" + }, "files": [ - "dist" + "dist", + "README.md" ] } diff --git a/.publish/mobile/README.md b/.publish/mobile/README.md index 58087db7..a8a9a6e4 100644 --- a/.publish/mobile/README.md +++ b/.publish/mobile/README.md @@ -76,6 +76,14 @@ const status = network ? await network.getStatus() : { connected: true, connecti Unavailable required plugins throw `MobileUnavailableError` with an actionable message. +The package also provides portable application-facing primitives: + +- `listenDeepLinks` normalizes initial and live links with an allowed-scheme list. +- `PushNotifications` performs permission gating and validates registrations. +- `SecureStorage` namespaces and validates keys over an application-supplied encrypted + Keychain/Keystore adapter; it does not mislabel browser `localStorage` as secure. +- `OfflineQueue` persists bounded sync batches through a pluggable durable store. + ## Requirements / Notes - Capacitor plugin imports must remain in browser-owned modules. diff --git a/.publish/mobile/package.json b/.publish/mobile/package.json index 89b6e9d5..6ffc7e5b 100644 --- a/.publish/mobile/package.json +++ b/.publish/mobile/package.json @@ -1,9 +1,25 @@ { "name": "@wrnexus/mobile", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "description": "@wrnexus/mobile — part of the WrNexus framework.", "license": "MIT", + "repository": { + "type": "git", + "url": "https://git.workroot.in/WorkRoot/WRNexusJS.git", + "directory": "packages/mobile" + }, + "homepage": "https://wrnexusjs.dev/packages/mobile", + "bugs": { + "url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues" + }, + "keywords": [ + "wrnexus", + "bun", + "typescript", + "mobile" + ], + "sideEffects": false, "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -21,9 +37,10 @@ } }, "dependencies": { - "@wrnexus/native": "^0.7.0" + "@wrnexus/native": "^0.8.0" }, "files": [ - "dist" + "dist", + "README.md" ] } diff --git a/.publish/native/README.md b/.publish/native/README.md index 3a2f5bc4..aa659c46 100644 --- a/.publish/native/README.md +++ b/.publish/native/README.md @@ -79,6 +79,9 @@ const position = await native.run( Built-ins include `camera`, `clipboard.write`, `share`, `geolocation`, `network`, `haptics`, storage, filesystem, notifications, and device information. +`defineNativeManifest` declares required capabilities and typed permissions, while +`PermissionManager` normalizes permission query/request flows across platform adapters. + ## Requirements / Notes Use `supports()` before showing optional controls. Mobile capabilities require their diff --git a/.publish/native/package.json b/.publish/native/package.json index b9ac5cb0..5a3368dd 100644 --- a/.publish/native/package.json +++ b/.publish/native/package.json @@ -1,9 +1,25 @@ { "name": "@wrnexus/native", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "description": "@wrnexus/native — part of the WrNexus framework.", "license": "MIT", + "repository": { + "type": "git", + "url": "https://git.workroot.in/WorkRoot/WRNexusJS.git", + "directory": "packages/native" + }, + "homepage": "https://wrnexusjs.dev/packages/native", + "bugs": { + "url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues" + }, + "keywords": [ + "wrnexus", + "bun", + "typescript", + "native" + ], + "sideEffects": false, "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -29,6 +45,7 @@ } }, "files": [ - "dist" + "dist", + "README.md" ] } diff --git a/.publish/oauth/README.md b/.publish/oauth/README.md index e5f1d27f..badbe154 100644 --- a/.publish/oauth/README.md +++ b/.publish/oauth/README.md @@ -194,3 +194,24 @@ const gitlab = defineProvider({ `verifier` between `startAuth` and `completeAuth` (session or signed cookie). - Pairs with [`@wrnexus/core`](../core) — feed the normalized `OAuthProfile` into `logIn` to establish a session. + OIDC integrations can combine strict discovery with the rotating JWKS verifier: + +```ts +import { createRemoteJwks } from "@wrnexus/jwt"; +import { discoverOidc, verifyOidcIdToken } from "@wrnexus/oauth"; + +const metadata = await discoverOidc("https://issuer.example"); +const jwks = createRemoteJwks(metadata.jwks_uri); +const claims = await verifyOidcIdToken(idToken, { + issuer: metadata.issuer, + clientId: "client-id", + jwks, + nonce: expectedNonce, + accessToken, +}); +``` + +Discovery requires an exact normalized issuer and HTTPS endpoints without URL +credentials/fragments. ID-token verification checks the RS256 signature, +expiry/not-before, issuer, audience, required OIDC claims, nonce, multi-audience +`azp`, optional token age, and optional `at_hash` binding. diff --git a/.publish/oauth/package.json b/.publish/oauth/package.json index 9046302d..eb8dff78 100644 --- a/.publish/oauth/package.json +++ b/.publish/oauth/package.json @@ -1,9 +1,25 @@ { "name": "@wrnexus/oauth", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "description": "@wrnexus/oauth — part of the WrNexus framework.", "license": "MIT", + "repository": { + "type": "git", + "url": "https://git.workroot.in/WorkRoot/WRNexusJS.git", + "directory": "packages/oauth" + }, + "homepage": "https://wrnexusjs.dev/packages/oauth", + "bugs": { + "url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues" + }, + "keywords": [ + "wrnexus", + "bun", + "typescript", + "oauth" + ], + "sideEffects": false, "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -20,7 +36,11 @@ "import": "./dist/index.js" } }, + "dependencies": { + "@wrnexus/jwt": "^0.8.0" + }, "files": [ - "dist" + "dist", + "README.md" ] } diff --git a/.publish/plugin/README.md b/.publish/plugin/README.md index 910d50a5..80c96a43 100644 --- a/.publish/plugin/README.md +++ b/.publish/plugin/README.md @@ -1,7 +1,60 @@ # @wrnexus/plugin +## Least-privilege package permissions + +Package manifests declare every framework capability they register: + +```json +{ + "wrnexus": { + "permissions": ["routes", "migrations"], + "routes": [{ "kind": "api", "path": "/api/example", "entry": "./route.ts" }] + } +} +``` + +Applications can enable fail-closed grants: + +```ts +export default { + pluginPermissions: { + enforce: true, + grants: { "example-plugin": ["routes"] }, + }, +}; +``` + +Discovery rejects used-but-undeclared capabilities with +`WRN-PLUGIN-PERMISSION-UNDECLARED` and ungranted capabilities with +`WRN-PLUGIN-PERMISSION-DENIED`. Permissions cover components, browser runtime, +assets, styles, routes, middleware, migrations, config, transforms, +diagnostics/tooling, and server/build hooks. + +## Compatibility matrices + +Manifests can add `compatibility: { bunMin: "1.3.0", os: ["linux", +"darwin"] }` alongside `runtimes` and `requires`. Use +`testPluginCompatibility(manifest, targets)` in a package test to exercise the +complete support matrix. Runtime discovery enforces the same Bun minimum, OS, +runtime, and capability declarations used by the test kit. + Deterministic WRNexusJS plugin contracts for configuration, AST/code transforms, diagnostics, development servers, production builds, and DevToolbar extensions. Use `definePlugin()` and declare `enforce`, `before`, or `after` when ordering matters. Duplicate names and dependency cycles are rejected. + +## Complete lifecycle and contributions + +Plugins may implement `setup`, `configure`, `configResolved`, `transformAst`, +`transformCode`, `diagnostics`, `routes`, `configureServer`, `buildStart`, +`buildEnd`, `render`, `deploy`, `shutdown`, and `hmrUpdate`. The runner preserves +resolved plugin order for every hook and executes `setup` exactly once. + +In addition to components, routes, middleware, assets, styles, runtimes, and +migrations, plugins can contribute `directives`, `cliCommands`, +`virtualModules`, `deploymentAdapters`, `configSchemas`, `documentation`, and +`typeDefinitions`. Names are collision checked. Configuration schemas run after +configuration resolution, CLI commands are callable as normal `wrnexus` +commands, directives participate in AST transformation, and production builds +materialize virtual modules and invoke matching contributed adapters. diff --git a/.publish/plugin/package.json b/.publish/plugin/package.json index 599aefbe..506287c1 100644 --- a/.publish/plugin/package.json +++ b/.publish/plugin/package.json @@ -1,9 +1,25 @@ { "name": "@wrnexus/plugin", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "description": "@wrnexus/plugin — part of the WrNexus framework.", "license": "MIT", + "repository": { + "type": "git", + "url": "https://git.workroot.in/WorkRoot/WRNexusJS.git", + "directory": "packages/plugin" + }, + "homepage": "https://wrnexusjs.dev/packages/plugin", + "bugs": { + "url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues" + }, + "keywords": [ + "wrnexus", + "bun", + "typescript", + "plugin" + ], + "sideEffects": false, "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -33,9 +49,10 @@ } }, "dependencies": { - "@wrnexus/syntax": "^0.7.0" + "@wrnexus/syntax": "^0.8.0" }, "files": [ - "dist" + "dist", + "README.md" ] } diff --git a/.publish/pubsub/README.md b/.publish/pubsub/README.md index 424cfb88..fd7a0fd2 100644 --- a/.publish/pubsub/README.md +++ b/.publish/pubsub/README.md @@ -31,13 +31,15 @@ Creates a bus over a driver. Defaults to `memoryDriver()` (in-process). interface PubSub { publish(topic: string, message: T): Promise; subscribe(pattern: string, handler: Handler): () => void; + close(): Promise; } type Handler = (message: T, topic: string) => void | Promise; ``` -- `publish(topic, message)` — resolves once the driver has dispatched the message. +- `publish(topic, message)` — resolves once the driver and in-memory async handlers finish. - `subscribe(pattern, handler)` — returns an unsubscribe function. +- `close()` — idempotently rejects new work, clears local subscriptions, and closes the driver. ### Pattern matching @@ -67,7 +69,7 @@ then `redis://localhost:6379`. The URL may carry a password and a database index (e.g. `redis://:secret@host:6379/2`). ```ts -function redisDriver(url?: string): PubSubDriver & { close(): void }; +function redisDriver(url?: string, options?: RedisDriverOptions): PubSubDriver & { close(): void }; ``` - Exact topics use Redis `SUBSCRIBE`; wildcard patterns (`ns:*`, `*`) use @@ -75,6 +77,9 @@ function redisDriver(url?: string): PubSubDriver & { close(): void }; - Messages are JSON-stringified on publish and `JSON.parse`d on receipt; a payload that isn't valid JSON is delivered as the raw string. - `close()` tears down both the subscriber and publisher connections. +- Lost sockets reconnect with bounded exponential backoff and active subscriptions + are replayed. `maxPending` bounds unavailable-connection writes (default 1000); + `reconnectDelayMs` and `reconnectMaxDelayMs` tune recovery (100ms/5000ms). ### RESP codec (internal) @@ -115,8 +120,8 @@ bus.subscribe("order:*", (msg, topic) => { await bus.publish("order:created", { id: 7 }); -// on shutdown -driver.close(); +// on shutdown (also closes the driver) +await bus.close(); ``` ## Requirements / Notes diff --git a/.publish/pubsub/package.json b/.publish/pubsub/package.json index b1cd80b8..2de9c082 100644 --- a/.publish/pubsub/package.json +++ b/.publish/pubsub/package.json @@ -1,9 +1,25 @@ { "name": "@wrnexus/pubsub", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "description": "@wrnexus/pubsub — part of the WrNexus framework.", "license": "MIT", + "repository": { + "type": "git", + "url": "https://git.workroot.in/WorkRoot/WRNexusJS.git", + "directory": "packages/pubsub" + }, + "homepage": "https://wrnexusjs.dev/packages/pubsub", + "bugs": { + "url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues" + }, + "keywords": [ + "wrnexus", + "bun", + "typescript", + "pubsub" + ], + "sideEffects": false, "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -19,12 +35,17 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" }, + "./brokers": { + "types": "./dist/brokers.d.ts", + "import": "./dist/brokers.js" + }, "./redis": { "types": "./dist/redis.d.ts", "import": "./dist/redis.js" } }, "files": [ - "dist" + "dist", + "README.md" ] } diff --git a/.publish/queue/README.md b/.publish/queue/README.md index 85485e07..e2917849 100644 --- a/.publish/queue/README.md +++ b/.publish/queue/README.md @@ -43,33 +43,45 @@ function createQueue(options?: QueueOptions): Queue; | `backoffMs` | `number` | `1000` | Base retry backoff in ms; doubles per attempt. | | `pollMs` | `number` | `250` | Poll interval used once `start()` is called (ms). | | `onFailed` | `(job: Job, error: unknown) => void` | — | Called when a job exhausts its attempts. | +| `concurrency` | `number` | unlimited | Maximum jobs claimed by one `drain()` call. | +| `capacity` | `number` | unlimited | Maximum queued plus active jobs before adds reject. | | `now` | `() => number` | `Date.now` | Clock injection for deterministic tests. | ### `Queue` The object returned by `createQueue`. -| Method | Signature | Description | -| --------- | -------------------------------------------------------------- | -------------------------------------------------------------- | -| `add` | `add(name, data: T, options?: AddOptions): Promise>` | Enqueue a job under a worker name. Returns the created job. | -| `process` | `process(name, handler: JobHandler): void` | Register the worker that runs jobs of the given name. | -| `drain` | `drain(now?: number): Promise` | Run every job whose `runAt ≤ now`, once. Returns how many ran. | -| `start` | `start(): void` | Begin polling every `pollMs`. No-op if already started. | -| `stop` | `stop(): void` | Stop the poll timer. | -| `size` | `size(): number` | Number of jobs currently queued. | +| Method | Signature | Description | +| ---------- | -------------------------------------------------------------- | -------------------------------------------------------------- | +| `add` | `add(name, data: T, options?: AddOptions): Promise>` | Enqueue a job under a worker name. Returns the created job. | +| `process` | `process(name, handler: JobHandler): void` | Register the worker that runs jobs of the given name. | +| `drain` | `drain(now?: number): Promise` | Run every job whose `runAt ≤ now`, once. Returns how many ran. | +| `start` | `start(): void` | Begin polling every `pollMs`. No-op if already started. | +| `stop` | `stop(): void` | Stop the poll timer. | +| `shutdown` | `shutdown({ force? }): Promise` | Stop accepting jobs and await active work; force aborts it. | +| `size` | `size(): number` | Number of jobs currently queued. | +| `get/list` | `get(id)` / `list(name?)` | Inspect defensive copies of pending jobs. | +| `cancel` | `cancel(id): boolean` | Remove queued work or abort an active handler. | +| `failed` | `failed(): Job[]` | Inspect exhausted jobs in the dead-letter collection. | +| `retry` | `retry(id): Promise` | Reset and requeue a dead-lettered job. | #### `AddOptions` -| Option | Type | Description | -| ------------- | -------- | -------------------------------------------------------------------------- | -| `delayMs` | `number` | Delay before the job becomes runnable (ms). | -| `maxAttempts` | `number` | Max attempts before dead-lettering. Defaults to the queue's `maxAttempts`. | -| `repeat` | `number` | Re-enqueue this job this many ms after each successful run (recurring). | +| Option | Type | Description | +| ---------------- | -------- | -------------------------------------------------------------------------- | +| `delayMs` | `number` | Delay before the job becomes runnable (ms). | +| `maxAttempts` | `number` | Max attempts before dead-lettering. Defaults to the queue's `maxAttempts`. | +| `repeat` | `number` | Re-enqueue this job this many ms after each successful run (recurring). | +| `priority` | `number` | Higher values are selected first among due jobs. | +| `idempotencyKey` | `string` | Return the matching pending job instead of enqueueing a duplicate. | #### `JobHandler` ```ts -type JobHandler = (job: Job) => void | Promise; +type JobHandler = ( + job: Job, + context: { signal: AbortSignal }, +) => void | Promise; ``` #### `Job` @@ -106,6 +118,18 @@ await queue.add("email", { to: "a@b.com" }, { delayMs: 5000, maxAttempts: 3 }); queue.start(); // begin polling; queue.stop() to halt ``` +Use `context.signal` in network/database calls so forced shutdown and active +cancellation finish promptly. For process termination, prefer +`await queue.shutdown()`; use `{ force: true }` only after your grace period. + +### Durable queue + +`createDurableQueue({ store })` retains jobs until their handler succeeds and +supports atomic leases when a driver implements `QueueStore.claim`. It exposes +the same cancellation/shutdown behavior plus `list`, `failed`, and `retry`. +The included `memoryQueueStore()` is useful for tests; production Redis/SQL +drivers should make `claim()` atomic to prevent two workers executing one job. + ### Recurring jobs Pass `repeat` to re-enqueue a job a fixed interval after each successful run: @@ -145,6 +169,29 @@ clock = 5000; const ran = await queue.drain(); // => 1 ``` +### Durable workflows and approvals + +`createWorkflowEngine(store)` executes dependency-ordered steps and persists every transition, +result, progress update, failure, cancellation, and approval record. Approval steps pause safely +and can resume after a process restart because the snapshot lives in the supplied `WorkflowStore`. + +```ts +const workflow = defineDurableWorkflow({ + name: "publish-report", + steps: [ + { name: "build", run: buildReport }, + { name: "approve", dependsOn: ["build"], approval: true, run: (report) => report }, + { name: "publish", dependsOn: ["approve"], run: publishReport }, + ], +}); + +const run = await engine.start(workflow, input); +await engine.approve(workflow, run.id, "approve", currentUser.id); +``` + +Use `memoryWorkflowStore()` for tests. Production stores implement the small `get`, `put`, and +`list` contract using the same transactional database or durable service as the application. + ## Retry & backoff behavior - On a thrown handler error, the job is retried while `attempts < maxAttempts`. diff --git a/.publish/queue/package.json b/.publish/queue/package.json index 1bc6b908..85a61f32 100644 --- a/.publish/queue/package.json +++ b/.publish/queue/package.json @@ -1,9 +1,25 @@ { "name": "@wrnexus/queue", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "description": "@wrnexus/queue — part of the WrNexus framework.", "license": "MIT", + "repository": { + "type": "git", + "url": "https://git.workroot.in/WorkRoot/WRNexusJS.git", + "directory": "packages/queue" + }, + "homepage": "https://wrnexusjs.dev/packages/queue", + "bugs": { + "url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues" + }, + "keywords": [ + "wrnexus", + "bun", + "typescript", + "queue" + ], + "sideEffects": false, "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -20,7 +36,11 @@ "import": "./dist/index.js" } }, + "dependencies": { + "@wrnexus/core": "^0.8.0" + }, "files": [ - "dist" + "dist", + "README.md" ] } diff --git a/.publish/reactive/package.json b/.publish/reactive/package.json index d71e8783..8ec19bc4 100644 --- a/.publish/reactive/package.json +++ b/.publish/reactive/package.json @@ -1,9 +1,25 @@ { "name": "@wrnexus/reactive", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "description": "@wrnexus/reactive — part of the WrNexus framework.", "license": "MIT", + "repository": { + "type": "git", + "url": "https://git.workroot.in/WorkRoot/WRNexusJS.git", + "directory": "packages/reactive" + }, + "homepage": "https://wrnexusjs.dev/packages/reactive", + "bugs": { + "url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues" + }, + "keywords": [ + "wrnexus", + "bun", + "typescript", + "reactive" + ], + "sideEffects": false, "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -21,6 +37,7 @@ } }, "files": [ - "dist" + "dist", + "README.md" ] } diff --git a/.publish/router/package.json b/.publish/router/package.json index c9894fa3..8c3e0317 100644 --- a/.publish/router/package.json +++ b/.publish/router/package.json @@ -1,9 +1,25 @@ { "name": "@wrnexus/router", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "description": "@wrnexus/router — part of the WrNexus framework.", "license": "MIT", + "repository": { + "type": "git", + "url": "https://git.workroot.in/WorkRoot/WRNexusJS.git", + "directory": "packages/router" + }, + "homepage": "https://wrnexusjs.dev/packages/router", + "bugs": { + "url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues" + }, + "keywords": [ + "wrnexus", + "bun", + "typescript", + "router" + ], + "sideEffects": false, "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -21,10 +37,11 @@ } }, "dependencies": { - "@wrnexus/compiler": "^0.7.0", - "@wrnexus/core": "^0.7.0" + "@wrnexus/compiler": "^0.8.0", + "@wrnexus/core": "^0.8.0" }, "files": [ - "dist" + "dist", + "README.md" ] } diff --git a/.publish/ssr/package.json b/.publish/ssr/package.json index 2c4e5926..7d11b1d7 100644 --- a/.publish/ssr/package.json +++ b/.publish/ssr/package.json @@ -1,9 +1,25 @@ { "name": "@wrnexus/ssr", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "description": "@wrnexus/ssr — part of the WrNexus framework.", "license": "MIT", + "repository": { + "type": "git", + "url": "https://git.workroot.in/WorkRoot/WRNexusJS.git", + "directory": "packages/ssr" + }, + "homepage": "https://wrnexusjs.dev/packages/ssr", + "bugs": { + "url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues" + }, + "keywords": [ + "wrnexus", + "bun", + "typescript", + "ssr" + ], + "sideEffects": false, "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -29,11 +45,12 @@ } }, "dependencies": { - "@wrnexus/core": "^0.7.0", - "@wrnexus/store": "^0.7.0", - "@wrnexus/security": "^0.7.0" + "@wrnexus/core": "^0.8.0", + "@wrnexus/store": "^0.8.0", + "@wrnexus/security": "^0.8.0" }, "files": [ - "dist" + "dist", + "README.md" ] } diff --git a/.publish/styles/README.md b/.publish/styles/README.md index 4037b990..bb7709ab 100644 --- a/.publish/styles/README.md +++ b/.publish/styles/README.md @@ -1,5 +1,26 @@ # @wrnexus/styles +## Reusable layers and presets + +Compose local or package foundations in order; later layers override earlier +ones and the application has final base-config precedence: + +```ts +export default defineConfig({ + extends: ["@workroot/wrnexus-enterprise", "./layers/company"], + profiles: { production: { port: 8080 } }, +}); +``` + +A directory layer exports `wrnexus.layer.ts` (JavaScript/MJS are supported). +A package can provide that conventional file or declare +`wrnexus.layer` in its `package.json`. Layers may extend other layers and carry +the complete app configuration, including plugins that contribute layouts, +components, routes, middleware, and migrations. `plugins` and `head` compose; +other arrays intentionally replace earlier values. Cycles and missing/invalid +entries fail with stable `WRN-CONFIG-LAYER-*` diagnostics. `wrnexus config +--explain` lists every resolved layer source. + > Global CSS bundling, the `--wire-*` design-token theme system, and the `wrnexus.config.ts` app-config loader for WrNexus apps. Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework. diff --git a/.publish/styles/package.json b/.publish/styles/package.json index 8530ef84..b7238e67 100644 --- a/.publish/styles/package.json +++ b/.publish/styles/package.json @@ -1,9 +1,25 @@ { "name": "@wrnexus/styles", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "description": "@wrnexus/styles — part of the WrNexus framework.", "license": "MIT", + "repository": { + "type": "git", + "url": "https://git.workroot.in/WorkRoot/WRNexusJS.git", + "directory": "packages/styles" + }, + "homepage": "https://wrnexusjs.dev/packages/styles", + "bugs": { + "url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues" + }, + "keywords": [ + "wrnexus", + "bun", + "typescript", + "styles" + ], + "sideEffects": false, "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -21,11 +37,12 @@ } }, "dependencies": { - "@wrnexus/uploader": "^0.7.0", - "@wrnexus/core": "^0.7.0", - "@wrnexus/plugin": "^0.7.0" + "@wrnexus/uploader": "^0.8.0", + "@wrnexus/core": "^0.8.0", + "@wrnexus/plugin": "^0.8.0" }, "files": [ - "dist" + "dist", + "README.md" ] } diff --git a/.publish/syntax/package.json b/.publish/syntax/package.json index a82bc899..5751f608 100644 --- a/.publish/syntax/package.json +++ b/.publish/syntax/package.json @@ -1,9 +1,25 @@ { "name": "@wrnexus/syntax", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "description": "@wrnexus/syntax — part of the WrNexus framework.", "license": "MIT", + "repository": { + "type": "git", + "url": "https://git.workroot.in/WorkRoot/WRNexusJS.git", + "directory": "packages/syntax" + }, + "homepage": "https://wrnexusjs.dev/packages/syntax", + "bugs": { + "url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues" + }, + "keywords": [ + "wrnexus", + "bun", + "typescript", + "syntax" + ], + "sideEffects": false, "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -38,9 +54,14 @@ "./spec": { "types": "./dist/spec.d.ts", "import": "./dist/spec.js" + }, + "./formatter": { + "types": "./dist/formatter.d.ts", + "import": "./dist/formatter.js" } }, "files": [ - "dist" + "dist", + "README.md" ] } diff --git a/.publish/test/README.md b/.publish/test/README.md index 829fb1a8..21ba022a 100644 --- a/.publish/test/README.md +++ b/.publish/test/README.md @@ -103,6 +103,22 @@ Remember to `await app.close()` when done. ## Usage +The CLI supports focused suites by file or directory convention: + +```bash +wrnexus test unit # *.unit.test.ts or test/unit/** +wrnexus test component # *.component.test.ts or test/component/** +wrnexus test api # *.api.test.ts or test/api/** +wrnexus test accessibility # *.a11y.test.ts / *.accessibility.test.ts +wrnexus test performance # *.performance.test.ts / *.benchmark.test.ts +wrnexus test browser # Playwright project when configured +wrnexus test visual # Playwright tests tagged @visual +``` + +Pass the application directory after the level, for example +`wrnexus test component examples/basic-app`. A focused command fails clearly when no matching +suite exists instead of silently running unrelated tests. + ```ts import { test, expect, renderComponent, mountHtml, createHarness } from "@wrnexus/test"; diff --git a/.publish/test/package.json b/.publish/test/package.json index 5b3cf7b9..61808464 100644 --- a/.publish/test/package.json +++ b/.publish/test/package.json @@ -1,9 +1,25 @@ { "name": "@wrnexus/test", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "description": "@wrnexus/test — part of the WrNexus framework.", "license": "MIT", + "repository": { + "type": "git", + "url": "https://git.workroot.in/WorkRoot/WRNexusJS.git", + "directory": "packages/test" + }, + "homepage": "https://wrnexusjs.dev/packages/test", + "bugs": { + "url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues" + }, + "keywords": [ + "wrnexus", + "bun", + "typescript", + "test" + ], + "sideEffects": false, "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -21,6 +37,7 @@ } }, "files": [ - "dist" + "dist", + "README.md" ] } diff --git a/.publish/tracking/package.json b/.publish/tracking/package.json index bc4741bd..97ba1a8a 100644 --- a/.publish/tracking/package.json +++ b/.publish/tracking/package.json @@ -1,9 +1,25 @@ { "name": "@wrnexus/tracking", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "description": "@wrnexus/tracking — part of the WrNexus framework.", "license": "MIT", + "repository": { + "type": "git", + "url": "https://git.workroot.in/WorkRoot/WRNexusJS.git", + "directory": "packages/tracking" + }, + "homepage": "https://wrnexusjs.dev/packages/tracking", + "bugs": { + "url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues" + }, + "keywords": [ + "wrnexus", + "bun", + "typescript", + "tracking" + ], + "sideEffects": false, "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -21,6 +37,7 @@ } }, "files": [ - "dist" + "dist", + "README.md" ] } diff --git a/.publish/ui/package.json b/.publish/ui/package.json index 6a820e48..3f0fe17b 100644 --- a/.publish/ui/package.json +++ b/.publish/ui/package.json @@ -1,9 +1,25 @@ { "name": "@wrnexus/ui", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "description": "@wrnexus/ui — part of the WrNexus framework.", "license": "MIT", + "repository": { + "type": "git", + "url": "https://git.workroot.in/WorkRoot/WRNexusJS.git", + "directory": "packages/ui" + }, + "homepage": "https://wrnexusjs.dev/packages/ui", + "bugs": { + "url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues" + }, + "keywords": [ + "wrnexus", + "bun", + "typescript", + "ui" + ], + "sideEffects": false, "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -26,10 +42,11 @@ "./ui.css": "./ui.css" }, "dependencies": { - "@wrnexus/core": "^0.7.0" + "@wrnexus/core": "^0.8.0" }, "files": [ "dist", + "README.md", "components", "ui.css", "component-catalog.json", diff --git a/.publish/uploader/README.md b/.publish/uploader/README.md index b89645b8..287c221a 100644 --- a/.publish/uploader/README.md +++ b/.publish/uploader/README.md @@ -108,6 +108,11 @@ export const GET = serveFromStore("docs"); // your middleware decides who gets i ## API +Uploads can participate in security and media pipelines without changing storage drivers. Pass a +`scan` hook to reject malware/DLP findings before storage, and `afterStore` to enqueue image/video +processing or indexing. If post-processing throws, WRNexus deletes the newly written object so a +partially accepted upload is never left behind. + | Export | What | | --------------------------------------- | --------------------------------------------------------------- | | `handleUpload(opts)` | POST route handler → JSON `{ ok, files }` | @@ -123,3 +128,36 @@ export const GET = serveFromStore("docs"); // your middleware decides who gets i - SigV4 signing is implemented from scratch (no `@aws-sdk`); tested against local S3 semantics. Live AWS/R2 connectivity depends on your credentials + bucket policy. - v1 buffers each file in memory up to its size cap (fine for images/docs up to tens of MB). + +## Helper and component kit + +Use `formatFileSize`, `uploadAccept`, `uploadedFileMap`, `uploaderAttributes`, and `assertUploadedFiles` to keep upload forms and server validation consistent. + +Enable `uploaderPlugin()` for: + +- `` +- `` + +The complete blocks compose `Card`, `Alert`, and `Badge` from `@wrnexus/ui`; the specialized upload runtime remains responsible for the native file input and secure transport behavior. +Large files can use `createResumableUploadManager`. Sessions are bounded and +expiring; chunks may arrive out of order, carry SHA-256 checksums, and are +idempotent when retried. Conflicting retries reject, and the object is assembled +only after every exact-sized chunk is present. + +```ts +const uploads = createResumableUploadManager({ + driver: getStore("documents").driver, + sessions: redisUploadSessionStore, + chunkSize: 5 * 1024 * 1024, + maxBytes: 500 * 1024 * 1024, + accept: ["application/pdf"], +}); + +const session = await uploads.create({ name: "report.pdf", size, type }); +await uploads.uploadChunk(session.id, index, bytes, sha256); +``` + +The included memory session store is intended for one-process apps and tests. +Multi-instance production deployments should implement `ResumableSessionStore` +with shared durable storage and atomic session updates, and periodically call +`prune()` for abandoned uploads. diff --git a/.publish/uploader/package.json b/.publish/uploader/package.json index 42421953..01f2382f 100644 --- a/.publish/uploader/package.json +++ b/.publish/uploader/package.json @@ -1,9 +1,25 @@ { "name": "@wrnexus/uploader", - "version": "0.7.0", + "version": "0.8.0", "type": "module", - "description": "@wrnexus/uploader — part of the WrNexus framework.", + "description": "Secure upload drivers, policies, client runtime, helper functions, and reusable upload components.", "license": "MIT", + "repository": { + "type": "git", + "url": "https://git.workroot.in/WorkRoot/WRNexusJS.git", + "directory": "packages/uploader" + }, + "homepage": "https://wrnexusjs.dev/packages/uploader", + "bugs": { + "url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues" + }, + "keywords": [ + "wrnexus", + "bun", + "typescript", + "uploader" + ], + "sideEffects": false, "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -18,12 +34,28 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" - } + }, + "./plugin": { + "types": "./dist/plugin.d.ts", + "import": "./dist/plugin.js" + }, + "./components/*": "./components/*" }, "dependencies": { - "@wrnexus/core": "^0.7.0" + "@wrnexus/core": "^0.8.0", + "@wrnexus/plugin": "^0.8.0", + "@wrnexus/ui": "^0.8.0" + }, + "wrnexus": { + "plugin": { + "plugin": "./dist/plugin.js", + "export": "default", + "factory": true + } }, "files": [ - "dist" + "dist", + "README.md", + "components" ] } diff --git a/.publish/validation/README.md b/.publish/validation/README.md index fdbfacf5..8c1d688d 100644 --- a/.publish/validation/README.md +++ b/.publish/validation/README.md @@ -4,6 +4,31 @@ Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework. +## Boundary contracts + +Use `ContractRegistry` with `defineContract` or `defineEvent` to publish the +same schema descriptors for APIs, actions, webhooks, realtime, queues, cron, +pub/sub, plugins, configuration, and environment variables. + +```ts +import { ContractRegistry, defineEvent, v } from "@wrnexus/validation"; + +export const contracts = new ContractRegistry().register( + defineEvent({ + name: "user.created", + version: 1, + consumers: ["notification-worker", "audit-service"], + payload: v.object({ userId: v.string().uuid(), createdAt: v.string().date() }), + }), +); +``` + +Export the registry from `app/contracts.ts`, then accept a baseline with +`wrnexus contracts snapshot`. CI can run `wrnexus contracts check`; removed +contracts/fields, required-field additions, type changes, narrowed enums, and +tighter validation fail with stable `WRN-CONTRACT-*` diagnostics and list known +consumers. A generated `wrnexus.contracts.json` can be used instead of a module. + ## Overview Define a schema once with the fluent `v` builder, then reuse it in three places: `.parse()` runs server-side and returns coerced values plus per-field errors; `.describe()` emits a plain-JSON `SchemaDescriptor` that the browser runtime interprets (no `eval`, no bundled validator); and helpers like `parseBody` and `parseEnv` wire schemas straight into API routes and startup config. The server rule logic (`applyRule`/`checkField`) and the client runtime (`VALIDATE_RUNTIME`) mirror each other exactly, so a form validates identically in both places. Schemas are conventionally kept in `app/schemas/`. @@ -178,3 +203,55 @@ const head = ``; + const response = await app.fetch(`/?search=${encodeURIComponent(payload)}`); + expect(response.status).toBe(200); + expect(await response.text()).not.toContain(payload); + }); + + test("rejects traversal attempts without exposing source files", async () => { + for (const path of ["/../../package.json", "/%2e%2e/%2e%2e/package.json", "/..%5c..%5c.env"]) { + const response = await app.fetch(path); + expect([400, 404]).toContain(response.status); + const body = await response.text(); + expect(body).not.toContain("DATABASE_URL"); + expect(body).not.toContain('"workspaces"'); + } + }); + + test("does not grant CORS credentials to an untrusted origin", async () => { + const response = await app.fetch("/api/hello", { + headers: { origin: "https://evil.example" }, + }); + expect(response.headers.get("access-control-allow-origin")).toBeNull(); + expect(response.headers.get("access-control-allow-credentials")).toBeNull(); + }); + + test("requires CSRF for login and avoids credential oracle details", async () => { + const response = await app.fetch("/api/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email: "victim@example.com", password: "wrong-password" }), + }); + expect(response.status).toBe(403); + expect(await response.text()).not.toContain("passwordHash"); + }); + + test("handles malformed JSON without a stack trace", async () => { + const response = await app.fetch("/api/echo", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{broken", + }); + expect(response.status).toBe(400); + const body = await response.text(); + expect(body).not.toContain(" at "); + expect(body).not.toContain("node_modules"); + }); + + test("refuses actual request bodies over the configured limit", async () => { + try { + const response = await app.fetch("/api/echo", { + method: "POST", + headers: { "content-type": "application/json" }, + body: `"${"x".repeat(10 * 1024 * 1024)}"`, + }); + expect(response.status).toBe(413); + } catch (error) { + // Bun rejects the oversized socket before application dispatch on some versions. + expect(String(error)).toMatch(/ECONNRESET|socket connection was closed/i); + } + }); +}); diff --git a/examples/basic-app/app/types/generated-contracts.test.ts b/examples/basic-app/app/types/generated-contracts.test.ts new file mode 100644 index 00000000..753a3ad9 --- /dev/null +++ b/examples/basic-app/app/types/generated-contracts.test.ts @@ -0,0 +1,30 @@ +import type { Context } from "@wrnexus/core"; + +const apiInput: WRNexusGenerated.ApiContracts["/api/typed-user"]["POST"]["input"] = { + name: "Ada", + email: "ada@example.test", +}; +const queuePayload: WRNexusGenerated.QueuePayloads["welcome-email"] = { + userId: 42, + email: "ada@example.test", +}; +const queryArgs: WRNexusGenerated.DatabaseQueries["GetUserByEmail"]["args"] = { + email: "ada@example.test", +}; +const realtimeMessage: WRNexusGenerated.RealtimeMessages["/realtime/chat"] = { + user: "ada", + text: "hello", +}; +const middlewareContext: WRNexusGenerated.MiddlewareContexts["auth"] = {} as Context; + +void [apiInput, queuePayload, queryArgs, realtimeMessage, middlewareContext]; + +// @ts-expect-error schema-derived input requires an email. +const invalidApiInput: WRNexusGenerated.ApiContracts["/api/typed-user"]["POST"]["input"] = { + name: "Missing email", +}; +void invalidApiInput; + +// @ts-expect-error generated SQL query arguments require an email string. +const invalidQuery: WRNexusGenerated.DatabaseQueries["GetUserByEmail"]["args"] = {}; +void invalidQuery; diff --git a/examples/basic-app/app/types/wrnexus.generated.d.ts b/examples/basic-app/app/types/wrnexus.generated.d.ts new file mode 100644 index 00000000..afc7ac80 --- /dev/null +++ b/examples/basic-app/app/types/wrnexus.generated.d.ts @@ -0,0 +1,59 @@ +// AUTO-GENERATED by `wrnexus generate types` - do not edit. +declare namespace WRNexusGenerated { + type ApiContract = T extends import("@wrnexus/core").DefinedEndpoint + ? { input: I; output: O } + : T extends (...args: infer A) => infer R + ? { input: A extends [any, infer I, ...any[]] ? I : unknown; output: Awaited } + : { input: unknown; output: unknown }; + type MiddlewareContext = T extends (ctx: infer C, ...args: any[]) => any ? C : never; + type QueryContract = T extends (db: any, args: infer A, ...rest: any[]) => infer R + ? { args: A; result: Awaited } + : T extends (db: any, ...rest: any[]) => infer R + ? { args: Record; result: Awaited } + : never; + type RealtimeMessage = T extends import("@wrnexus/core").RoomDefinition ? M : unknown; + type QueuePayload = T extends import("@wrnexus/queue").JobDefinition ? I : unknown; + type RouteName = "about" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "index" | "language.tools" | "login" | "modal" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "test" | "ui"; + type ApiRoute = "/api/echo" | "/api/graphql-example" | "/api/hello" | "/api/login" | "/api/logout" | "/api/me" | "/api/typed-user" | "/api/users/csr" | "/api/users/ssr" | "/api/webhooks/payment"; + type RealtimeRoute = "/realtime/chat" | "/realtime/hello"; + type EnvironmentKey = "APP_LABEL" | "DATABASE_URL" | "DEMO_SHARED" | "HOST" | "NODE_ENV" | "PORT" | "SESSION_SECRET" | "UAT_ONLY"; + type TranslationKey = "api.greeting" | "home.intro" | "home.title" | "nav.about" | "nav.chat" | "nav.dashboard" | "nav.home" | "nav.ui"; + type QueueName = "welcome-email"; + type CacheKey = "users"; + interface Components { + "Modal": { props: Record; outputs: Record }; + "Counter": { props: { "start"?: unknown; "label"?: unknown }; outputs: Record }; + } + interface ApiContracts { + "/api/webhooks/payment": { POST: ApiContract }; + "/api/users/csr": { GET: ApiContract }; + "/api/users/ssr": { GET: ApiContract }; + "/api/graphql-example": { POST: ApiContract }; + "/api/typed-user": { POST: ApiContract }; + "/api/logout": { POST: ApiContract }; + "/api/hello": { GET: ApiContract }; + "/api/login": { POST: ApiContract }; + "/api/echo": { GET: ApiContract; POST: ApiContract }; + "/api/me": { GET: ApiContract }; + } + interface MiddlewareContexts { + "auth": MiddlewareContext<(typeof import("../middleware/auth.ts"))["default"]>; + "logger": MiddlewareContext<(typeof import("../middleware/logger.ts"))["default"]>; + "ratelimit": MiddlewareContext<(typeof import("../middleware/ratelimit.ts"))["default"]>; + } + interface DatabaseQueries { + "GetUserByEmail": QueryContract; + "ListUsers": QueryContract; + "CountActive": QueryContract; + "CreateUser": QueryContract; + "DeactivateUser": QueryContract; + } + interface RealtimeMessages { + "/realtime/hello": RealtimeMessage<(typeof import("../pages/hello.wrn"))["default"]>; + "/realtime/chat": RealtimeMessage<(typeof import("../realtime/chat.ts"))["default"]>; + } + interface QueuePayloads { + "welcome-email": QueuePayload<(typeof import("../queues/welcome-email.ts"))["default"]>; + } + type ApplicationConfig = (typeof import("../../wrnexus.config.ts"))["default"]; +} diff --git a/examples/basic-app/app/types/wrnexus.plugins.generated.d.ts b/examples/basic-app/app/types/wrnexus.plugins.generated.d.ts new file mode 100644 index 00000000..c8d0064d --- /dev/null +++ b/examples/basic-app/app/types/wrnexus.plugins.generated.d.ts @@ -0,0 +1 @@ +// AUTO-GENERATED plugin type aggregation - do not edit. diff --git a/examples/basic-app/deploy/README.md b/examples/basic-app/deploy/README.md new file mode 100644 index 00000000..c2bd1b3f --- /dev/null +++ b/examples/basic-app/deploy/README.md @@ -0,0 +1,10 @@ +# WRNexus deployment operations + +- Liveness: `GET /healthz` +- Readiness: `GET /readyz` (includes registered dependency checks) +- Migrations: run `bunx wrnexus db migrate --profile=production` once per release before scaling. +- Shutdown: the Bun production server drains on SIGTERM/SIGINT. +- Assets: `dist/public` files are content-addressed and may be cached immutably by a CDN. +- Secrets: provide `DATABASE_URL` and `SESSION_SECRET` through the platform secret store; never commit production env files. +- Logs: stdout/stderr are structured for platform collection. Configure OTLP for centralized telemetry. +- Scaling: start with 250m CPU/256Mi memory, use readiness probes, and scale horizontally from request latency and CPU. diff --git a/examples/basic-app/deploy/kubernetes.yaml b/examples/basic-app/deploy/kubernetes.yaml new file mode 100644 index 00000000..ce956c8e --- /dev/null +++ b/examples/basic-app/deploy/kubernetes.yaml @@ -0,0 +1,44 @@ +apiVersion: v1 +kind: Service +metadata: + name: wrnexus +spec: + selector: { app: wrnexus } + ports: [{ name: http, port: 80, targetPort: 3000 }] +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: wrnexus +spec: + replicas: 2 + selector: { matchLabels: { app: wrnexus } } + template: + metadata: { labels: { app: wrnexus } } + spec: + containers: + - name: app + image: ghcr.io/OWNER/APP:latest + ports: [{ containerPort: 3000 }] + envFrom: [{ secretRef: { name: wrnexus-secrets } }] + livenessProbe: { httpGet: { path: /healthz, port: 3000 }, initialDelaySeconds: 5 } + readinessProbe: { httpGet: { path: /readyz, port: 3000 }, initialDelaySeconds: 5 } + resources: + requests: { cpu: 250m, memory: 256Mi } + limits: { cpu: "1", memory: 512Mi } + lifecycle: { preStop: { exec: { command: ["sh", "-c", "sleep 5"] } } } + terminationGracePeriodSeconds: 30 +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: wrnexus-migrate +spec: + template: + spec: + restartPolicy: Never + containers: + - name: migrate + image: ghcr.io/OWNER/APP:latest + command: ["bunx", "wrnexus", "db", "migrate", "--profile=production"] + envFrom: [{ secretRef: { name: wrnexus-secrets } }] diff --git a/examples/basic-app/deploy/nginx.conf b/examples/basic-app/deploy/nginx.conf new file mode 100644 index 00000000..64a25cf6 --- /dev/null +++ b/examples/basic-app/deploy/nginx.conf @@ -0,0 +1,6 @@ +server { + listen 80; + server_name example.com; + location /assets/ { root /srv/wrnexus/dist/public; expires 1y; add_header Cache-Control "public, immutable"; } + location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Request-ID $request_id; } +} diff --git a/examples/basic-app/deploy/wrnexus.service b/examples/basic-app/deploy/wrnexus.service new file mode 100644 index 00000000..8049f066 --- /dev/null +++ b/examples/basic-app/deploy/wrnexus.service @@ -0,0 +1,22 @@ +[Unit] +Description=WRNexus application +After=network-online.target + +[Service] +Type=simple +WorkingDirectory=/srv/wrnexus +EnvironmentFile=/etc/wrnexus/wrnexus.env +ExecStartPre=/usr/bin/bunx wrnexus db migrate --profile=production +ExecStart=/usr/bin/bun dist/server.js +Restart=on-failure +RestartSec=5 +TimeoutStopSec=30 +User=wrnexus +Group=wrnexus +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ReadWritePaths=/srv/wrnexus + +[Install] +WantedBy=multi-user.target diff --git a/examples/basic-app/docker-compose.yml b/examples/basic-app/docker-compose.yml new file mode 100644 index 00000000..bfc748a1 --- /dev/null +++ b/examples/basic-app/docker-compose.yml @@ -0,0 +1,30 @@ +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: diff --git a/examples/basic-app/fly.toml b/examples/basic-app/fly.toml new file mode 100644 index 00000000..e922f763 --- /dev/null +++ b/examples/basic-app/fly.toml @@ -0,0 +1,19 @@ +app = "wrnexus-app" +primary_region = "bom" + +[build] + dockerfile = "Dockerfile" +[env] + PORT = "3000" +[http_service] + internal_port = 3000 + force_https = true + auto_stop_machines = "stop" + auto_start_machines = true + min_machines_running = 1 + [[http_service.checks]] + path = "/readyz" + interval = "15s" + timeout = "2s" +[deploy] + release_command = "bunx wrnexus db migrate --profile=production" diff --git a/examples/basic-app/generated/api/examples.md b/examples/basic-app/generated/api/examples.md new file mode 100644 index 00000000..a47cae19 --- /dev/null +++ b/examples/basic-app/generated/api/examples.md @@ -0,0 +1,67 @@ +# API examples + +## postWebhooksPayment + +```bash +curl -X POST "http://localhost:3000/api/webhooks/payment" +``` + +## getUsersCsr + +```bash +curl -X GET "http://localhost:3000/api/users/csr" +``` + +## getUsersSsr + +```bash +curl -X GET "http://localhost:3000/api/users/ssr" +``` + +## postGraphqlExample + +```bash +curl -X POST "http://localhost:3000/api/graphql-example" +``` + +## postTypedUser + +```bash +curl -X POST "http://localhost:3000/api/typed-user" +``` + +## postLogout + +```bash +curl -X POST "http://localhost:3000/api/logout" +``` + +## getHello + +```bash +curl -X GET "http://localhost:3000/api/hello" +``` + +## postLogin + +```bash +curl -X POST "http://localhost:3000/api/login" +``` + +## getEcho + +```bash +curl -X GET "http://localhost:3000/api/echo" +``` + +## postEcho + +```bash +curl -X POST "http://localhost:3000/api/echo" +``` + +## getMe + +```bash +curl -X GET "http://localhost:3000/api/me" +``` diff --git a/examples/basic-app/generated/api/index.html b/examples/basic-app/generated/api/index.html new file mode 100644 index 00000000..89c52d62 --- /dev/null +++ b/examples/basic-app/generated/api/index.html @@ -0,0 +1,79 @@ + + basic-app API +

basic-app API

+

OpenAPI 3.1 · 11 operations · specification

+
+

POST /api/webhooks/payment

+

Payment completed

+

Sent after a payment reaches its settled state.

+

Webhook event: payment.completed · signature: x-payment-signature

+ app/api/webhooks/payment.ts +
+
+

GET /api/users/csr

+

getUsersCsr

+ app/api/users/csr.ts +
+
+

GET /api/users/ssr

+

getUsersSsr

+ app/api/users/ssr.ts +
+
+

POST /api/graphql-example

+

postGraphqlExample

+ app/api/graphql-example.ts +
+
+

POST /api/typed-user

+

postTypedUser

+

Validate and echo a typed user payload.

+ app/api/typed-user.ts +
+
+

POST /api/logout

+

postLogout

+ app/api/logout.ts +
+
+

GET /api/hello

+

getHello

+ app/api/hello.ts +
+
+

POST /api/login

+

postLogin

+ app/api/login.ts +
+
+

GET /api/echo

+

getEcho

+ app/api/echo.ts +
+
+

POST /api/echo

+

postEcho

+ app/api/echo.ts +
+
+

GET /api/me

+

getMe

+ app/api/me.ts +
diff --git a/examples/basic-app/generated/api/openapi.json b/examples/basic-app/generated/api/openapi.json new file mode 100644 index 00000000..bf19cc1c --- /dev/null +++ b/examples/basic-app/generated/api/openapi.json @@ -0,0 +1,318 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "basic-app API", + "version": "0.8.0" + }, + "paths": { + "/api/webhooks/payment": { + "post": { + "operationId": "postWebhooksPayment", + "summary": "Payment completed", + "description": "Sent after a payment reaches its settled state.", + "tags": ["API"], + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "description": "Invalid request" + }, + "500": { + "description": "Internal error" + } + }, + "x-wrnexus-source": "app/api/webhooks/payment.ts" + } + }, + "/api/users/csr": { + "get": { + "operationId": "getUsersCsr", + "summary": "GET /api/users/csr", + "tags": ["API"], + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "description": "Invalid request" + }, + "500": { + "description": "Internal error" + } + }, + "x-wrnexus-source": "app/api/users/csr.ts" + } + }, + "/api/users/ssr": { + "get": { + "operationId": "getUsersSsr", + "summary": "GET /api/users/ssr", + "tags": ["API"], + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "description": "Invalid request" + }, + "500": { + "description": "Internal error" + } + }, + "x-wrnexus-source": "app/api/users/ssr.ts" + } + }, + "/api/graphql-example": { + "post": { + "operationId": "postGraphqlExample", + "summary": "POST /api/graphql-example", + "tags": ["API"], + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "description": "Invalid request" + }, + "500": { + "description": "Internal error" + } + }, + "x-wrnexus-source": "app/api/graphql-example.ts" + } + }, + "/api/typed-user": { + "post": { + "operationId": "postTypedUser", + "summary": "POST /api/typed-user", + "description": "Validate and echo a typed user payload.", + "tags": ["API"], + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "description": "Invalid request" + }, + "500": { + "description": "Internal error" + } + }, + "x-wrnexus-source": "app/api/typed-user.ts" + } + }, + "/api/logout": { + "post": { + "operationId": "postLogout", + "summary": "POST /api/logout", + "tags": ["API"], + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "description": "Invalid request" + }, + "500": { + "description": "Internal error" + } + }, + "x-wrnexus-source": "app/api/logout.ts" + } + }, + "/api/hello": { + "get": { + "operationId": "getHello", + "summary": "GET /api/hello", + "tags": ["API"], + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "description": "Invalid request" + }, + "500": { + "description": "Internal error" + } + }, + "x-wrnexus-source": "app/api/hello.ts" + } + }, + "/api/login": { + "post": { + "operationId": "postLogin", + "summary": "POST /api/login", + "tags": ["API"], + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "description": "Invalid request" + }, + "500": { + "description": "Internal error" + } + }, + "x-wrnexus-source": "app/api/login.ts" + } + }, + "/api/echo": { + "get": { + "operationId": "getEcho", + "summary": "GET /api/echo", + "tags": ["API"], + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "description": "Invalid request" + }, + "500": { + "description": "Internal error" + } + }, + "x-wrnexus-source": "app/api/echo.ts" + }, + "post": { + "operationId": "postEcho", + "summary": "POST /api/echo", + "tags": ["API"], + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "description": "Invalid request" + }, + "500": { + "description": "Internal error" + } + }, + "x-wrnexus-source": "app/api/echo.ts" + } + }, + "/api/me": { + "get": { + "operationId": "getMe", + "summary": "GET /api/me", + "tags": ["API"], + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "description": "Invalid request" + }, + "500": { + "description": "Internal error" + } + }, + "x-wrnexus-source": "app/api/me.ts" + } + } + }, + "webhooks": { + "payment.completed": { + "post": { + "summary": "Payment completed", + "description": "Sent after a payment reaches its settled state.", + "parameters": [ + { + "name": "x-payment-signature", + "in": "header", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentCompleted" + } + } + } + }, + "responses": { + "200": { + "description": "Webhook accepted" + } + }, + "x-wrnexus-source": "app/api/webhooks/payment.ts" + } + } + } +} diff --git a/examples/basic-app/generated/api/postman.json b/examples/basic-app/generated/api/postman.json new file mode 100644 index 00000000..09276c59 --- /dev/null +++ b/examples/basic-app/generated/api/postman.json @@ -0,0 +1,91 @@ +{ + "info": { + "name": "basic-app API", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "postWebhooksPayment", + "request": { + "method": "POST", + "url": "{{baseUrl}}/api/webhooks/payment" + } + }, + { + "name": "getUsersCsr", + "request": { + "method": "GET", + "url": "{{baseUrl}}/api/users/csr" + } + }, + { + "name": "getUsersSsr", + "request": { + "method": "GET", + "url": "{{baseUrl}}/api/users/ssr" + } + }, + { + "name": "postGraphqlExample", + "request": { + "method": "POST", + "url": "{{baseUrl}}/api/graphql-example" + } + }, + { + "name": "postTypedUser", + "request": { + "method": "POST", + "url": "{{baseUrl}}/api/typed-user" + } + }, + { + "name": "postLogout", + "request": { + "method": "POST", + "url": "{{baseUrl}}/api/logout" + } + }, + { + "name": "getHello", + "request": { + "method": "GET", + "url": "{{baseUrl}}/api/hello" + } + }, + { + "name": "postLogin", + "request": { + "method": "POST", + "url": "{{baseUrl}}/api/login" + } + }, + { + "name": "getEcho", + "request": { + "method": "GET", + "url": "{{baseUrl}}/api/echo" + } + }, + { + "name": "postEcho", + "request": { + "method": "POST", + "url": "{{baseUrl}}/api/echo" + } + }, + { + "name": "getMe", + "request": { + "method": "GET", + "url": "{{baseUrl}}/api/me" + } + } + ], + "variable": [ + { + "key": "baseUrl", + "value": "http://localhost:3000" + } + ] +} diff --git a/examples/basic-app/generated/api/sdk/go/wrnexus-api.go b/examples/basic-app/generated/api/sdk/go/wrnexus-api.go new file mode 100644 index 00000000..1c7a5f59 --- /dev/null +++ b/examples/basic-app/generated/api/sdk/go/wrnexus-api.go @@ -0,0 +1,5 @@ +package wrnexussdk + +import ("bytes"; "encoding/json"; "fmt"; "net/http") +type Client struct { BaseURL string; HTTP *http.Client } +func (c *Client) Request(method, path string, body any) (map[string]any, error) { data,_:=json.Marshal(body); req,_:=http.NewRequest(method,c.BaseURL+path,bytes.NewReader(data)); req.Header.Set("content-type","application/json"); client:=c.HTTP;if client==nil{client=http.DefaultClient};res,err:=client.Do(req);if err!=nil{return nil,err};defer res.Body.Close();if res.StatusCode>=400{return nil,fmt.Errorf("API status %d",res.StatusCode)};var out map[string]any;err=json.NewDecoder(res.Body).Decode(&out);return out,err } diff --git a/examples/basic-app/generated/api/sdk/java/wrnexus-api.java b/examples/basic-app/generated/api/sdk/java/wrnexus-api.java new file mode 100644 index 00000000..d969b5e6 --- /dev/null +++ b/examples/basic-app/generated/api/sdk/java/wrnexus-api.java @@ -0,0 +1,3 @@ +package dev.wrnexus.sdk; +import java.net.URI; import java.net.http.*; +public final class WrnexusApi { private final String baseUrl; private final HttpClient http = HttpClient.newHttpClient(); public WrnexusApi(String baseUrl){this.baseUrl=baseUrl;} public String request(String method,String path,String json)throws Exception{var request=HttpRequest.newBuilder(URI.create(baseUrl+path)).header("content-type","application/json").method(method,HttpRequest.BodyPublishers.ofString(json==null?"":json)).build();var response=http.send(request,HttpResponse.BodyHandlers.ofString());if(response.statusCode()>=400)throw new IllegalStateException("API status "+response.statusCode());return response.body();} } diff --git a/examples/basic-app/generated/api/sdk/javascript/wrnexus-api.js b/examples/basic-app/generated/api/sdk/javascript/wrnexus-api.js new file mode 100644 index 00000000..abf71219 --- /dev/null +++ b/examples/basic-app/generated/api/sdk/javascript/wrnexus-api.js @@ -0,0 +1,58 @@ +const request = async (method, path, body, options = {}) => { + const response = await globalThis.fetch((options.baseUrl || "") + path, { + method, + headers: { "content-type": "application/json", ...(options.headers || {}) }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const value = await response.json(); + if (!response.ok) + throw Object.assign(new Error(value?.error?.message || "API request failed"), { + status: response.status, + body: value, + }); + return value.data ?? value; +}; +export const postWebhooksPayment = (params = {}, body, options = {}) => { + void params; + return request("POST", `/api/webhooks/payment`, body, options); +}; +export const getUsersCsr = (params = {}, body, options = {}) => { + void params; + return request("GET", `/api/users/csr`, body, options); +}; +export const getUsersSsr = (params = {}, body, options = {}) => { + void params; + return request("GET", `/api/users/ssr`, body, options); +}; +export const postGraphqlExample = (params = {}, body, options = {}) => { + void params; + return request("POST", `/api/graphql-example`, body, options); +}; +export const postTypedUser = (params = {}, body, options = {}) => { + void params; + return request("POST", `/api/typed-user`, body, options); +}; +export const postLogout = (params = {}, body, options = {}) => { + void params; + return request("POST", `/api/logout`, body, options); +}; +export const getHello = (params = {}, body, options = {}) => { + void params; + return request("GET", `/api/hello`, body, options); +}; +export const postLogin = (params = {}, body, options = {}) => { + void params; + return request("POST", `/api/login`, body, options); +}; +export const getEcho = (params = {}, body, options = {}) => { + void params; + return request("GET", `/api/echo`, body, options); +}; +export const postEcho = (params = {}, body, options = {}) => { + void params; + return request("POST", `/api/echo`, body, options); +}; +export const getMe = (params = {}, body, options = {}) => { + void params; + return request("GET", `/api/me`, body, options); +}; diff --git a/examples/basic-app/generated/api/sdk/python/wrnexus-api.py b/examples/basic-app/generated/api/sdk/python/wrnexus-api.py new file mode 100644 index 00000000..e82ca342 --- /dev/null +++ b/examples/basic-app/generated/api/sdk/python/wrnexus-api.py @@ -0,0 +1,19 @@ +import json, urllib.request + +class WrnexusApi: + def __init__(self, base_url): self.base_url = base_url.rstrip('/') + def request(self, method, path, body=None): + data = None if body is None else json.dumps(body).encode() + request = urllib.request.Request(self.base_url + path, data=data, method=method, headers={'content-type':'application/json'}) + with urllib.request.urlopen(request) as response: return json.load(response) + def postWebhooksPayment(self, path, body=None): return self.request('POST', path, body) + def getUsersCsr(self, path, body=None): return self.request('GET', path, body) + def getUsersSsr(self, path, body=None): return self.request('GET', path, body) + def postGraphqlExample(self, path, body=None): return self.request('POST', path, body) + def postTypedUser(self, path, body=None): return self.request('POST', path, body) + def postLogout(self, path, body=None): return self.request('POST', path, body) + def getHello(self, path, body=None): return self.request('GET', path, body) + def postLogin(self, path, body=None): return self.request('POST', path, body) + def getEcho(self, path, body=None): return self.request('GET', path, body) + def postEcho(self, path, body=None): return self.request('POST', path, body) + def getMe(self, path, body=None): return self.request('GET', path, body) diff --git a/examples/basic-app/generated/api/sdk/typescript/wrnexus-api.ts b/examples/basic-app/generated/api/sdk/typescript/wrnexus-api.ts new file mode 100644 index 00000000..a0b57cea --- /dev/null +++ b/examples/basic-app/generated/api/sdk/typescript/wrnexus-api.ts @@ -0,0 +1,109 @@ +export type RequestOptions = { baseUrl?: string; headers?: HeadersInit }; +type ApiEnvelope = { data?: unknown; error?: { message?: string } }; +const request = async ( + method: string, + path: string, + body: unknown, + options: RequestOptions = {}, +) => { + const response = await globalThis.fetch((options.baseUrl || "") + path, { + method, + headers: { "content-type": "application/json", ...(options.headers || {}) }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const value: ApiEnvelope = await response.json(); + if (!response.ok) + throw Object.assign(new Error(value?.error?.message || "API request failed"), { + status: response.status, + body: value, + }); + return value.data ?? value; +}; +export const postWebhooksPayment = ( + params: Record = {}, + body: unknown, + options: RequestOptions = {}, +) => { + void params; + return request("POST", `/api/webhooks/payment`, body, options); +}; +export const getUsersCsr = ( + params: Record = {}, + body: unknown, + options: RequestOptions = {}, +) => { + void params; + return request("GET", `/api/users/csr`, body, options); +}; +export const getUsersSsr = ( + params: Record = {}, + body: unknown, + options: RequestOptions = {}, +) => { + void params; + return request("GET", `/api/users/ssr`, body, options); +}; +export const postGraphqlExample = ( + params: Record = {}, + body: unknown, + options: RequestOptions = {}, +) => { + void params; + return request("POST", `/api/graphql-example`, body, options); +}; +export const postTypedUser = ( + params: Record = {}, + body: unknown, + options: RequestOptions = {}, +) => { + void params; + return request("POST", `/api/typed-user`, body, options); +}; +export const postLogout = ( + params: Record = {}, + body: unknown, + options: RequestOptions = {}, +) => { + void params; + return request("POST", `/api/logout`, body, options); +}; +export const getHello = ( + params: Record = {}, + body: unknown, + options: RequestOptions = {}, +) => { + void params; + return request("GET", `/api/hello`, body, options); +}; +export const postLogin = ( + params: Record = {}, + body: unknown, + options: RequestOptions = {}, +) => { + void params; + return request("POST", `/api/login`, body, options); +}; +export const getEcho = ( + params: Record = {}, + body: unknown, + options: RequestOptions = {}, +) => { + void params; + return request("GET", `/api/echo`, body, options); +}; +export const postEcho = ( + params: Record = {}, + body: unknown, + options: RequestOptions = {}, +) => { + void params; + return request("POST", `/api/echo`, body, options); +}; +export const getMe = ( + params: Record = {}, + body: unknown, + options: RequestOptions = {}, +) => { + void params; + return request("GET", `/api/me`, body, options); +}; diff --git a/examples/basic-app/package.json b/examples/basic-app/package.json index 03c8c5f0..b84de078 100644 --- a/examples/basic-app/package.json +++ b/examples/basic-app/package.json @@ -1,6 +1,6 @@ { "name": "basic-app", - "version": "0.1.0", + "version": "0.8.0", "private": true, "type": "module", "scripts": { @@ -22,13 +22,13 @@ }, "devDependencies": { "@wrnexus/test": "workspace:*", - "@eslint/js": "latest", + "@eslint/js": "^10.0.1", "@iconify-json/lucide": "^1.2.118", "@iconify/tailwind4": "^1.2.3", "@tailwindcss/cli": "^4.0.0", - "eslint": "latest", + "eslint": "^10.8.0", "prettier": "^3.9.4", "tailwindcss": "^4.0.0", - "typescript-eslint": "latest" + "typescript-eslint": "^8.65.0" } } diff --git a/examples/basic-app/railway.toml b/examples/basic-app/railway.toml new file mode 100644 index 00000000..c808a966 --- /dev/null +++ b/examples/basic-app/railway.toml @@ -0,0 +1,8 @@ +[build] +builder = "DOCKERFILE" + +[deploy] +startCommand = "bun dist/server.js" +healthcheckPath = "/readyz" +restartPolicyType = "ON_FAILURE" +preDeployCommand = ["bunx wrnexus db migrate --profile=production"] diff --git a/examples/basic-app/render.yaml b/examples/basic-app/render.yaml new file mode 100644 index 00000000..1064f5a0 --- /dev/null +++ b/examples/basic-app/render.yaml @@ -0,0 +1,11 @@ +services: + - type: web + name: wrnexus + runtime: docker + healthCheckPath: /readyz + preDeployCommand: bunx wrnexus db migrate --profile=production + envVars: + - key: DATABASE_URL + sync: false + - key: SESSION_SECRET + sync: false diff --git a/examples/basic-app/wrnexus.config.ts b/examples/basic-app/wrnexus.config.ts index 26c32e21..f2cb88eb 100644 --- a/examples/basic-app/wrnexus.config.ts +++ b/examples/basic-app/wrnexus.config.ts @@ -10,6 +10,8 @@ import type { AppConfig } from "@wrnexus/styles"; * framework-level headers and optional CORS. */ const config: AppConfig = { + frameworkBehaviour: 1, + compatibilityDate: "2026-08-02", head: [ // --- Use a CSS framework via CDN (uncomment one) --- // Bootstrap: diff --git a/examples/captcha-showcase/package.json b/examples/captcha-showcase/package.json index 334b3ae5..eca2ee2a 100644 --- a/examples/captcha-showcase/package.json +++ b/examples/captcha-showcase/package.json @@ -1,6 +1,6 @@ { "name": "captcha-showcase", - "version": "0.1.0", + "version": "0.8.0", "private": true, "type": "module", "scripts": { diff --git a/examples/component-showcase/package.json b/examples/component-showcase/package.json index fe1a2e85..fab9e218 100644 --- a/examples/component-showcase/package.json +++ b/examples/component-showcase/package.json @@ -1,6 +1,6 @@ { "name": "component-showcase", - "version": "0.1.0", + "version": "0.8.0", "private": true, "type": "module", "scripts": { diff --git a/examples/component-showcase/test/showcase.test.ts b/examples/component-showcase/test/showcase.test.ts index 8c2167a3..eab380f7 100644 --- a/examples/component-showcase/test/showcase.test.ts +++ b/examples/component-showcase/test/showcase.test.ts @@ -187,7 +187,7 @@ test("every declared public event is documented and visible in the playground", expect(source).toContain("data-playground-event-log"); for (const event of component.events) { expect(source).toContain(`@${event}`); - expect(source).toContain(`@${event}='console.log(event.detail)'`); + expect(source).toContain(`@${event}='console.log(payload)'`); } } }); diff --git a/examples/i18n-showcase/README.md b/examples/i18n-showcase/README.md new file mode 100644 index 00000000..48f9332b --- /dev/null +++ b/examples/i18n-showcase/README.md @@ -0,0 +1,14 @@ +# WRNexus i18n showcase + +A complete English, Hindi, and Marathi example covering SSR translations, translated attributes, +interpolation, plural rules, numbers, INR currency, percentages, dates, relative time, lists, +fallback chains, browser language negotiation, and cookie-persisted language selection. + +```bash +bun install +bun run --cwd examples/i18n-showcase dev +``` + +Open `http://localhost:3000`, change the language with the packaged `LanguageSwitcher`, and inspect +the document `` attribute and `wire-lang` cookie. The `/api/formats` endpoint demonstrates +request-aware `Intl` formatting using the same selected locale. diff --git a/examples/i18n-showcase/app/api/formats.ts b/examples/i18n-showcase/app/api/formats.ts new file mode 100644 index 00000000..79a57e93 --- /dev/null +++ b/examples/i18n-showcase/app/api/formats.ts @@ -0,0 +1,22 @@ +import type { Context } from "@wrnexus/core"; +import { createLocaleFormatter, formatMessage, plural } from "@wrnexus/i18n"; + +export const GET = async (ctx: Context) => { + const locale = ctx.lang || "en"; + const format = createLocaleFormatter(locale, "Asia/Kolkata"); + const count = 3; + + return Response.json({ + locale, + direction: ["ar", "fa", "he", "ur"].includes(locale.split("-")[0]!) ? "rtl" : "ltr", + translated: ctx.t("api.greeting", { name: "Asha" }), + interpolated: formatMessage(ctx.t("api.inbox"), { name: "Asha", count }, locale), + plural: plural(count, { one: ctx.t("api.itemOne"), other: ctx.t("api.itemOther") }, locale), + number: format.number(1_234_567.89), + currency: format.currency(1_234.5, "INR"), + percent: format.number(0.78, { style: "percent" }), + date: format.date("2026-08-15T09:30:00+05:30", { dateStyle: "full" }), + relativeTime: format.relative(-3, "day", { numeric: "auto" }), + list: format.list(["Mumbai", "Pune", "Nagpur"], { style: "long", type: "conjunction" }), + }); +}; diff --git a/examples/i18n-showcase/app/layouts/document.wrn b/examples/i18n-showcase/app/layouts/document.wrn new file mode 100644 index 00000000..7cbafccd --- /dev/null +++ b/examples/i18n-showcase/app/layouts/document.wrn @@ -0,0 +1,14 @@ +layout Document { + props { + language: string = "en" + } + + view { + + + +
+ + + } +} diff --git a/examples/i18n-showcase/app/locales/en.json b/examples/i18n-showcase/app/locales/en.json new file mode 100644 index 00000000..fcfc05c0 --- /dev/null +++ b/examples/i18n-showcase/app/locales/en.json @@ -0,0 +1,69 @@ +{ + "header": { + "eyebrow": "WRNexus i18n", + "title": "Internationalization showcase" + }, + "hero": { + "kicker": "Three languages, one page", + "title": "Everything needed for an international product", + "description": "The server chooses a language from the cookie or Accept-Language header and renders translated HTML." + }, + "sections": { "content": "Translated content" }, + "switcher": { + "label": "Language", + "placeholder": "Choose language", + "helper": "Saved securely in your language cookie", + "variantsTitle": "Responsive switcher variants", + "variantsDescription": "Every variant reads locales from configuration and the active language from the request cookie.", + "compactTitle": "Compact", + "segmentedTitle": "Segmented" + }, + "cards": { + "text": { + "title": "Text translation", + "body": "Headings, paragraphs, buttons, and labels are rendered on the server." + }, + "attributes": { + "title": "Attribute translation", + "label": "Search label", + "placeholder": "Search in English" + }, + "interpolation": { + "title": "Interpolation", + "body": "Parameters such as a person's name are inserted safely.", + "action": "Open the dynamic API" + }, + "plural": { + "title": "Plural rules", + "body": "One item and many items follow locale-aware CLDR rules." + }, + "numbers": { "title": "Numbers and currency" }, + "time": { "title": "Dates and relative time" }, + "list": { "title": "List formatting" }, + "fallback": { + "title": "Fallback chains", + "body": "Missing regional content falls back to the base language and then English." + }, + "document": { + "title": "Document language", + "body": "The same cookie sets the server-rendered html lang and dir attributes." + } + }, + "samples": { + "number": "1,234,567.89", + "currency": "₹1,234.50", + "percent": "78%", + "date": "15 August 2026", + "relative": "3 days ago", + "list": "Mumbai, Pune, and Nagpur" + }, + "api": { + "title": "Try request-aware formatting", + "description": "The JSON endpoint uses ctx.lang, translations, Intl formatters, interpolation, plurals, and lists.", + "action": "View localized JSON", + "greeting": "Hello, {name}!", + "inbox": "{name}, you have {count, plural, one {# message} other {# messages}}.", + "itemOne": "# item", + "itemOther": "# items" + } +} diff --git a/examples/i18n-showcase/app/locales/hi.json b/examples/i18n-showcase/app/locales/hi.json new file mode 100644 index 00000000..5ed71a34 --- /dev/null +++ b/examples/i18n-showcase/app/locales/hi.json @@ -0,0 +1,69 @@ +{ + "header": { + "eyebrow": "WRNexus अंतर्राष्ट्रीयकरण", + "title": "बहुभाषी उदाहरण" + }, + "hero": { + "kicker": "तीन भाषाएँ, एक पृष्ठ", + "title": "एक अंतर्राष्ट्रीय उत्पाद के लिए आवश्यक सब कुछ", + "description": "सर्वर कुकी या Accept-Language हेडर से भाषा चुनता है और अनुवादित HTML प्रस्तुत करता है।" + }, + "sections": { "content": "अनुवादित सामग्री" }, + "switcher": { + "label": "भाषा", + "placeholder": "भाषा चुनें", + "helper": "आपकी भाषा कुकी में सुरक्षित रूप से सहेजा गया", + "variantsTitle": "रेस्पॉन्सिव स्विचर प्रकार", + "variantsDescription": "हर प्रकार कॉन्फ़िगरेशन से भाषाएँ और अनुरोध कुकी से सक्रिय भाषा पढ़ता है।", + "compactTitle": "कॉम्पैक्ट", + "segmentedTitle": "खंडित" + }, + "cards": { + "text": { + "title": "पाठ अनुवाद", + "body": "शीर्षक, अनुच्छेद, बटन और लेबल सर्वर पर प्रस्तुत होते हैं।" + }, + "attributes": { + "title": "एट्रिब्यूट अनुवाद", + "label": "खोज लेबल", + "placeholder": "हिन्दी में खोजें" + }, + "interpolation": { + "title": "इंटरपोलेशन", + "body": "व्यक्ति के नाम जैसे पैरामीटर सुरक्षित रूप से जोड़े जाते हैं।", + "action": "डायनेमिक API खोलें" + }, + "plural": { + "title": "बहुवचन नियम", + "body": "एक और अनेक वस्तुएँ भाषा के CLDR नियमों का पालन करती हैं।" + }, + "numbers": { "title": "संख्याएँ और मुद्रा" }, + "time": { "title": "तिथियाँ और सापेक्ष समय" }, + "list": { "title": "सूची स्वरूपण" }, + "fallback": { + "title": "फॉलबैक श्रृंखला", + "body": "अनुपलब्ध क्षेत्रीय सामग्री पहले मूल भाषा और फिर अंग्रेज़ी में मिलती है।" + }, + "document": { + "title": "दस्तावेज़ की भाषा", + "body": "यही कुकी सर्वर द्वारा प्रस्तुत html lang और dir एट्रिब्यूट तय करती है।" + } + }, + "samples": { + "number": "12,34,567.89", + "currency": "₹1,234.50", + "percent": "78%", + "date": "15 अगस्त 2026", + "relative": "3 दिन पहले", + "list": "मुंबई, पुणे और नागपुर" + }, + "api": { + "title": "अनुरोध के अनुसार स्वरूपण आज़माएँ", + "description": "JSON एंडपॉइंट ctx.lang, अनुवाद, Intl फॉर्मैटर, इंटरपोलेशन, बहुवचन और सूचियों का उपयोग करता है।", + "action": "स्थानीयकृत JSON देखें", + "greeting": "नमस्ते, {name}!", + "inbox": "{name}, आपके पास {count, plural, one {# संदेश} other {# संदेश}} हैं।", + "itemOne": "# वस्तु", + "itemOther": "# वस्तुएँ" + } +} diff --git a/examples/i18n-showcase/app/locales/mr.json b/examples/i18n-showcase/app/locales/mr.json new file mode 100644 index 00000000..559f89ea --- /dev/null +++ b/examples/i18n-showcase/app/locales/mr.json @@ -0,0 +1,69 @@ +{ + "header": { + "eyebrow": "WRNexus आंतरराष्ट्रीयीकरण", + "title": "बहुभाषिक उदाहरण" + }, + "hero": { + "kicker": "तीन भाषा, एक पृष्ठ", + "title": "आंतरराष्ट्रीय उत्पादनासाठी आवश्यक असलेले सर्व काही", + "description": "सर्व्हर कुकी किंवा Accept-Language हेडरवरून भाषा निवडतो आणि भाषांतरित HTML प्रस्तुत करतो." + }, + "sections": { "content": "भाषांतरित मजकूर" }, + "switcher": { + "label": "भाषा", + "placeholder": "भाषा निवडा", + "helper": "तुमच्या भाषा कुकीमध्ये सुरक्षितपणे जतन केले", + "variantsTitle": "प्रतिसादक्षम स्विचर प्रकार", + "variantsDescription": "प्रत्येक प्रकार कॉन्फिगरेशनमधून भाषा आणि विनंती कुकीमधून सक्रिय भाषा घेतो.", + "compactTitle": "संक्षिप्त", + "segmentedTitle": "विभाजित" + }, + "cards": { + "text": { + "title": "मजकूर भाषांतर", + "body": "शीर्षके, परिच्छेद, बटणे आणि लेबले सर्व्हरवर प्रस्तुत होतात." + }, + "attributes": { + "title": "गुणधर्म भाषांतर", + "label": "शोध लेबल", + "placeholder": "मराठीत शोधा" + }, + "interpolation": { + "title": "इंटरपोलेशन", + "body": "व्यक्तीच्या नावासारखे पॅरामीटर सुरक्षितपणे जोडले जातात.", + "action": "डायनॅमिक API उघडा" + }, + "plural": { + "title": "अनेकवचन नियम", + "body": "एक आणि अनेक वस्तू स्थानिक CLDR नियमांचे पालन करतात." + }, + "numbers": { "title": "संख्या आणि चलन" }, + "time": { "title": "दिनांक आणि सापेक्ष वेळ" }, + "list": { "title": "यादी स्वरूपण" }, + "fallback": { + "title": "फॉलबॅक साखळी", + "body": "प्रादेशिक मजकूर उपलब्ध नसल्यास मूळ भाषा आणि नंतर इंग्रजी वापरली जाते." + }, + "document": { + "title": "दस्तावेजाची भाषा", + "body": "हीच कुकी सर्व्हरने प्रस्तुत केलेले html lang आणि dir गुणधर्म ठरवते." + } + }, + "samples": { + "number": "12,34,567.89", + "currency": "₹1,234.50", + "percent": "78%", + "date": "15 ऑगस्ट 2026", + "relative": "3 दिवसांपूर्वी", + "list": "मुंबई, पुणे आणि नागपूर" + }, + "api": { + "title": "विनंतीनुसार स्वरूपण वापरून पहा", + "description": "JSON एंडपॉइंट ctx.lang, भाषांतरे, Intl फॉर्मॅटर, इंटरपोलेशन, अनेकवचन आणि याद्या वापरतो.", + "action": "स्थानिकीकरण केलेला JSON पहा", + "greeting": "नमस्कार, {name}!", + "inbox": "{name}, तुमच्याकडे {count, plural, one {# संदेश} other {# संदेश}} आहेत.", + "itemOne": "# वस्तू", + "itemOther": "# वस्तू" + } +} diff --git a/examples/i18n-showcase/app/pages/index.wrn b/examples/i18n-showcase/app/pages/index.wrn new file mode 100644 index 00000000..1ffb8ef2 --- /dev/null +++ b/examples/i18n-showcase/app/pages/index.wrn @@ -0,0 +1,64 @@ +import LanguageSwitcher from "@wrnexus/i18n/components/LanguageSwitcher.wrn" + +page InternationalizationShowcase { + seo { + title = "Internationalization Showcase" + description = "English, Hindi, and Marathi localization with WRNexusJS." + } + + view { +
+
+
+
+

WRNexus i18n

+

Internationalization showcase

+
+ +
+
+ +
+
+

Three languages, one page

+

Everything needed for an international product

+

The server chooses a language from the cookie or Accept-Language header and renders translated HTML.

+
+ +
+

Responsive switcher variants

+

Every variant reads locales from configuration and the active language from the request cookie.

+
+
Compact
+
Segmented
+
+
+ +
+

Translated content

+
+

Text translation

Headings, paragraphs, buttons, and labels are rendered on the server.

+

Attribute translation

+ +

Plural rules

One item and many items follow locale-aware CLDR rules.

+

Numbers and currency

12,34,567.89

₹1,234.50

78%

+

Dates and relative time

15 August 2026

3 days ago

+

List formatting

Mumbai, Pune, and Nagpur

+

Fallback chains

Missing regional content falls back to the base language and then English.

+

Document language

The same cookie sets the server-rendered html lang and dir attributes.

+
+
+ +
+

Try request-aware formatting

+

The JSON endpoint uses ctx.lang, translations, Intl formatters, interpolation, plurals, and lists.

+ View localized JSON +
+
+
+ } +} diff --git a/examples/i18n-showcase/app/routes.gen.ts b/examples/i18n-showcase/app/routes.gen.ts new file mode 100644 index 00000000..a4c90a6b --- /dev/null +++ b/examples/i18n-showcase/app/routes.gen.ts @@ -0,0 +1,55 @@ +// AUTO-GENERATED by `wrnexus dev` - do not edit. +// Typed routes support required, optional, and catch-all parameters. + +export interface Routes { + "/": Record; +} + +export type RoutePath = keyof Routes; +type RouteValue = string | readonly string[] | undefined; + +function encodeRouteValue(value: RouteValue, catchAll: boolean): string { + if (value === undefined) return ""; + const values = Array.isArray(value) ? value : catchAll ? String(value).split("/") : [String(value)]; + return values.map((part) => encodeURIComponent(part)).join("/"); +} + +export function href

( + path: P, + ...args: keyof Routes[P] extends never + ? [] + : Record extends Routes[P] + ? [params?: Routes[P]] + : [params: Routes[P]] +): string { + const params = (args[0] ?? {}) as Record; + const output: string[] = []; + for (const segment of String(path).split("/").filter(Boolean)) { + let name: string | undefined; + let optional = false; + let catchAll = false; + if (segment.startsWith("[[") && segment.endsWith("]]")) { + optional = true; + name = segment.slice(2, -2); + } else if (segment.startsWith("[") && segment.endsWith("]")) { + name = segment.slice(1, -1); + if (name.endsWith("?")) { + optional = true; + name = name.slice(0, -1); + } + } + if (!name) { + output.push(segment); + continue; + } + if (name.startsWith("...")) { + catchAll = true; + name = name.slice(3); + } + const value = params[name]; + if (value === undefined && optional) continue; + if (value === undefined) throw new Error(`WRN-ROUTE-MISSING-PARAM: Missing route parameter '${name}'.`); + output.push(encodeRouteValue(value, catchAll)); + } + return "/" + output.filter(Boolean).join("/"); +} diff --git a/examples/i18n-showcase/app/styles/global.css b/examples/i18n-showcase/app/styles/global.css new file mode 100644 index 00000000..0d323d00 --- /dev/null +++ b/examples/i18n-showcase/app/styles/global.css @@ -0,0 +1,59 @@ +@import "tailwindcss"; +@plugin "@iconify/tailwind4"; +@source "../**/*.wrn"; +@source "../../../packages/i18n/components/*.wrn"; + +html { + color-scheme: light dark; +} + +body { + margin: 0; + font-family: "Plus Jakarta Sans", "Noto Sans Devanagari", ui-sans-serif, system-ui, sans-serif; +} + +/* The server sets from the language cookie, so each script can use + a typography stack designed for its glyph shapes and metrics. */ +html:lang(hi) body, +html:lang(mr) body { + font-family: "Noto Sans Devanagari", "Nirmala UI", Mangal, sans-serif; +} + +html[dir="rtl"] body { + text-align: start; +} + +.demo-card { + display: flex; + min-height: 12rem; + flex-direction: column; + border: 1px solid var(--wire-color-border); + border-radius: var(--wire-radius-xl); + background: var(--wire-color-surface); + padding: 1.25rem; +} + +.demo-card h3 { + margin: 0.75rem 0 0.35rem; + font-size: 1rem; +} + +.demo-card p { + margin: 0.2rem 0; + color: var(--wire-color-muted); + line-height: 1.6; +} + +.demo-icon { + width: 1.4rem; + height: 1.4rem; + color: var(--wire-color-primary); +} + +.demo-link { + margin-top: auto; + padding-top: 1rem; + color: var(--wire-color-primary); + font-size: 0.875rem; + font-weight: 600; +} diff --git a/examples/i18n-showcase/package.json b/examples/i18n-showcase/package.json new file mode 100644 index 00000000..4991c893 --- /dev/null +++ b/examples/i18n-showcase/package.json @@ -0,0 +1,25 @@ +{ + "name": "i18n-showcase", + "version": "0.8.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun run ../../packages/cli/src/index.ts dev .", + "build": "bun run ../../packages/cli/src/index.ts build .", + "test": "bun test test", + "typecheck": "tsc --noEmit -p tsconfig.json", + "check": "bun run typecheck && bun run test && bun run build" + }, + "dependencies": { + "@wrnexus/core": "workspace:*", + "@wrnexus/i18n": "workspace:*", + "@wrnexus/ui": "workspace:*" + }, + "devDependencies": { + "@iconify-json/lucide": "^1.2.118", + "@iconify/tailwind4": "^1.2.3", + "@tailwindcss/cli": "^4.0.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.9.2" + } +} diff --git a/examples/i18n-showcase/test/showcase.test.ts b/examples/i18n-showcase/test/showcase.test.ts new file mode 100644 index 00000000..a2b85ba0 --- /dev/null +++ b/examples/i18n-showcase/test/showcase.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { loadLocales, makeT, resolveI18n, resolveLang, translationCoverage } from "@wrnexus/i18n"; + +const localeDir = join(import.meta.dir, "../app/locales"); +const i18n = resolveI18n(loadLocales(localeDir, { strict: true }), { + default: "en", + locales: ["en", "hi", "mr"], + strict: true, +}); + +test("ships complete English, Hindi, and Marathi translations", () => { + expect(i18n.langs).toEqual(["en", "hi", "mr"]); + const coverage = translationCoverage(i18n); + expect(coverage.en?.percentage).toBe(100); + expect(coverage.hi?.percentage).toBe(100); + expect(coverage.mr?.percentage).toBe(100); + expect(makeT(i18n, "hi")("header.title")).toBe("बहुभाषी उदाहरण"); + expect(makeT(i18n, "mr")("header.title")).toBe("बहुभाषिक उदाहरण"); +}); + +test("resolves browser negotiation and persisted cookie preferences", () => { + expect(resolveLang(i18n, undefined, "mr-IN,hi;q=0.8,en;q=0.5")).toBe("mr"); + expect(resolveLang(i18n, "hi", "mr;q=1")).toBe("hi"); +}); + +test("uses the packaged language switcher and document shell", () => { + const page = readFileSync(join(import.meta.dir, "../app/pages/index.wrn"), "utf8"); + const document = readFileSync(join(import.meta.dir, "../app/layouts/document.wrn"), "utf8"); + expect(page).toContain( + 'import LanguageSwitcher from "@wrnexus/i18n/components/LanguageSwitcher.wrn"', + ); + expect(page).toContain(""); +}); diff --git a/examples/i18n-showcase/tsconfig.json b/examples/i18n-showcase/tsconfig.json new file mode 100644 index 00000000..21db131c --- /dev/null +++ b/examples/i18n-showcase/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { "noEmit": true }, + "include": ["app/**/*.ts", "test/**/*.ts", "wrnexus.config.ts"] +} diff --git a/examples/i18n-showcase/wrnexus.config.ts b/examples/i18n-showcase/wrnexus.config.ts new file mode 100644 index 00000000..72c261ce --- /dev/null +++ b/examples/i18n-showcase/wrnexus.config.ts @@ -0,0 +1,41 @@ +import type { AppConfig } from "@wrnexus/styles"; + +const config = { + seo: { + title: "WRNexus i18n Showcase", + description: "English, Hindi, and Marathi internationalization examples.", + robots: "noindex,nofollow", + }, + theme: { palette: "violet", default: "light" }, + fonts: { + google: [ + { family: "Plus Jakarta Sans", weights: [400, 500, 600, 700] }, + { family: "Noto Sans Devanagari", weights: [400, 500, 600, 700] }, + ], + display: "swap", + sans: '"Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif', + }, + // Keep localhost development deterministic. A service-worker navigation cache + // is URL-based and cannot distinguish pages rendered from different cookies. + pwa: { serviceWorker: false }, + i18n: { + default: "en", + locales: ["en", "hi", "mr"], + labels: { en: "English", hi: "हिन्दी", mr: "मराठी" }, + fallbacks: { hi: ["en"], mr: ["en"] }, + cookie: { name: "wire-lang", path: "/", maxAge: 31_536_000, sameSite: "Lax" }, + strict: true, + }, + styles: { + entry: "app/styles/global.css", + failureMode: "throw", + process: async ({ entryPath, appRoot, mode }) => { + if (!entryPath) throw new Error("The i18n showcase stylesheet was not resolved."); + const args = ["@tailwindcss/cli", "-i", entryPath]; + if (mode === "production") args.push("--minify"); + return await Bun.$.cwd(appRoot)`bunx ${args}`.text(); + }, + }, +} satisfies AppConfig; + +export default config; diff --git a/package.json b/package.json index 8bff0eb0..73ace17b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wrnexus", - "version": "0.7.0", + "version": "0.8.0", "private": true, "type": "module", "description": "An SSR-first full-stack web framework with server-rendered reactive components. Bun-first, Node-friendly.", @@ -15,6 +15,14 @@ "create": "bun run packages/cli/src/index.ts create", "example": "bun run dev", "test": "bun test packages", + "test:examples": "bun test --preload ./scripts/fail-on-test-warnings.ts examples", + "check:example-pages": "bun run scripts/check-example-pages.ts", + "test:services": "bun test services", + "test:editor": "bun run --cwd editors/vscode test && bun run --cwd editors/vscode validate", + "check:editor-compiler": "node scripts/build-editor-compiler.mjs --check", + "check:editor-language-server": "node scripts/build-editor-language-server.mjs --check", + "check:editor-extension": "node scripts/build-editor-extension.mjs --check", + "test:all": "bun run test && bun run test:examples && bun run test:services && bun run test:editor", "test:coverage": "bun test --coverage packages", "security:audit": "bun audit", "release:prepare": "bun run scripts/release.ts prepare", @@ -35,28 +43,41 @@ "validate:0.6": "node scripts/validate-0.6.mjs", "validate:0.7": "node scripts/validate-0.7.mjs", "security:framework": "node scripts/security-performance-audit.mjs", + "security:asvs": "node scripts/check-security-asvs.mjs", + "generate:security-report": "node scripts/security-performance-audit.mjs --write", + "check:public-api": "node scripts/check-public-api.mjs", + "generate:public-api": "node scripts/check-public-api.mjs --write", + "check:ui-visual": "node scripts/check-ui-visual-contract.mjs", + "generate:ui-visual": "node scripts/check-ui-visual-contract.mjs --write", "sbom": "node scripts/generate-sbom.mjs", "benchmark:framework": "node --experimental-transform-types scripts/benchmark-framework.mjs", - "check:production": "bun run check:workspace && bun run validate:0.7 && bun run security:framework && bun run check", + "check:production": "bun run check:workspace && bun run check:public-api && bun run check:ui-visual && bun run validate:0.8 && bun run security:framework && bun run security:asvs && bun run check", "validate:staging": "node --experimental-transform-types scripts/test-package-integrity.mjs", + "stage:packages": "bun run scripts/publish-packages.ts", + "test:staged-consumers": "node scripts/test-staged-consumers.mjs", "repair:workspace": "node scripts/repair-workspace.mjs", "test:workspace-repair": "node scripts/test-workspace-repair.mjs", - "check:workspace": "node scripts/repair-workspace.mjs --check" + "check:workspace": "node scripts/repair-workspace.mjs --check", + "validate:0.8": "node scripts/validate-0.8.mjs", + "audit:packages": "node scripts/audit-package-kits.mjs", + "generate:package-audit": "node scripts/audit-package-kits.mjs --write", + "test:package-kits": "node --experimental-transform-types scripts/test-package-kits.mjs" }, "devDependencies": { - "@eslint/js": "latest", - "@types/bun": "latest", - "eslint": "latest", - "happy-dom": "^20.10.6", - "prettier": "latest", + "@eslint/js": "^10.0.1", + "@types/bun": "^1.3.14", + "eslint": "^10.8.0", + "happy-dom": "^20.11.1", + "prettier": "^3.9.6", "tsup": "^8.5.1", - "typescript": "^5.5.0", - "typescript-eslint": "latest" + "typescript": "^5.9.3", + "typescript-eslint": "^8.65.0" }, "engines": { "bun": ">=1.3.0" }, "overrides": { - "esbuild": "0.28.1" + "esbuild": "0.28.1", + "brace-expansion": "5.0.8" } } diff --git a/packages/ai/README.md b/packages/ai/README.md index 05bd4c2e..94a2cd1c 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -1,5 +1,9 @@ # @wrnexus/ai +Provider-neutral AI orchestration for OpenAI, Anthropic, Google and local OpenAI-compatible models, +with streaming, structured output, tools, embeddings, vector search/RAG, conversation persistence, +templates, guardrails, usage events, fallback, rate limits and evaluation reports. + > A tiny, zero-dependency Claude (Anthropic) client for WrNexus apps — generate and stream text with Claude from any server-side code. Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework. @@ -120,6 +124,34 @@ try { } ``` +### Multi-provider client + +`createAIClient` adds named-provider selection and fallback, capability discovery, +validated JSON output, validated tool execution, abort-aware exponential retries, +and per-provider circuit breakers. Attempt events intentionally contain metadata +only: prompts, credentials, and raw model responses are never passed to telemetry. + +```ts +import { anthropicProvider, createAIClient } from "@wrnexus/ai"; + +const ai = createAIClient({ + providers: [anthropicProvider()], + retry: { attempts: 3, baseDelayMs: 100, maxDelayMs: 2_000 }, + circuitBreaker: { failureThreshold: 5, resetAfterMs: 30_000 }, +}); + +const result = await ai.generateObject<{ title: string }>("Return a JSON title", { + validate: (value): value is { title: string } => + typeof value === "object" && value !== null && "title" in value, +}); +``` + +Providers can return normalized `usage` (`inputTokens`, `outputTokens`, +`totalTokens`, and `costUsd`) and `toolCalls`. Use `executeTools` with a named, +validated tool registry; unknown tools and invalid arguments are rejected before +application code runs. `deterministicAIProvider` supplies ordered or computed +offline responses for tests and examples without API keys or network calls. + ## Usage ### Return generated JSON from an API route diff --git a/packages/ai/package.json b/packages/ai/package.json index 1f5113fb..c2a1669d 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,11 +1,12 @@ { "name": "@wrnexus/ai", - "version": "0.7.0", + "version": "0.8.0", "private": true, "type": "module", "description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.", "main": "src/index.ts", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./platform": "./src/platform.ts" } } diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index d16c780b..3f30f2b8 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -245,5 +245,46 @@ export function createAI(config: AIConfig = {}): AI { return { generate, stream, streamResponse }; } -export { anthropicProvider, aiProvider, createAIClient } from "./providers.ts"; -export type { AIUsage, AIResult, AIProvider, AIClient, AIClientOptions } from "./providers.ts"; +export { + anthropicProvider, + aiProvider, + createAIClient, + deterministicAIProvider, +} from "./providers.ts"; +export type { + AIAttemptEvent, + AICircuitBreakerOptions, + AIClient, + AIClientOptions, + AIProvider, + AIProviderCapabilities, + AIResult, + AIRetryOptions, + AITool, + AIToolCall, + AIUsage, + DeterministicAIProviderOptions, +} from "./providers.ts"; +export { + aiRateLimiter, + createRagPipeline, + evaluateAI, + googleAIProvider, + guardedProvider, + localAIProvider, + maxPromptLength, + memoryConversationStore, + memoryVectorStore, + openAIEmbeddings, + openAIProvider, + promptTemplate, +} from "./platform.ts"; +export type { + AIGuardrail, + ConversationStore, + EmbeddingProvider, + HttpAIProviderOptions, + VectorMatch, + VectorRecord, + VectorStore, +} from "./platform.ts"; diff --git a/packages/ai/src/platform.ts b/packages/ai/src/platform.ts new file mode 100644 index 00000000..d0beae70 --- /dev/null +++ b/packages/ai/src/platform.ts @@ -0,0 +1,353 @@ +import { AIError, type GenerateOptions, type Message } from "./index.ts"; +import type { AIProvider, AIResult } from "./providers.ts"; + +export interface HttpAIProviderOptions { + apiKey?: string; + model: string; + baseUrl?: string; + fetch?: typeof fetch; + headers?: HeadersInit; +} +function key(configured: string | undefined, name: string): string { + const value = configured ?? process.env[name]; + if (!value) throw new AIError(`Missing ${name}.`, 0, "authentication_error"); + return value; +} +function promptMessages(prompt: string | Message[], options?: GenerateOptions) { + const messages = + options?.messages ?? + (typeof prompt === "string" ? [{ role: "user" as const, content: prompt }] : prompt); + return options?.system ? [{ role: "system", content: options.system }, ...messages] : messages; +} + +export function openAIProvider(options: HttpAIProviderOptions): AIProvider { + const send = options.fetch ?? fetch; + const base = (options.baseUrl ?? "https://api.openai.com/v1").replace(/\/$/, ""); + return { + name: "openai", + capabilities: { streaming: false, structuredOutput: true, tools: true, usage: true }, + async generate(prompt, call = {}) { + const response = await send(`${base}/chat/completions`, { + method: "POST", + headers: { + authorization: `Bearer ${key(options.apiKey, "OPENAI_API_KEY")}`, + "content-type": "application/json", + ...Object.fromEntries(new Headers(options.headers)), + }, + body: JSON.stringify({ + model: call.model ?? options.model, + messages: promptMessages(prompt, call), + ...(call.maxTokens ? { max_completion_tokens: call.maxTokens } : {}), + }), + signal: call.signal, + }); + if (!response.ok) + throw new AIError(`OpenAI returned ${response.status}.`, response.status, "provider_error"); + const body = (await response.json()) as any; + return { + value: String(body.choices?.[0]?.message?.content ?? ""), + provider: "openai", + model: body.model, + finishReason: body.choices?.[0]?.finish_reason, + usage: { + inputTokens: body.usage?.prompt_tokens, + outputTokens: body.usage?.completion_tokens, + totalTokens: body.usage?.total_tokens, + }, + toolCalls: body.choices?.[0]?.message?.tool_calls?.map((tool: any) => ({ + id: String(tool.id), + name: String(tool.function?.name), + arguments: JSON.parse(tool.function?.arguments ?? "{}"), + })), + raw: body, + }; + }, + }; +} + +export function googleAIProvider(options: HttpAIProviderOptions): AIProvider { + const send = options.fetch ?? fetch; + const base = (options.baseUrl ?? "https://generativelanguage.googleapis.com/v1beta").replace( + /\/$/, + "", + ); + return { + name: "google", + capabilities: { streaming: false, structuredOutput: true, tools: true, usage: true }, + async generate(prompt, call = {}) { + const response = await send( + `${base}/models/${encodeURIComponent(call.model ?? options.model)}:generateContent?key=${encodeURIComponent(key(options.apiKey, "GOOGLE_AI_API_KEY"))}`, + { + method: "POST", + headers: { + "content-type": "application/json", + ...Object.fromEntries(new Headers(options.headers)), + }, + body: JSON.stringify({ + contents: promptMessages(prompt, call) + .filter((message) => message.role !== "system") + .map((message) => ({ + role: message.role === "assistant" ? "model" : "user", + parts: [{ text: message.content }], + })), + ...(call.system ? { systemInstruction: { parts: [{ text: call.system }] } } : {}), + }), + signal: call.signal, + }, + ); + if (!response.ok) + throw new AIError( + `Google AI returned ${response.status}.`, + response.status, + "provider_error", + ); + const body = (await response.json()) as any; + return { + value: String( + body.candidates?.[0]?.content?.parts?.map((part: any) => part.text ?? "").join("") ?? "", + ), + provider: "google", + model: call.model ?? options.model, + finishReason: body.candidates?.[0]?.finishReason, + usage: { + inputTokens: body.usageMetadata?.promptTokenCount, + outputTokens: body.usageMetadata?.candidatesTokenCount, + totalTokens: body.usageMetadata?.totalTokenCount, + }, + raw: body, + }; + }, + }; +} + +/** OpenAI-compatible local servers including Ollama, llama.cpp and vLLM. */ +export function localAIProvider( + options: Omit & { apiKey?: string }, +): AIProvider { + const provider = openAIProvider({ + ...options, + apiKey: options.apiKey ?? "local", + baseUrl: options.baseUrl ?? "http://localhost:11434/v1", + }); + return { + ...provider, + name: "local", + generate: async (prompt, call) => ({ + ...(await provider.generate(prompt, call)), + provider: "local", + }), + }; +} + +export interface EmbeddingProvider { + name: string; + embed( + values: string[], + options?: { model?: string; signal?: AbortSignal }, + ): Promise<{ vectors: number[][]; usage?: { tokens?: number } }>; +} +export function openAIEmbeddings(options: HttpAIProviderOptions): EmbeddingProvider { + return { + name: "openai", + async embed(values, call = {}) { + const response = await (options.fetch ?? fetch)( + `${(options.baseUrl ?? "https://api.openai.com/v1").replace(/\/$/, "")}/embeddings`, + { + method: "POST", + headers: { + authorization: `Bearer ${key(options.apiKey, "OPENAI_API_KEY")}`, + "content-type": "application/json", + }, + body: JSON.stringify({ model: call.model ?? options.model, input: values }), + signal: call.signal, + }, + ); + if (!response.ok) + throw new AIError(`Embedding provider returned ${response.status}.`, response.status); + const body = (await response.json()) as any; + return { + vectors: body.data.map((item: any) => item.embedding as number[]), + usage: { tokens: body.usage?.total_tokens }, + }; + }, + }; +} + +export interface VectorRecord> { + id: string; + vector: number[]; + text: string; + metadata: T; +} +export interface VectorMatch> extends VectorRecord { + score: number; +} +export interface VectorStore> { + upsert(records: VectorRecord[]): Promise; + query( + vector: number[], + limit?: number, + filter?: (metadata: T) => boolean, + ): Promise[]>; + delete(ids: string[]): Promise; +} +export function memoryVectorStore>(): VectorStore { + const records = new Map>(); + const cosine = (a: number[], b: number[]) => { + if (a.length !== b.length || !a.length) return 0; + let dot = 0, + aa = 0, + bb = 0; + for (let i = 0; i < a.length; i++) { + dot += a[i]! * b[i]!; + aa += a[i]! ** 2; + bb += b[i]! ** 2; + } + return aa && bb ? dot / Math.sqrt(aa * bb) : 0; + }; + return { + async upsert(values) { + for (const value of values) records.set(value.id, { ...value, vector: [...value.vector] }); + }, + async query(vector, limit = 5, filter) { + return [...records.values()] + .filter((record) => !filter || filter(record.metadata)) + .map((record) => ({ ...record, score: cosine(vector, record.vector) })) + .sort((a, b) => b.score - a.score) + .slice(0, Math.max(0, limit)); + }, + async delete(ids) { + for (const id of ids) records.delete(id); + }, + }; +} + +export function createRagPipeline(options: { + embeddings: EmbeddingProvider; + store: VectorStore; + generate: (prompt: string, options?: GenerateOptions) => Promise>; + maxContextCharacters?: number; +}) { + return { + async index(documents: Array<{ id: string; text: string; metadata: T }>) { + const embedded = await options.embeddings.embed(documents.map((document) => document.text)); + await options.store.upsert( + documents.map((document, index) => ({ ...document, vector: embedded.vectors[index]! })), + ); + return documents.length; + }, + async ask( + question: string, + call: GenerateOptions & { limit?: number; filter?: (metadata: T) => boolean } = {}, + ) { + const embedded = await options.embeddings.embed([question], { signal: call.signal }); + const matches = await options.store.query(embedded.vectors[0]!, call.limit, call.filter); + const context = matches + .map((match, index) => `[${index + 1}] ${match.text}`) + .join("\n\n") + .slice(0, options.maxContextCharacters ?? 12_000); + const result = await options.generate( + `Answer using only the supplied context. Cite sources as [n].\n\nContext:\n${context}\n\nQuestion: ${question}`, + call, + ); + return { ...result, sources: matches }; + }, + }; +} + +export interface ConversationStore { + load(id: string): Promise; + append(id: string, messages: Message[]): Promise; + clear(id: string): Promise; +} +export function memoryConversationStore(maxMessages = 100): ConversationStore { + const conversations = new Map(); + return { + async load(id) { + return [...(conversations.get(id) ?? [])]; + }, + async append(id, messages) { + conversations.set(id, [...(conversations.get(id) ?? []), ...messages].slice(-maxMessages)); + }, + async clear(id) { + conversations.delete(id); + }, + }; +} +export function promptTemplate(template: string) { + return (variables: Record) => + template.replace(/\{\{\s*([\w.-]+)\s*\}\}/g, (_match, name: string) => { + if (!(name in variables)) throw new Error(`WRN-AI-PROMPT-VARIABLE:${name}`); + return String(variables[name]); + }); +} +export type AIGuardrail = (input: { + prompt: string | Message[]; + output?: string; +}) => void | Promise; +export const maxPromptLength = + (maximum: number): AIGuardrail => + ({ prompt }) => { + const length = + typeof prompt === "string" + ? prompt.length + : prompt.reduce((sum, message) => sum + message.content.length, 0); + if (length > maximum) + throw new AIError("Prompt exceeds configured guardrail.", 400, "guardrail"); + }; +export function guardedProvider(provider: AIProvider, guardrails: AIGuardrail[]): AIProvider { + return { + ...provider, + async generate(prompt, options) { + for (const guardrail of guardrails) await guardrail({ prompt }); + const result = await provider.generate(prompt, options); + for (const guardrail of guardrails) await guardrail({ prompt, output: result.value }); + return result; + }, + }; +} +export function aiRateLimiter(options: { limit: number; windowMs: number; now?: () => number }) { + const buckets = new Map(); + const now = options.now ?? Date.now; + return (key: string) => { + const time = now(); + const bucket = buckets.get(key); + if (!bucket || bucket.reset <= time) { + buckets.set(key, { count: 1, reset: time + options.windowMs }); + return { allowed: true, remaining: options.limit - 1 }; + } + if (bucket.count >= options.limit) + return { allowed: false, remaining: 0, retryAfterMs: bucket.reset - time }; + bucket.count++; + return { allowed: true, remaining: options.limit - bucket.count }; + }; +} +export async function evaluateAI( + cases: Array<{ + name: string; + prompt: string; + expected?: string; + score?: (output: string) => number | Promise; + }>, + generate: (prompt: string) => Promise, +) { + const results = []; + for (const item of cases) { + const started = performance.now(); + const output = await generate(item.prompt); + const score = item.score + ? await item.score(output) + : item.expected === undefined + ? 1 + : output.includes(item.expected) + ? 1 + : 0; + results.push({ name: item.name, output, score, durationMs: performance.now() - started }); + } + return { + results, + score: results.length + ? results.reduce((sum, result) => sum + result.score, 0) / results.length + : 0, + }; +} diff --git a/packages/ai/src/providers.ts b/packages/ai/src/providers.ts index b2c9a87b..d01bd779 100644 --- a/packages/ai/src/providers.ts +++ b/packages/ai/src/providers.ts @@ -11,28 +11,79 @@ export interface AIUsage { inputTokens?: number; outputTokens?: number; totalTokens?: number; + costUsd?: number; } + export interface AIResult { value: T; provider: string; model?: string; usage?: AIUsage; finishReason?: string; + toolCalls?: AIToolCall[]; raw?: unknown; } + +export interface AIProviderCapabilities { + streaming?: boolean; + structuredOutput?: boolean; + tools?: boolean; + usage?: boolean; +} + +export interface AIToolCall { + id: string; + name: string; + arguments: unknown; +} + +export interface AITool { + name: string; + description?: string; + validate?: (value: unknown) => value is TInput; + execute(input: TInput, context: { signal?: AbortSignal }): TOutput | Promise; +} + export interface AIProvider { name: string; + capabilities?: AIProviderCapabilities; generate(prompt: string | Message[], options?: GenerateOptions): Promise>; stream?( prompt: string | Message[], options?: GenerateOptions, ): AsyncGenerator; } + +export interface AIRetryOptions { + attempts?: number; + baseDelayMs?: number; + maxDelayMs?: number; + retry?: (error: unknown) => boolean; +} + +export interface AICircuitBreakerOptions { + failureThreshold?: number; + resetAfterMs?: number; +} + export interface AIClientOptions { providers: AIProvider[]; fallback?: boolean; - onAttempt?: (provider: string, error?: unknown) => void | Promise; + retry?: AIRetryOptions; + circuitBreaker?: AICircuitBreakerOptions; + /** Receives metadata only; prompts, provider raw responses, and credentials are never emitted. */ + onAttempt?: (event: AIAttemptEvent) => void | Promise; } + +export interface AIAttemptEvent { + provider: string; + attempt: number; + outcome: "start" | "success" | "error" | "circuit-open"; + durationMs?: number; + error?: { name: string; type?: string; status?: number; message: string }; + usage?: AIUsage; +} + export interface AIClient { generate( prompt: string | Message[], @@ -42,40 +93,82 @@ export interface AIClient { prompt: string | Message[], options?: GenerateOptions & { provider?: string; validate?: (value: unknown) => value is T }, ): Promise>; + executeTools( + result: AIResult, + tools: AITool[], + options?: { signal?: AbortSignal }, + ): Promise>; stream( prompt: string | Message[], options?: GenerateOptions & { provider?: string }, ): AsyncGenerator; + capabilities(provider?: string): Record; } export function anthropicProvider(config: AIConfig = {}): AIProvider { const client = createAI(config); return { name: "anthropic", - async generate(prompt: string | Message[], options?: GenerateOptions) { + capabilities: { streaming: true, structuredOutput: true }, + async generate(prompt, options) { return { value: await client.generate(prompt, options), provider: "anthropic", model: options?.model ?? config.model, }; }, - stream: (prompt: string | Message[], options?: GenerateOptions) => - client.stream(prompt, options), + stream: (prompt, options) => client.stream(prompt, options), }; } -export function aiProvider(name: string, client: AI): AIProvider { +export function aiProvider( + name: string, + client: AI, + capabilities: AIProviderCapabilities = {}, +): AIProvider { return { name, - async generate(prompt: string | Message[], options?: GenerateOptions) { + capabilities: { streaming: true, ...capabilities }, + async generate(prompt, options) { return { value: await client.generate(prompt, options), provider: name, model: options?.model, }; }, - stream: (prompt: string | Message[], options?: GenerateOptions) => - client.stream(prompt, options), + stream: (prompt, options) => client.stream(prompt, options), + }; +} + +export interface DeterministicAIProviderOptions { + name?: string; + responses?: Array>; + handler?: ( + prompt: string | Message[], + options?: GenerateOptions, + ) => string | AIResult | Promise>; +} + +/** Offline provider for examples and tests. Responses are consumed in order. */ +export function deterministicAIProvider(options: DeterministicAIProviderOptions = {}): AIProvider { + let index = 0; + const name = options.name ?? "deterministic"; + return { + name, + capabilities: { streaming: true, structuredOutput: true, tools: true, usage: true }, + async generate(prompt, callOptions) { + const selected = options.handler + ? await options.handler(prompt, callOptions) + : options.responses?.[index++]; + if (selected === undefined) + throw new AIError("No deterministic response configured", 0, "provider_error"); + return typeof selected === "string" + ? { value: selected, provider: name } + : { ...selected, provider: name }; + }, + async *stream(prompt, callOptions) { + yield (await this.generate(prompt, callOptions)).value; + }, }; } @@ -84,27 +177,116 @@ function jsonText(value: string): string { return (fenced?.[1] ?? value).trim(); } +function safeError(error: unknown): AIAttemptEvent["error"] { + if (error instanceof AIError) + return { + name: error.name, + type: error.type, + status: error.status, + message: error.message.slice(0, 300), + }; + if (error instanceof Error) return { name: error.name, message: error.message.slice(0, 300) }; + return { name: "Error", message: "Unknown provider error" }; +} + +function defaultRetry(error: unknown): boolean { + return ( + error instanceof AIError && + (error.status === 408 || error.status === 429 || error.status >= 500) + ); +} + +function abortError(): Error { + return new DOMException("The operation was aborted", "AbortError"); +} + +async function delay(ms: number, signal?: AbortSignal): Promise { + if (signal?.aborted) throw signal.reason ?? abortError(); + await new Promise((resolve, reject) => { + const timer = setTimeout(resolve, ms); + signal?.addEventListener( + "abort", + () => { + clearTimeout(timer); + reject(signal.reason ?? abortError()); + }, + { once: true }, + ); + }); +} + export function createAIClient(options: AIClientOptions): AIClient { if (!options.providers.length) throw new Error("WRN-AI-NO-PROVIDERS"); + const duplicate = options.providers.find( + (provider, index) => + options.providers.findIndex((other) => other.name === provider.name) !== index, + ); + if (duplicate) throw new Error(`WRN-AI-DUPLICATE-PROVIDER:${duplicate.name}`); + const circuits = new Map(); const select = (name?: string) => name ? options.providers.filter((provider) => provider.name === name) : options.providers; + const attempts = Math.max(1, options.retry?.attempts ?? 1); + const failureThreshold = Math.max(1, options.circuitBreaker?.failureThreshold ?? 5); + const resetAfterMs = Math.max(0, options.circuitBreaker?.resetAfterMs ?? 30_000); + const generate: AIClient["generate"] = async (prompt, callOptions = {}) => { const providers = select(callOptions.provider); if (!providers.length) throw new AIError(`Unknown AI provider: ${callOptions.provider}`, 0, "provider_error"); let last: unknown; for (const provider of providers) { - try { - await options.onAttempt?.(provider.name); - return await provider.generate(prompt, callOptions); - } catch (error) { - last = error; - await options.onAttempt?.(provider.name, error); - if (options.fallback === false || callOptions.provider) throw error; + const circuit = circuits.get(provider.name) ?? { failures: 0 }; + if (circuit.openedAt !== undefined && Date.now() - circuit.openedAt < resetAfterMs) { + await options.onAttempt?.({ provider: provider.name, attempt: 0, outcome: "circuit-open" }); + last = new AIError(`Circuit is open for provider: ${provider.name}`, 0, "circuit_open"); + if (callOptions.provider) throw last; + continue; } + if (circuit.openedAt !== undefined) { + circuit.failures = 0; + circuit.openedAt = undefined; + } + for (let attempt = 1; attempt <= attempts; attempt++) { + if (callOptions.signal?.aborted) throw callOptions.signal.reason ?? abortError(); + const started = Date.now(); + await options.onAttempt?.({ provider: provider.name, attempt, outcome: "start" }); + try { + const result = await provider.generate(prompt, callOptions); + circuits.set(provider.name, { failures: 0 }); + await options.onAttempt?.({ + provider: provider.name, + attempt, + outcome: "success", + durationMs: Date.now() - started, + usage: result.usage, + }); + return result; + } catch (error) { + last = error; + circuit.failures++; + if (circuit.failures >= failureThreshold) circuit.openedAt = Date.now(); + circuits.set(provider.name, circuit); + await options.onAttempt?.({ + provider: provider.name, + attempt, + outcome: "error", + durationMs: Date.now() - started, + error: safeError(error), + }); + const retryable = (options.retry?.retry ?? defaultRetry)(error); + if (!retryable || attempt === attempts || callOptions.signal?.aborted) break; + const backoff = Math.min( + options.retry?.maxDelayMs ?? 5_000, + (options.retry?.baseDelayMs ?? 100) * 2 ** (attempt - 1), + ); + await delay(backoff, callOptions.signal); + } + } + if (options.fallback === false || callOptions.provider) throw last; } throw last; }; + return { generate, async generateObject( @@ -114,6 +296,16 @@ export function createAIClient(options: AIClientOptions): AIClient { validate?: (value: unknown) => value is T; } = {}, ) { + const candidates = select(callOptions.provider); + if ( + candidates.length && + candidates.every((provider) => provider.capabilities?.structuredOutput === false) + ) + throw new AIError( + "Selected provider does not support structured output", + 0, + "capability_error", + ); const result = await generate(prompt, callOptions); let value: unknown; try { @@ -129,11 +321,27 @@ export function createAIClient(options: AIClientOptions): AIClient { ); return { ...result, value: value as T }; }, + async executeTools(result, tools, toolOptions = {}) { + const registry = new Map(tools.map((tool) => [tool.name, tool])); + const output: Array<{ call: AIToolCall; value: unknown }> = []; + for (const call of result.toolCalls ?? []) { + if (toolOptions.signal?.aborted) throw toolOptions.signal.reason ?? abortError(); + const tool = registry.get(call.name); + if (!tool) throw new AIError(`Unknown AI tool: ${call.name}`, 0, "tool_error"); + if (tool.validate && !tool.validate(call.arguments)) + throw new AIError(`Invalid arguments for AI tool: ${call.name}`, 0, "tool_error"); + output.push({ + call, + value: await tool.execute(call.arguments, { signal: toolOptions.signal }), + }); + } + return output; + }, async *stream(prompt, callOptions = {}) { const providers = select(callOptions.provider); let last: unknown; for (const provider of providers) { - if (!provider.stream) continue; + if (!provider.stream || provider.capabilities?.streaming === false) continue; try { yield* provider.stream(prompt, callOptions); return; @@ -146,5 +354,10 @@ export function createAIClient(options: AIClientOptions): AIClient { const result = await generate(prompt, callOptions); yield result.value; }, + capabilities(provider) { + return Object.fromEntries( + select(provider).map((item) => [item.name, { ...item.capabilities }]), + ); + }, }; } diff --git a/packages/ai/test/ai.test.ts b/packages/ai/test/ai.test.ts index ea84b29b..ea3386db 100644 --- a/packages/ai/test/ai.test.ts +++ b/packages/ai/test/ai.test.ts @@ -1,5 +1,11 @@ import { afterEach, expect, test } from "bun:test"; -import { AIError, createAI } from "../src/index.ts"; +import { + AIError, + createAI, + createAIClient, + deterministicAIProvider, + type AIProvider, +} from "../src/index.ts"; const originalFetch = globalThis.fetch; afterEach(() => { @@ -54,3 +60,76 @@ test("streams fragmented SSE and keeps a final event without a newline", async ( for await (const chunk of createAI({ apiKey: "secret" }).stream("Hi")) chunks.push(chunk); expect(chunks).toEqual(["A", "B"]); }); + +test("generates and validates structured output with an offline provider", async () => { + const client = createAIClient({ + providers: [deterministicAIProvider({ responses: ['```json\n{"ok":true}\n```'] })], + }); + const result = await client.generateObject<{ ok: boolean }>("ignored", { + validate: (value): value is { ok: boolean } => + typeof value === "object" && value !== null && "ok" in value, + }); + expect(result.value).toEqual({ ok: true }); + expect(client.capabilities().deterministic?.structuredOutput).toBe(true); +}); + +test("retries transient failures and reports redacted metadata", async () => { + let calls = 0; + const events: unknown[] = []; + const provider: AIProvider = { + name: "retrying", + async generate() { + calls++; + if (calls === 1) throw new AIError("temporary secret-token", 503, "overloaded"); + return { value: "done", provider: "retrying", usage: { totalTokens: 3, costUsd: 0.01 } }; + }, + }; + const result = await createAIClient({ + providers: [provider], + retry: { attempts: 2, baseDelayMs: 0 }, + onAttempt: (event) => { + events.push(event); + }, + }).generate("private prompt"); + expect(result.value).toBe("done"); + expect(calls).toBe(2); + expect(JSON.stringify(events)).not.toContain("private prompt"); + expect(JSON.stringify(events)).not.toContain("raw"); +}); + +test("opens a provider circuit and falls back", async () => { + let failedCalls = 0; + const failing: AIProvider = { + name: "failing", + async generate() { + failedCalls++; + throw new AIError("down", 503); + }, + }; + const client = createAIClient({ + providers: [failing, deterministicAIProvider({ responses: ["one", "two"] })], + retry: { attempts: 1 }, + circuitBreaker: { failureThreshold: 1, resetAfterMs: 60_000 }, + }); + expect((await client.generate("first")).value).toBe("one"); + expect((await client.generate("second")).value).toBe("two"); + expect(failedCalls).toBe(1); +}); + +test("executes validated tool calls and forwards cancellation", async () => { + const client = createAIClient({ providers: [deterministicAIProvider({ responses: ["ok"] })] }); + const result = { + value: "", + provider: "deterministic", + toolCalls: [{ id: "1", name: "double", arguments: { value: 4 } }], + }; + const output = await client.executeTools(result, [ + { + name: "double", + validate: (value): value is { value: number } => + typeof value === "object" && value !== null && "value" in value, + execute: ({ value }) => value * 2, + }, + ]); + expect(output[0]?.value).toBe(8); +}); diff --git a/packages/ai/test/platform.test.ts b/packages/ai/test/platform.test.ts new file mode 100644 index 00000000..d9f001c3 --- /dev/null +++ b/packages/ai/test/platform.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "bun:test"; +import { + aiRateLimiter, + createRagPipeline, + evaluateAI, + googleAIProvider, + guardedProvider, + localAIProvider, + maxPromptLength, + memoryConversationStore, + memoryVectorStore, + openAIProvider, + promptTemplate, +} from "../src/platform.ts"; + +describe("AI platform", () => { + test("adapts OpenAI, Google and local providers", async () => { + const openFetch = (async () => + Response.json({ + model: "gpt", + choices: [{ message: { content: "hello" }, finish_reason: "stop" }], + usage: { total_tokens: 3 }, + })) as unknown as typeof fetch; + expect( + (await openAIProvider({ model: "gpt", apiKey: "key", fetch: openFetch }).generate("hi")) + .value, + ).toBe("hello"); + const googleFetch = (async () => + Response.json({ + candidates: [{ content: { parts: [{ text: "hola" }] } }], + usageMetadata: { totalTokenCount: 2 }, + })) as unknown as typeof fetch; + expect( + ( + await googleAIProvider({ model: "gemini", apiKey: "key", fetch: googleFetch }).generate( + "hi", + ) + ).value, + ).toBe("hola"); + expect( + (await localAIProvider({ model: "llama", fetch: openFetch }).generate("hi")).provider, + ).toBe("local"); + }); + + test("indexes and retrieves a RAG answer", async () => { + const store = memoryVectorStore<{ source: string }>(); + const embeddings = { + name: "test", + embed: async (values: string[]) => ({ + vectors: values.map((value) => (value.includes("Bun") ? [1, 0] : [0, 1])), + }), + }; + const rag = createRagPipeline({ + embeddings, + store, + generate: async (prompt) => ({ + value: prompt.includes("fast") ? "Bun [1]" : "none", + provider: "test", + }), + }); + await rag.index([ + { id: "bun", text: "Bun is fast", metadata: { source: "docs" } }, + { id: "other", text: "Other", metadata: { source: "other" } }, + ]); + const result = await rag.ask("Bun runtime"); + expect(result.value).toBe("Bun [1]"); + expect(result.sources[0]?.id).toBe("bun"); + }); + + test("persists conversations, renders prompts, guards and rate limits", async () => { + const conversations = memoryConversationStore(2); + await conversations.append("one", [ + { role: "user", content: "a" }, + { role: "assistant", content: "b" }, + { role: "user", content: "c" }, + ]); + expect(await conversations.load("one")).toHaveLength(2); + expect(promptTemplate("Hello {{name}}")({ name: "Wire" })).toBe("Hello Wire"); + const provider = guardedProvider( + { name: "test", generate: async () => ({ value: "ok", provider: "test" }) }, + [maxPromptLength(3)], + ); + await expect(provider.generate("long")).rejects.toThrow("guardrail"); + const limit = aiRateLimiter({ limit: 1, windowMs: 100, now: () => 0 }); + expect(limit("u").allowed).toBe(true); + expect(limit("u").allowed).toBe(false); + }); + + test("evaluates model output", async () => { + const report = await evaluateAI( + [{ name: "answer", prompt: "question", expected: "42" }], + async () => "42", + ); + expect(report.score).toBe(1); + }); +}); diff --git a/packages/auth/README.md b/packages/auth/README.md index a1b2c585..4157d954 100644 --- a/packages/auth/README.md +++ b/packages/auth/README.md @@ -368,3 +368,14 @@ bun run validate:auth ``` Read [SECURITY.md](./SECURITY.md) before production deployment. + +## Package-owned UI blocks and route helpers + +Authentication forms continue to compose `@wrnexus/ui` inputs, buttons, cards, alerts, badges, avatars, and PIN controls. The package also provides: + +- `` +- `` +- `` +- complete sign-in, sign-up, MFA, passkey, recovery, account-status, session, and impersonation blocks + +Server helpers include `authRoute`, `authSuccess`, `authFailure`, `requireAuthUser`, `optionalAuthUser`, `currentAuthSession`, and `authComponentProps`. diff --git a/packages/auth/components/AccountStatus.wrn b/packages/auth/components/AccountStatus.wrn index e307e6ee..09bf3135 100644 --- a/packages/auth/components/AccountStatus.wrn +++ b/packages/auth/components/AccountStatus.wrn @@ -1,11 +1,27 @@ component AccountStatus { - props { status = "active" title = "Account status" activeMessage = "Your account is active and ready to use." pendingMessage = "Verify your contact details to activate your account." lockedMessage = "Your account is temporarily locked for security." disabledMessage = "Your account has been disabled." supportHref = "/support" color = "primary" size = "md" class = "" } + props { + status: string = "active" + title: string = "Account status" + activeMessage: string = "Your account is active and ready to use." + pendingMessage: string = "Verify your contact details to activate your account." + lockedMessage: string = "Your account is temporarily locked for security." + disabledMessage: string = "Your account has been disabled." + supportHref: string = "/support" + color: string = "primary" + size: string = "md" + class: string = "" + } + view { -

- {#if status == "active"}{:else if status == "pending"}{:else}{/if} -

{title}

-

{status == "active" ? activeMessage : status == "pending" ? pendingMessage : status == "locked" ? lockedMessage : disabledMessage}

- {#if status != "active"}Contact support{/if} -
+ +
+ + {#if status == "active"}{:else if status == "pending"}{:else}{/if} + +

{status == "active" ? activeMessage : status == "pending" ? pendingMessage : status == "locked" ? lockedMessage : disabledMessage}

+ + {#if status != "active"}
+
} } diff --git a/packages/auth/components/AuthProviderButtons.wrn b/packages/auth/components/AuthProviderButtons.wrn new file mode 100644 index 00000000..de0ac62c --- /dev/null +++ b/packages/auth/components/AuthProviderButtons.wrn @@ -0,0 +1,43 @@ +component AuthProviderButtons { + outputs { + select(payload: { provider: string; href: string }) + } + + props { + providers: unknown[] = [] + title: string = "Continue with" + dividerLabel: string = "or" + color: string = "primary" + size: string = "md" + class: string = "" + } + + functions { + client function choose(provider) { + output.select({ provider: provider.id || provider.name || provider.label, href: provider.href || "" }) + } + } + + view { +
+ {#if title}

{title}

{/if} +
+ {#each providers as provider} +
+ {#if dividerLabel} +
{dividerLabel}
+ {/if} +
+ } +} diff --git a/packages/auth/components/AuthSecurityNotice.wrn b/packages/auth/components/AuthSecurityNotice.wrn new file mode 100644 index 00000000..76cf776f --- /dev/null +++ b/packages/auth/components/AuthSecurityNotice.wrn @@ -0,0 +1,22 @@ +component AuthSecurityNotice { + props { + title: string = "Security notice" + description: string = "Your session and credentials are protected by WRNexusJS security controls." + color: string = "info" + size: string = "sm" + class: string = "" + } + + view { + + } +} diff --git a/packages/auth/components/AuthShell.wrn b/packages/auth/components/AuthShell.wrn new file mode 100644 index 00000000..00442c5f --- /dev/null +++ b/packages/auth/components/AuthShell.wrn @@ -0,0 +1,37 @@ +component AuthShell { + props { + title: string = "Welcome" + description: string = "" + eyebrow: string = "" + icon: string = "icon-[lucide--shield-check]" + footer: string = "" + color: string = "primary" + size: string = "md" + maxWidth: string = "md" + class: string = "" + } + + view { + +
+ +
+ +
+ } +} diff --git a/packages/auth/components/DeviceSessions.wrn b/packages/auth/components/DeviceSessions.wrn index f0901b2b..7ff1373a 100644 --- a/packages/auth/components/DeviceSessions.wrn +++ b/packages/auth/components/DeviceSessions.wrn @@ -1,40 +1,39 @@ component DeviceSessions { props { - sessions = [] - currentSessionId = "" - title = "Active sessions" - description = "Review devices signed in to your account." - revokeAction = "/api/auth/sessions/revoke" - revokeSchema = "auth-session-revoke" - revokeLabel = "Sign out" - successMessage = "Session revoked." - color = "primary" - size = "md" - class = "" + sessions: unknown[] = [] + currentSessionId: string = "" + title: string = "Active sessions" + description: string = "Review devices signed in to your account." + revokeAction: string = "/api/auth/sessions/revoke" + revokeSchema: string = "auth-session-revoke" + revokeLabel: string = "Sign out" + successMessage: string = "Session revoked." + color: string = "primary" + size: string = "md" + class: string = "" } + view { -
-

{title}

-

{description}

-
+ +
{#each sessions as session}
- +

{session.userAgent || "Unknown device"}

{session.ip || "Unknown IP"} · Last active {session.lastSeenAt}

{#if session.id == currentSessionId} - Current + {:else}

- +
{/each}
-
+ } } diff --git a/packages/auth/components/ImpersonationBanner.wrn b/packages/auth/components/ImpersonationBanner.wrn index e034049d..0090287c 100644 --- a/packages/auth/components/ImpersonationBanner.wrn +++ b/packages/auth/components/ImpersonationBanner.wrn @@ -1,22 +1,25 @@ component ImpersonationBanner { props { - visible = true - targetName = "this user" - stopAction = "/api/auth/impersonation/stop" - stopSchema = "auth-empty" - redirect = "/account" - message = "You are viewing the application as" - stopLabel = "Stop impersonating" - color = "warning" - size = "md" - class = "" + visible: boolean = true + targetName: string = "this user" + stopAction: string = "/api/auth/impersonation/stop" + stopSchema: string = "auth-empty" + redirect: string = "/account" + message: string = "You are viewing the application as" + stopLabel: string = "Stop impersonating" + color: string = "warning" + size: string = "md" + class: string = "" } + view { {#if visible} - + +
+ +
+ - -
+ + + +
} }\n", + ); + writeFileSync(join(root, "app/api/users.ts"), "export default () => new Response('ok');\n"); + writeFileSync(join(root, "app/realtime/chat.ts"), "export default {};\n"); + writeFileSync(join(root, "app/queues/email.ts"), "export default {};\n"); + writeFileSync(join(root, "app/locales/en.json"), JSON.stringify({ common: { save: "Save" } })); + return root; +} + +test("generate types emits application-wide deterministic contracts", () => { + const root = fixture(); + const result = generateApplicationTypes(root); + const output = readFileSync(join(root, result.file), "utf8"); + expect(existsSync(join(root, "app/routes.gen.ts"))).toBe(true); + expect(output).toContain('type RouteName = "index" | "users.id"'); + expect(output).toContain('type EnvironmentKey = "PUBLIC_API_URL"'); + expect(output).toContain('type TranslationKey = "common.save"'); + expect(output).toContain('type QueueName = "email"'); + expect(output).toContain('"Button": { props: { "label": string }'); + expect(output).toContain("interface ApiContracts"); + expect(output).toContain('"/api/users": { default: ApiContract<'); + expect(output).toContain("interface RealtimeMessages"); + expect(output).toContain('"/realtime/chat": RealtimeMessage<'); + expect(output).toContain("interface QueuePayloads"); + expect(output).toContain('"email": QueuePayload<'); +}); + +test("application checker validates every wrn source", () => { + expect(checkApplication(fixture()).filter((item) => item.category === "error")).toEqual([]); +}, 15_000); + +test("component inspection exposes its typed public contract", () => { + const value = inspectComponent(fixture(), "button") as { name: string; props: unknown[] }; + expect(value.name).toBe("Button"); + expect(value.props).toEqual([{ name: "label", type: "string", required: true }]); +}); diff --git a/packages/cli/test/update-v060.test.ts b/packages/cli/test/update-v060.test.ts index 499b3d38..82bc915c 100644 --- a/packages/cli/test/update-v060.test.ts +++ b/packages/cli/test/update-v060.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { migrateV060WrnSource } from "../src/update.ts"; +import { formatCurrentWrnSource, migrateV060WrnSource } from "../src/update.ts"; const report = () => ({ changedAutomatically: [], @@ -30,4 +30,16 @@ describe("v0.6 source migration", () => { expect(first).toContain("output.confirm({ ok: true })"); expect(second).toBe(first); }); + + test("uses the canonical framework formatter idempotently", () => { + const source = `page Home { +view { + +} +}`; + const formatted = formatCurrentWrnSource(source); + + expect(formatted).toContain(" { + const root = mkdtempSync(join(tmpdir(), "wrnexus-update-current-source-")); + mkdirSync(join(root, "app", "components"), { recursive: true }); + mkdirSync(join(root, "app", "layouts"), { recursive: true }); + mkdirSync(join(root, "app", "pages"), { recursive: true }); + writeFileSync( + join(root, "package.json"), + JSON.stringify({ + name: "source-app", + dependencies: { "@wrnexus/core": "^0.7.0" }, + wrnexus: { version: "0.7.0" }, + }), + ); + writeFileSync( + join(root, "app", "components", "Notice.wrn"), + 'component Notice {\r\n props { label = "Ready" count = 1 } \r\n view {

{label}

}\r\n}', + ); + writeFileSync( + join(root, "app", "layouts", "shell.wrn"), + "layout Shell {\n view {
}\n}\n", + ); + writeFileSync( + join(root, "app", "pages", "index.wrn"), + 'page Home {\n layout = "shell"\n view { }\n}\n', + ); + + try { + updateApp(root, "0.8.0", false); + const first = readFileSync(join(root, "app", "pages", "index.wrn"), "utf8"); + expect(first).toContain('import Notice from "@/components/Notice.wrn"'); + expect(first).toContain('import Shell from "@/layouts/shell.wrn"'); + expect(first).toContain("layout = Shell"); + expect(first).toContain("label='{\"Updated\"}'"); + expect(first.endsWith("\n")).toBe(true); + + const component = readFileSync(join(root, "app", "components", "Notice.wrn"), "utf8"); + expect(component).toContain('props {\n label = "Ready"\n count = 1\n }'); + expect(component).not.toContain("\r"); + + const reportPath = join(root, ".wrnexus", "migrations", "0.8.0-source-modernization.json"); + const report = JSON.parse(readFileSync(reportPath, "utf8")); + expect(report.changedFiles).toContain("app/pages/index.wrn"); + expect(report.unresolvedImports).toContain( + "app/pages/index.wrn: component 'Missing' could not be resolved", + ); + + updateApp(root, "0.8.0", false); + expect(readFileSync(join(root, "app", "pages", "index.wrn"), "utf8")).toBe(first); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/cli/test/workspace.test.ts b/packages/cli/test/workspace.test.ts index 42a5f31a..11cdc4b7 100644 --- a/packages/cli/test/workspace.test.ts +++ b/packages/cli/test/workspace.test.ts @@ -47,8 +47,14 @@ test("workspace templates pin the running framework release", () => { expect(files["README.md"]).toContain("http://127.0.0.1:3000"); expect(files["README.md"]).toContain("internal gateway targets"); expect(JSON.parse(files["package.json"]!).scripts.production).toBe("wrnexus production"); + expect(JSON.parse(files["package.json"]!).scripts.check).toContain("typecheck"); + expect(JSON.parse(files["package.json"]!).scripts.check).toContain("format:check"); expect(files["wrnexus.workspace.ts"]).toContain('runtime: "development"'); expect(files["wrnexus.workspace.ts"]).toContain("hmr: false"); + expect(files[".env.example"]).toContain("REDIS_URL"); + expect(files["eslint.config.js"]).toContain("typescript-eslint"); + expect(files["tsconfig.json"]).toContain('"strict": true'); + expect(files[".vscode/extensions.json"]).toContain("wrnexus.wrnexus"); }); test("production workspace detects default and named SQL migrations", () => { diff --git a/packages/compiler/README.md b/packages/compiler/README.md index 7baf52ee..4fec3363 100644 --- a/packages/compiler/README.md +++ b/packages/compiler/README.md @@ -1,9 +1,44 @@ # @wrnexus/compiler +## Partial-static rendering + +Pages can select `render = "partial-static"` and divide their view with `` and +`` boundaries. The compiler emits a build-only shell renderer that never evaluates +dynamic-boundary children. `wrnexus build` expands static component mounts into +`dist/partial-shells.json`, records byte/region evidence in `build-report.json`, and embeds +the shell in the production route manifest. At request time the production runtime retains +request-aware layouts, locale/theme metadata and security nonces while streaming dynamic +regions into stable placeholders. + > Compiler for the `.wrn` language — tokenizes, parses, and lowers `.wrn` page and component files to TypeScript. Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework. +Production adapters use `analyzeRuntimeImports` before bundling. Edge, worker, +service-worker, and browser targets reject Node filesystem, TCP, and process +modules with `WRN-RUNTIME-CAPABILITY`. Package manifests can declare supported +`wrnexus.runtimes` and required `wrnexus.requires` capabilities; discovery fails +when the selected deployment cannot satisfy them. + +## Server actions + +```wrn +action createUser using CreateUserSchema { + const user = await users.create(input) + invalidate("users") + return user +} + +view { +
...
+} +``` + +The compiler produces a schema-aware server registry, a fully inferred action +client, and progressively enhanced form metadata. The shared runtime performs +validation, authentication/permission checks, CSRF verification, serialization, +invalidation reporting, and browser lifecycle events. + ## Overview `@wrnexus/compiler` turns `.wrn` source into TypeScript that targets the framework's runtime primitives. A `.wrn` file declares either a `page` (a route) or a `component` (a reusable, prop-driven fragment) with blocks for `state`, `view` (plain HTML), `seo`, `style`, `functions`, `api`, `ssr`/`client` data bindings, and `realtime` websocket handlers. The pipeline is `source → Lexer → parse() → PageAst → generate() → TypeScript`. It is a build/server-side library — the WrNexus dev loader calls it to compile `.wrn` files on the fly, surfacing `ParseError` as a readable error page. diff --git a/packages/compiler/package.json b/packages/compiler/package.json index 833df827..1152384d 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -1,13 +1,15 @@ { "name": "@wrnexus/compiler", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "main": "src/index.ts", "exports": { ".": "./src/index.ts" }, "dependencies": { + "@wrnexus/csr": "workspace:*", "@wrnexus/syntax": "workspace:*", - "@wrnexus/store": "workspace:*" + "@wrnexus/store": "workspace:*", + "@wrnexus/validation": "workspace:*" } } diff --git a/packages/compiler/src/analysis.ts b/packages/compiler/src/analysis.ts index aa9b7322..da0a4f16 100644 --- a/packages/compiler/src/analysis.ts +++ b/packages/compiler/src/analysis.ts @@ -15,6 +15,185 @@ export interface RuntimeRequirements { needsServerRuntime: boolean; hydrationStrategy: string | null; reasons: string[]; + optimization: OptimizationReport; + cachePolicy: Record; + requiredPermission: string | null; +} + +export interface OptimizationReport { + staticNodes: number; + reactiveRegions: number; + eliminatedBranches: number; + unusedState: string[]; + unusedHandlers: string[]; + constantProps: string[]; + unusedLocalCssClasses: string[]; + batchableStateUpdates: number; + memoizableComponents: string[]; + preloadDependencies: string[]; + serverOnlyModules: string[]; +} + +function identifiers(value: string): Set { + return new Set(value.match(/[A-Za-z_$][\w$]*/g) ?? []); +} + +function literalBoolean(expression: string | null): boolean | undefined { + if (expression === null) return true; + const value = expression.trim(); + if (value === "true") return true; + if ( + value === "false" || + value === "null" || + value === "undefined" || + value === "0" || + value === "''" || + value === '""' + ) + return false; + if (/^-?(?:[1-9]\d*|0?\.\d+)$/.test(value) || /^(['"]).+\1$/.test(value)) return true; + return undefined; +} + +function optimizeNodes(nodes: ViewNode[], report: { eliminated: number }): ViewNode[] { + const output: ViewNode[] = []; + for (const node of nodes) { + if (node.type === "element") + output.push({ + ...node, + attrs: node.attrs.map((attribute) => ({ ...attribute })), + children: optimizeNodes(node.children, report), + }); + else if (node.type === "each") + output.push({ + ...node, + body: optimizeNodes(node.body, report), + empty: optimizeNodes(node.empty, report), + }); + else if (node.type === "if") { + let selected: ViewNode[] | undefined; + let dynamic = false; + for (const branch of node.branches) { + const value = literalBoolean(branch.cond); + if (value === undefined) { + dynamic = true; + break; + } + report.eliminated++; + if (value) { + selected = branch.body; + break; + } + } + if (dynamic) + output.push({ + ...node, + branches: node.branches.map((branch) => ({ + ...branch, + body: optimizeNodes(branch.body, report), + })), + }); + else if (selected) output.push(...optimizeNodes(selected, report)); + } else output.push({ ...node }); + } + return output; +} + +/** Safe compile-time folding for literal conditional branches. */ +export function optimizeAst(ast: PageAst): { ast: PageAst; eliminatedBranches: number } { + const report = { eliminated: 0 }; + return { + ast: { ...ast, view: optimizeNodes(ast.view, report) }, + eliminatedBranches: report.eliminated, + }; +} + +export function analyzeOptimizations(ast: PageAst): OptimizationReport { + const used = new Set(); + let staticNodes = 0; + let reactiveRegions = 0; + const componentNames = new Set(); + const staticClasses = new Set(); + const visit = (nodes: ViewNode[]) => { + for (const node of nodes) { + if (node.type === "text") { + const refs = identifiers(node.value); + refs.forEach((name) => used.add(name)); + if (node.value.includes("{")) reactiveRegions++; + else staticNodes++; + } else if (node.type === "element") { + if (/^[A-Z]/.test(node.tag)) componentNames.add(node.tag); + let reactive = false; + for (const attribute of node.attrs) { + identifiers(attribute.value).forEach((name) => used.add(name)); + reactive ||= attribute.event || attribute.value.includes("{"); + if (attribute.name === "class" && !attribute.value.includes("{")) + for (const name of attribute.value.split(/\s+/)) if (name) staticClasses.add(name); + } + if (reactive) reactiveRegions++; + else staticNodes++; + visit(node.children); + } else if (node.type === "each") { + identifiers(`${node.list} ${node.key ?? ""}`).forEach((name) => used.add(name)); + reactiveRegions++; + visit(node.body); + visit(node.empty); + } else { + for (const branch of node.branches) { + identifiers(branch.cond ?? "").forEach((name) => used.add(name)); + visit(branch.body); + } + reactiveRegions++; + } + } + }; + visit(ast.view); + const handlerReferences = new Set(used); + const executable = [ + ...ast.runtimeFunctions.map((fn) => fn.body), + ...ast.functions, + ...ast.effects.map((effect) => effect.body), + ...ast.watches.map((watch) => watch.body), + ...ast.actions.map((action) => action.body), + ].join("\n"); + identifiers(executable).forEach((name) => used.add(name)); + const localCss = new Set( + ast.styles.flatMap((style) => + [...style.matchAll(/\.([_a-zA-Z][\w-]*)/g)].map((match) => match[1]!), + ), + ); + const optimized = optimizeAst(ast); + const assignmentCounts = ast.runtimeFunctions.map( + (fn) => + ast.states.filter((state) => + new RegExp(`\\b${state.name}\\s*(?:[+*/-]?=|\\+\\+|--)`).test(fn.body), + ).length, + ); + return { + staticNodes, + reactiveRegions, + eliminatedBranches: optimized.eliminatedBranches, + unusedState: ast.states.filter((state) => !used.has(state.name)).map((state) => state.name), + unusedHandlers: ast.runtimeFunctions + .filter((fn) => fn.runtime !== "server" && !handlerReferences.has(fn.name)) + .map((fn) => fn.name), + constantProps: ast.props + .filter((prop) => + /^(?:-?\d+(?:\.\d+)?|true|false|null|(['"]).*\1)$/.test(prop.default.trim()), + ) + .map((prop) => prop.name), + unusedLocalCssClasses: [...localCss].filter((name) => !staticClasses.has(name)).sort(), + batchableStateUpdates: assignmentCounts + .filter((count) => count > 1) + .reduce((sum, count) => sum + count - 1, 0), + memoizableComponents: [...componentNames].sort(), + preloadDependencies: ast.structuredImports + .filter((entry) => !entry.typeOnly && !entry.source.startsWith("node:")) + .map((entry) => entry.source), + serverOnlyModules: ast.structuredImports + .filter((entry) => entry.source.startsWith("node:") || ast.runtime === "server") + .map((entry) => entry.source), + }; } function hasEvent(nodes: ViewNode[]): boolean { @@ -67,12 +246,42 @@ export function analyzeRuntimeRequirements(ast: PageAst): RuntimeRequirements { else if (interactive) kind = "static-interactive"; else kind = "static"; + if (ast.renderMode === "static") { + kind = "static"; + reasons.push("explicit static rendering"); + } else if (ast.renderMode === "server") { + kind = requestData ? "request-ssr" : "static"; + reasons.push("explicit server rendering"); + } else if (ast.renderMode === "client") { + kind = "static-interactive"; + reasons.push("explicit client rendering"); + } else if (ast.renderMode === "partial-static") { + kind = "streaming-ssr"; + reasons.push("partial-static shell with streamed dynamic regions"); + } + + const clientDisabled = ast.renderMode === "static" || ast.renderMode === "server"; + const serverDisabled = ast.renderMode === "client"; + return { kind, canPrerender: kind === "static" || kind === "static-interactive", - needsClientRuntime: interactive && ast.hydrate !== "none" && ast.runtime !== "server", - needsServerRuntime: requestData || authenticated || streaming || ast.runtime === "server", - hydrationStrategy: interactive ? (ast.hydrate ?? "load") : null, + needsClientRuntime: + !clientDisabled && + (interactive || ast.renderMode === "client") && + ast.hydrate !== "none" && + ast.runtime !== "server", + needsServerRuntime: + !serverDisabled && + (requestData || + authenticated || + streaming || + ast.renderMode === "server" || + ["server", "edge", "worker", "service-worker"].includes(ast.runtime ?? "")), + hydrationStrategy: clientDisabled ? null : interactive ? (ast.hydrate ?? "load") : null, reasons, + optimization: analyzeOptimizations(ast), + cachePolicy: { ...(ast.cache ?? {}) }, + requiredPermission: ast.security.permission ?? null, }; } diff --git a/packages/compiler/src/codegen.ts b/packages/compiler/src/codegen.ts index 663d84b5..79baef08 100644 --- a/packages/compiler/src/codegen.ts +++ b/packages/compiler/src/codegen.ts @@ -21,6 +21,7 @@ import { VOID_ELEMENTS, type Attr, type DataMode, type PageAst, type ViewNode } import { eraseFunctionTypes, runtimeTypeOf } from "./types.ts"; import { stripRuntimeFunctionModifiers } from "@wrnexus/syntax"; import { generateStoreModule } from "./store-codegen.ts"; +import { optimizeAst } from "./analysis.ts"; interface RenderBinding { method: string; @@ -352,6 +353,55 @@ function renderLoopBody(node: ViewNode): string { const inner = node.children.map(renderLoopBody).join(""); + if (node.tag === "Static") return inner; + if (node.tag === "Dynamic") + return ( + escLit('') + + inner + + escLit("") + ); + if (node.tag === "KeepAlive") { + const key = node.attrs.find((attribute) => attribute.name === "key")?.value ?? "default"; + return ( + escLit('
`) + + inner + + escLit("
") + ); + } + if (node.tag === "Portal") { + const target = node.attrs.find((attribute) => attribute.name === "to")?.value ?? "body"; + return ( + escLit('
') + + inner + + escLit("
") + ); + } + if (node.tag === "Transition") { + const name = + node.attrs.find((attribute) => attribute.name === "name")?.value ?? "wrn-transition"; + return ( + escLit('
') + + inner + + escLit("
") + ); + } + if (node.tag === "Component") { + const selected = node.attrs.find((attribute) => attribute.name === "is")?.value ?? ""; + return ( + escLit('
') + + inner + + escLit("
") + ); + } + if (componentTag) { return ( escLit(`
renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive)) + .join(""); + return node.tag === "Static" + ? inner + : `${inner}`; + } + if (node.tag === "Portal" || node.tag === "Transition" || node.tag === "Component") { + const inner = node.children + .map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive)) + .join(""); + const attribute = + node.tag === "Portal" + ? "data-wrn-portal" + : node.tag === "Transition" + ? "data-wrn-transition" + : "data-wrn-dynamic-component"; + const source = node.tag === "Portal" ? "to" : node.tag === "Transition" ? "name" : "is"; + const fallback = + node.tag === "Portal" ? "body" : node.tag === "Transition" ? "wrn-transition" : ""; + const original = node.attrs.find((item) => item.name === source); + const rendered = original + ? renderAttrs([{ ...original, name: attribute }], undefined, reactive, loops) + : ` ${attribute}="${attrEscape(fallback)}"`; + return `${inner}
`; + } + + if (node.tag === "Async") { + const source = attrValue(node.attrs, "source") ?? "data"; + const retries = attrValue(node.attrs, "retries") ?? "2"; + const serverResolved = attrValue(node.attrs, "data-wrn-async-server") === "true"; + const asyncIndex = serverResolved ? loops.push("") - 1 : -1; + const branch = (name: string) => { + const element = node.children.find( + (child): child is Extract => + child.type === "element" && child.tag === name, + ); + return (element?.children ?? []) + .map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive)) + .join(""); + }; + const loading = branch("Loading"); + const success = branch("Success"); + const error = branch("Error"); + let initial = loading; + if (serverResolved) { + const nested = (value: string) => value.replace(/\\/g, "\\\\").replace(/`/g, "\\`"); + const sourcePattern = source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const serverSuccess = success.replace( + new RegExp(`\\{\\s*(${sourcePattern}(?:\\.[A-Za-z_$][\\w$]*)*)\\s*\\}`, "g"), + (_whole, expression: string) => `\${__wrnexusEscapeHtml(${expression})}`, + ); + loops[asyncIndex] = + `\${ctx[${JSON.stringify(source)}] !== undefined ? \`${nested(serverSuccess)}\` : \`${nested(loading)}\`}`; + initial = `\x00WRNEACH${asyncIndex}\x00`; + } + return `
${initial}
`; + } + + if (node.tag === "KeepAlive") { + const key = attrValue(node.attrs, "key") ?? "default"; + const inner = node.children + .map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive)) + .join(""); + return `
${inner}
`; + } if (isComponentTag(node.tag)) { return renderPageComponentInvocation( node, @@ -848,7 +966,9 @@ function generateSsrStateAliases(stateNames: string[]): string { } function hydrationAttribute(ast: PageAst): string { - const strategy = ast.hydrate ?? "load"; + const strategy = ["static", "server"].includes(ast.renderMode ?? "") + ? "none" + : (ast.hydrate ?? "load"); const hasBrowserModule = ast.runtimeFunctions.some((fn) => ["legacy", "client", "shared"].includes(fn.runtime), ); @@ -879,13 +999,88 @@ function publicOutputNames(ast: PageAst): string[] { ]; } +function prepareActionForms(nodes: ViewNode[], actions: ReadonlySet): void { + for (const node of nodes) { + if (node.type === "text") continue; + if (node.type === "each") { + prepareActionForms(node.body, actions); + prepareActionForms(node.empty, actions); + continue; + } + if (node.type === "if") { + node.branches.forEach((branch) => prepareActionForms(branch.body, actions)); + continue; + } + prepareActionForms(node.children, actions); + if (node.tag.toLowerCase() !== "form") continue; + const submit = node.attrs.find((attr) => attr.event && attr.name === "submit"); + if (!submit || !actions.has(submit.value.trim())) continue; + const name = submit.value.trim(); + node.attrs = node.attrs.filter((attr) => attr !== submit); + if (!node.attrs.some((attr) => !attr.event && attr.name === "method")) { + node.attrs.push({ name: "method", value: "post", event: false }); + } + node.attrs.push({ name: "data-wrn-action", value: name, event: false }); + node.children.unshift({ + type: "element", + tag: "input", + attrs: [ + { name: "type", value: "hidden", event: false }, + { name: "name", value: "_wrnexus_action", event: false }, + { name: "value", value: name, event: false }, + ], + children: [], + }); + } +} + +function markServerAsyncBoundaries(nodes: ViewNode[], serverLoads: ReadonlySet): void { + for (const node of nodes) { + if (node.type === "text") continue; + if (node.type === "each") { + markServerAsyncBoundaries(node.body, serverLoads); + markServerAsyncBoundaries(node.empty, serverLoads); + continue; + } + if (node.type === "if") { + node.branches.forEach((branch) => markServerAsyncBoundaries(branch.body, serverLoads)); + continue; + } + if (node.tag === "Async") { + const source = attrValue(node.attrs, "source") ?? "data"; + if ( + serverLoads.has(source) && + !node.attrs.some((attribute) => attribute.name === "data-wrn-async-server") + ) { + node.attrs.push({ name: "data-wrn-async-server", value: "true", event: false }); + } + } + markServerAsyncBoundaries(node.children, serverLoads); + } +} + export function generate(ast: PageAst): string { + ast = optimizeAst(ast).ast; if (ast.kind === "global-store" || ast.kind === "page-store") return generateStoreModule(ast); if (ast.kind === "component" || ast.kind === "layout") { return generateComponent(ast); } const out: string[] = []; + prepareActionForms(ast.view, new Set(ast.actions.map((action) => action.name))); + markServerAsyncBoundaries( + ast.view, + new Set( + ast.loads + .filter((load) => load.mode === "server" && !load.deferred && load.name) + .map((load) => load.name!), + ), + ); + if (ast.actions.length > 0) { + out.push( + `import { createActionClient } from "@wrnexus/csr";\nimport type { InferSchema } from "@wrnexus/validation";`, + ); + } if (ast.imports.length > 0) out.push(generatedImports(ast).join("\n")); const ssrBindings: SsrBinding[] = []; const csrBindings: CsrBinding[] = []; @@ -909,11 +1104,19 @@ export function generate(ast: PageAst): string { `export const layout = ${ast.layoutIsSymbol ? ast.layout : JSON.stringify(ast.layout)};`, ); out.push(`export const __wrnexusRuntime = ${JSON.stringify(ast.runtime ?? "universal")};`); - out.push(`export const __wrnexusHydrate = ${JSON.stringify(ast.hydrate ?? "load")};`); + out.push(`export const __wrnexusRender = ${JSON.stringify(ast.renderMode ?? "hybrid")};`); + out.push( + `export const __wrnexusHydrate = ${JSON.stringify(["static", "server"].includes(ast.renderMode ?? "") ? "none" : (ast.hydrate ?? "load"))};`, + ); out.push(`export const __wrnexusHydrationId = ${JSON.stringify(hydrationId(ast))};`); + if (Object.keys(ast.cache ?? {}).length > 0) + out.push(`export const __wrnexusCache = ${JSON.stringify(ast.cache, null, 2)};`); if (Object.keys(ast.security).length > 0) { out.push(`export const __wrnexusSecurity = ${JSON.stringify(ast.security, null, 2)};`); } + if (Object.keys(ast.navigation).length > 0) { + out.push(`export const __wrnexusNavigation = ${JSON.stringify(ast.navigation, null, 2)};`); + } // --- View -> default page component --- const browserStates = ast.states.filter((state) => state.runtime !== "server"); @@ -960,6 +1163,10 @@ export function generate(ast: PageAst): string { if (pageStyleTag) { html = `${pageStyleTag}${html}`; } + if (ast.renderMode === "client") { + const clientRoot = hydrationId(ast); + html = `
`; + } const pageStyleExport = localStyleExport(ast, styles); if (pageStyleExport) out.push(pageStyleExport); if (csrBindings.length > 0) { @@ -972,6 +1179,14 @@ export function generate(ast: PageAst): string { // Escape the static HTML for the template literal, then swap loop sentinels for // their real `${…}` code (which must NOT be escaped). let body = templateEscape(html); + let staticShellBody: string | undefined; + if (ast.renderMode === "partial-static") { + const shellHtml = html.replace( + /]*>[\s\S]*?<\/wrn-dynamic-region>/gi, + '', + ); + staticShellBody = templateEscape(shellHtml); + } const dynamicStateScope = ast.states .map( (state) => @@ -994,9 +1209,16 @@ export function generate(ast: PageAst): string { (entry) => ` const ${entry.local} = await ctx.__wrnexusUseStore(${entry.internal});`, ) .join("\n"); + const serverLoadAliases = ast.loads + .filter((load) => load.mode === "server" && !load.deferred && load.name) + .map((load) => ` const ${load.name} = ctx[${JSON.stringify(load.name)}];`) + .join("\n"); loops.forEach((code, idx) => { body = body.replace(`\x00WRNEACH${idx}\x00`, () => code); + if (staticShellBody?.includes(`\x00WRNEACH${idx}\x00`)) { + staticShellBody = staticShellBody.replace(`\x00WRNEACH${idx}\x00`, () => code); + } }); // Server loops iterate raw SSR data. Declare a named const for every `ssr` data @@ -1024,6 +1246,7 @@ export function generate(ast: PageAst): string { out.push( `export default async function ${ast.name}(ctx: any) { ${storeDeclarations} + ${serverLoadAliases} ${decls} const __state: ${stateType} = { ${dynamicStateScope} }; ${ssrStateAliases} @@ -1055,6 +1278,7 @@ export function generate(ast: PageAst): string { out.push( `export default ${storeBindings.length > 0 ? "async " : ""}function ${ast.name}(ctx: any) { ${storeDeclarations} + ${serverLoadAliases} const __state: ${stateType} = { ${dynamicStateScope} }; ${ssrStateAliases} const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, (__state as any)[key]])); @@ -1081,30 +1305,122 @@ export function generate(ast: PageAst): string { ); } + if (staticShellBody !== undefined) { + out.push( + `export async function __wrnexusBuildStaticShell(ctx: any = {}) { + ${storeDeclarations} + ${serverLoadAliases} + ${loopConsts.length > 0 ? loopConsts.join("\n") : ""} + const __state: ${stateType} = { ${dynamicStateScope} }; + ${ssrStateAliases} + const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, (__state as any)[key]])); + const __scopeValue = Object.entries(__hydrationState) + .map(([key, value]) => { + const encoded = typeof value === "number" || typeof value === "boolean" + ? String(value) + : JSON.stringify(value == null ? "" : String(value)); + return key + ": " + encoded; + }) + .join(", ") + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); + return \`${staticShellBody}\`.replace("__WRNEXUS_DYNAMIC_SCOPE__", __scopeValue); + }`, + ); + } + if (ast.loads.length > 0) { - const serverLoads = ast.loads.filter((entry) => entry.mode === "server"); - const clientLoads = ast.loads.filter((entry) => entry.mode === "client"); - if (serverLoads.length > 0) { - out.push( - `export async function __wrnexusLoad(ctx: any) {\n${serverLoads.map((entry) => entry.body).join("\n")}\n}`, - ); - } - if (clientLoads.length > 0) { - out.push( - `export async function __wrnexusClientLoad(ctx: any) { -${clientLoads.map((entry) => entry.body).join("\n")} -}`, - ); - } + const serverLoads = ast.loads.filter((entry) => entry.mode === "server" && !entry.deferred); + const publicClientLoads = ast.loads.filter( + (entry) => entry.mode === "client" || entry.deferred, + ); + const namedByName = new Map( + ast.loads.filter((entry) => entry.name).map((entry) => [entry.name!, entry]), + ); + const clientNames = new Set( + publicClientLoads.flatMap((entry) => (entry.name ? [entry.name] : [])), + ); + const includeDependencies = (name: string): void => { + for (const dependency of namedByName.get(name)?.dependsOn ?? []) { + if (clientNames.has(dependency)) continue; + clientNames.add(dependency); + includeDependencies(dependency); + } + }; + for (const name of [...clientNames]) includeDependencies(name); + const clientLoads = ast.loads.filter((entry) => !entry.name || clientNames.has(entry.name)); + const renderLoads = ( + exportName: string, + execution: typeof ast.loads, + exposed: typeof ast.loads, + ): string => { + const declarations = execution + .filter((entry) => entry.name) + .map((entry) => { + const dependencies = (entry.dependsOn ?? []) + .map((dependency) => `const ${dependency} = await __load_${dependency}();`) + .join("\n"); + return ` let __promise_${entry.name}: Promise | undefined; + const __load_${entry.name} = () => (__promise_${entry.name} ??= (async () => { + ${dependencies} + ${entry.body} + })());`; + }) + .join("\n"); + const visible = exposed.filter((entry) => entry.name); + return `export async function ${exportName}(ctx: any) { +${exposed + .filter((entry) => !entry.name) + .map((entry) => entry.body) + .join("\n")} +${declarations} +${ + visible.length + ? ` const __values = await Promise.all([${visible.map((entry) => `__load_${entry.name}()`).join(", ")}]); + return { ${visible.map((entry, index) => `${JSON.stringify(entry.name)}: __values[${index}]`).join(", ")} };` + : "" +} +}`; + }; + if (serverLoads.length > 0) out.push(renderLoads("__wrnexusLoad", serverLoads, serverLoads)); + if (publicClientLoads.length > 0) + out.push(renderLoads("__wrnexusClientLoad", clientLoads, publicClientLoads)); } if (ast.actions.length > 0) { for (const action of ast.actions) { - out.push(`export async function ${action.name}(${action.args.join(", ")}) {${action.body}}`); + if (!action.schema) { + out.push( + `export async function ${action.name}(${action.args.join(", ")}) {${action.body}}`, + ); + continue; + } + out.push(`export async function ${action.name}(input: any, ctx: any) { + const invalidate = (...tags: string[]) => { + const bucket = (ctx.locals.__wrnexusInvalidatedTags ??= []); + bucket.push(...tags.flat()); + }; +${action.body} +}`); } out.push( - `export const __wrnexusActions = { ${ast.actions.map((action) => action.name).join(", ")} };`, + `export const __wrnexusActions = { ${ast.actions + .map( + (action) => + `${action.name}: { run: ${action.name}, schema: ${action.schema ?? "undefined"} }`, + ) + .join(", ")} };`, ); + out.push(`export const __wrnexusActionClients = { +${ast.actions + .map( + (action) => + ` ${action.name}: createActionClient<${action.schema ? `InferSchema` : "Record"}, Awaited>>("", ${JSON.stringify(action.name)}),`, + ) + .join("\n")} +};`); } // --- API blocks -> method handlers --- @@ -1545,6 +1861,34 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string { return renderComponentIfNode(node, ctx); } + if (node.tag === "Static" || node.tag === "Dynamic") { + const inner = node.children.map((child) => renderComponentNode(child, ctx)).join(""); + return node.tag === "Static" + ? inner + : `${inner}`; + } + + if (node.tag === "KeepAlive") { + const key = node.attrs.find((attribute) => attribute.name === "key")?.value ?? "default"; + const inner = node.children.map((child) => renderComponentNode(child, ctx)).join(""); + return `
${inner}
`; + } + + if (node.tag === "Portal" || node.tag === "Transition" || node.tag === "Component") { + const inner = node.children.map((child) => renderComponentNode(child, ctx)).join(""); + const attribute = + node.tag === "Portal" + ? "data-wrn-portal" + : node.tag === "Transition" + ? "data-wrn-transition" + : "data-wrn-dynamic-component"; + const source = node.tag === "Portal" ? "to" : node.tag === "Transition" ? "name" : "is"; + const fallback = + node.tag === "Portal" ? "body" : node.tag === "Transition" ? "wrn-transition" : ""; + const raw = node.attrs.find((item) => item.name === source)?.value ?? fallback; + return `
${inner}
`; + } + if (isComponentTag(node.tag)) { return renderNestedComponentInvocation(node, ctx); } @@ -1865,11 +2209,19 @@ function generateComponent(ast: PageAst): string { out.push(`export const __wrnexusComponent = ${JSON.stringify(ast.name)};`); } out.push(`export const __wrnexusRuntime = ${JSON.stringify(ast.runtime ?? "universal")};`); - out.push(`export const __wrnexusHydrate = ${JSON.stringify(ast.hydrate ?? "load")};`); + out.push(`export const __wrnexusRender = ${JSON.stringify(ast.renderMode ?? "hybrid")};`); + out.push( + `export const __wrnexusHydrate = ${JSON.stringify(["static", "server"].includes(ast.renderMode ?? "") ? "none" : (ast.hydrate ?? "load"))};`, + ); out.push(`export const __wrnexusHydrationId = ${JSON.stringify(hydrationId(ast))};`); + if (Object.keys(ast.cache ?? {}).length > 0) + out.push(`export const __wrnexusCache = ${JSON.stringify(ast.cache, null, 2)};`); if (Object.keys(ast.security).length > 0) { out.push(`export const __wrnexusSecurity = ${JSON.stringify(ast.security, null, 2)};`); } + if (Object.keys(ast.navigation).length > 0) { + out.push(`export const __wrnexusNavigation = ${JSON.stringify(ast.navigation, null, 2)};`); + } const componentStyleExport = localStyleExport(ast, styles); if (componentStyleExport) out.push(componentStyleExport); diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index c2791eb0..6ec97b3c 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -15,6 +15,8 @@ import { type PageAst, type WrnDiagnostic, } from "@wrnexus/syntax"; +export { formatWrn } from "@wrnexus/syntax"; +export type { FormatWrnOptions } from "@wrnexus/syntax"; import { generate } from "./codegen.ts"; import { generateNative } from "./native-codegen.ts"; @@ -35,8 +37,14 @@ export { generateStoreBrowserModule, generateStoreModule } from "./store-codegen export { createComponentContract } from "./component-contract.ts"; export { resolveWrnImport, resolveWrnImports } from "./import-resolver.ts"; export { createWrnSourceMap } from "./source-map.ts"; -export { analyzeRuntimeRequirements } from "./analysis.ts"; -export type { RouteExecutionKind, RuntimeRequirements } from "./analysis.ts"; +export { analyzeOptimizations, analyzeRuntimeRequirements, optimizeAst } from "./analysis.ts"; +export type { OptimizationReport, RouteExecutionKind, RuntimeRequirements } from "./analysis.ts"; +export { analyzeRuntimeImports, runtimeCapabilities } from "./runtime-capabilities.ts"; +export type { + DeploymentRuntime, + RuntimeCapability, + RuntimeCapabilityDiagnostic, +} from "./runtime-capabilities.ts"; export { generateNative, NativeCompileError } from "./native-codegen.ts"; export { Lexer, LexError } from "@wrnexus/syntax"; export { eraseFunctionTypes, inferredRuntimeType, runtimeTypeOf } from "@wrnexus/syntax"; diff --git a/packages/compiler/src/runtime-capabilities.ts b/packages/compiler/src/runtime-capabilities.ts new file mode 100644 index 00000000..74ab2a74 --- /dev/null +++ b/packages/compiler/src/runtime-capabilities.ts @@ -0,0 +1,69 @@ +export type DeploymentRuntime = "bun" | "node" | "edge" | "worker" | "service-worker" | "browser"; +export type RuntimeCapability = + "filesystem" | "tcp" | "process" | "websocket" | "crypto" | "streams" | "background-tasks"; + +const CAPABILITIES: Record> = { + bun: new Set([ + "filesystem", + "tcp", + "process", + "websocket", + "crypto", + "streams", + "background-tasks", + ]), + node: new Set([ + "filesystem", + "tcp", + "process", + "websocket", + "crypto", + "streams", + "background-tasks", + ]), + edge: new Set(["websocket", "crypto", "streams", "background-tasks"]), + worker: new Set(["websocket", "crypto", "streams", "background-tasks"]), + "service-worker": new Set(["crypto", "streams", "background-tasks"]), + browser: new Set(["websocket", "crypto", "streams"]), +}; + +const MODULE_CAPABILITIES: Array<[RegExp, RuntimeCapability]> = [ + [/^(?:node:)?(?:fs|path|os)(?:\/|$)/, "filesystem"], + [/^(?:node:)?(?:net|tls|dgram|http2)(?:\/|$)/, "tcp"], + [/^(?:node:)?(?:child_process|cluster|worker_threads)(?:\/|$)/, "process"], +]; + +export interface RuntimeCapabilityDiagnostic { + code: "WRN-RUNTIME-CAPABILITY"; + runtime: DeploymentRuntime; + module: string; + capability: RuntimeCapability; + message: string; +} + +export function runtimeCapabilities(runtime: DeploymentRuntime): ReadonlySet { + return CAPABILITIES[runtime]; +} + +export function analyzeRuntimeImports( + source: string, + runtime: DeploymentRuntime, +): RuntimeCapabilityDiagnostic[] { + const modules = [ + ...source.matchAll(/\b(?:import\s+(?:[\s\S]*?\s+from\s+)?|require\s*\()\s*["']([^"']+)["']/g), + ].map((match) => match[1]!); + const available = runtimeCapabilities(runtime); + return modules.flatMap((module) => { + const requirement = MODULE_CAPABILITIES.find(([pattern]) => pattern.test(module)); + if (!requirement || available.has(requirement[1])) return []; + return [ + { + code: "WRN-RUNTIME-CAPABILITY" as const, + runtime, + module, + capability: requirement[1], + message: `Module '${module}' requires ${requirement[1]}, which is unavailable in the ${runtime} runtime.`, + }, + ]; + }); +} diff --git a/packages/compiler/test/__snapshots__/resilience.test.ts.snap b/packages/compiler/test/__snapshots__/resilience.test.ts.snap new file mode 100644 index 00000000..23421be2 --- /dev/null +++ b/packages/compiler/test/__snapshots__/resilience.test.ts.snap @@ -0,0 +1,278 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`compiler output remains snapshot-compatible for the canonical component contract 1`] = ` +{ + "code": +"// compiled from .wrn +import Button from "@wrnexus/ui/components/Button.wrn"; + +import { Buffer as __WrnexusBuffer } from "node:buffer"; + +export const __wrnexusComponent = "Counter"; + +export const __wrnexusRuntime = "universal"; + +export const __wrnexusRender = "hybrid"; + +export const __wrnexusHydrate = "load"; + +export const __wrnexusHydrationId = "Counter:1skggk6"; + +export const __wrnexusBehavior = { + "functions": "function increment(){\\n count = count + 1\\n output.change(count)\\n }", + "outputs": [ + { + "name": "change", + "payload": { + "name": "value", + "valueType": "number", + "optional": false + } + } + ], + "computed": [], + "effects": [], + "lifecycle": {}, + "watches": [] +}; + +export interface CounterProps { + [attribute: string]: unknown; + "label"?: string; +} + +export interface CounterOutputs { + "change"(value: number): void; +} + +function __coerce(v: any, def: any, declared: string = "unknown"): any { + if (v === undefined || v === null) { + return def; + } + + if (declared === "number" || typeof def === "number") { + const parsed = Number(v); + if (!Number.isFinite(parsed)) throw new TypeError("Expected a finite number prop"); + return parsed; + } + + if (declared === "boolean" || typeof def === "boolean") { + if (v === true || v === "" || v === "true" || v === 1 || v === "1") return true; + if (v === false || v === "false" || v === 0 || v === "0") return false; + throw new TypeError("Expected a boolean prop"); + } + + if (declared === "array" || Array.isArray(def)) { + if (Array.isArray(v)) { + return v; + } + + if (typeof v === "string") { + try { + const parsed = JSON.parse(v); + return Array.isArray(parsed) ? parsed : def; + } catch { + if (declared === "array") throw new TypeError("Expected an array prop"); + return def; + } + } + + return def; + } + + if (declared === "object" || (def !== null && typeof def === "object")) { + if ( + v !== null && + typeof v === "object" && + !Array.isArray(v) + ) { + return v; + } + + if (typeof v === "string") { + try { + const parsed = JSON.parse(v); + + return ( + parsed !== null && + typeof parsed === "object" && + !Array.isArray(parsed) + ) + ? parsed + : def; + } catch { + if (declared === "object") throw new TypeError("Expected an object prop"); + return def; + } + } + + return def; + } + + if (declared === "bigint") return BigInt(v); + if (declared === "function" && typeof v !== "function") { + throw new TypeError("Expected a function prop"); + } + return declared === "unknown" && def === undefined ? v : String(v); +} + +function __restProps( + props: Record, + declared: Set, +): Record { + return Object.fromEntries( + Object.entries(props).filter(([name]) => !declared.has(name)), + ); +} + +function __wireHtml(v: any): string { + return String(v == null ? "" : v).replace( + /[&<>]/g, + (c) => + c === "&" + ? "&" + : c === "<" + ? "<" + : ">", + ); +} + +function __wireAttr(v: any): string { + return String(v == null ? "" : v).replace( + /[&<>"]/g, + (c) => + c === "&" + ? "&" + : c === "<" + ? "<" + : c === ">" + ? ">" + : """, + ); +} + +function __wireBooleanAttr(name: string, value: any): string { + return value === true || + value === "true" || + value === "" || + value === 1 || + value === "1" || + value === name + ? " " + name + : ""; +} + +function __wireSpreadAttrs(value: any): string { + if (value === null || typeof value !== "object" || Array.isArray(value)) return ""; + + const booleanAttributes = new Set(["allowfullscreen","async","autofocus","autoplay","checked","controls","default","defer","disabled","formnovalidate","hidden","inert","ismap","itemscope","loop","multiple","muted","nomodule","novalidate","open","playsinline","readonly","required","reversed","selected"]); + const attributes: string[] = []; + + for (const [name, raw] of Object.entries(value)) { + const lowerName = name.toLowerCase(); + if ( + !/^[A-Za-z_:][A-Za-z0-9_.:-]*$/.test(name) || + lowerName.startsWith("on") || + lowerName === "style" || + lowerName === "slot" || + lowerName === "data-component" || + lowerName.startsWith("data-wrn") + ) { + continue; + } + + if (booleanAttributes.has(lowerName)) { + attributes.push(__wireBooleanAttr(name, raw)); + continue; + } + + if (raw === false || raw === null || raw === undefined) continue; + attributes.push(" " + name + '="' + __wireAttr(raw) + '"'); + } + + return attributes.join(""); +} + +function __wireProp(v: any): string { + const value = + v !== null && typeof v === "object" + ? JSON.stringify(v) + : String(v == null ? "" : v); + + return __wireAttr(value); +} + +function __wireRaw(v: any): string { + return String(v == null ? "" : v); +} + +function __wrnexusSerializeScopeValue(value: any): string { + if (value === undefined) { + return "undefined"; + } + + if (value === null) { + return "null"; + } + + if (typeof value === "number") { + return Number.isFinite(value) + ? String(value) + : "null"; + } + + if (typeof value === "boolean") { + return value ? "true" : "false"; + } + + if (typeof value === "string") { + return JSON.stringify(value); + } + + try { + const serialized = JSON.stringify(value); + + return serialized === undefined + ? "undefined" + : serialized; + } catch { + return "null"; + } + } + + function __wrnexusScopeDecl(obj: Record): string { + return Object.keys(obj) + .map( + (key) => + key + + ": " + + __wrnexusSerializeScopeValue( + obj[key], + ), + ) + .join(", ") + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); + } + +export function render(props: CounterProps = {} as CounterProps): string { + const __p = props || {}; + const label: string = __coerce(__p["label"], ("Count"), "string"); + const __attrs = __restProps(__p, new Set(["label"])); + let count = (0); + const __scopeState = { "label": label, "count": count }; + const __scope = __wrnexusScopeDecl(__scopeState); + const __scopePayload = __WrnexusBuffer.from(JSON.stringify(__scopeState), "utf8").toString("base64"); + return \`
+
\${__wireHtml(label)}: \${__wireHtml(count)}
+
\`; +} + +export default { name: "Counter", kind: "component", render }; +" +, + "diagnostics": [], +} +`; diff --git a/packages/compiler/test/actions.test.ts b/packages/compiler/test/actions.test.ts new file mode 100644 index 00000000..732cba9b --- /dev/null +++ b/packages/compiler/test/actions.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "bun:test"; +import { compile } from "../src/index.ts"; + +test("compiles schema-backed actions and progressively enhanced forms", () => { + const output = compile( + `import { CreateUserSchema } from "./schema"; + page Users { + action createUser using CreateUserSchema { invalidate("users"); return { id: input.name } } + view {
} + }`, + "Users.wrn", + ).code; + expect(output).toContain('data-wrn-action="createUser"'); + expect(output).toContain('name="_wrnexus_action" value="createUser"'); + expect(output).toContain("schema: CreateUserSchema"); + expect(output).toContain("__wrnexusInvalidatedTags"); + expect(output).toContain( + "createActionClient, Awaited>>", + ); + expect(output).not.toContain("data-on-submit"); +}); diff --git a/packages/compiler/test/async-boundary.test.ts b/packages/compiler/test/async-boundary.test.ts new file mode 100644 index 00000000..c430149a --- /dev/null +++ b/packages/compiler/test/async-boundary.test.ts @@ -0,0 +1,70 @@ +import { expect, test } from "bun:test"; +import { generate, parse } from "../src/index.ts"; + +test("Async syntax compiles loading, success and error branches into inert templates", () => { + const code = generate( + parse(`page Users { + load client users { return [{ name: "Ada" }] } + view { + +

Loading users

+

{users.name}

+

{error.message}

+
+ } + }`), + ); + expect(code).toContain('data-wrn-async="users"'); + expect(code).toContain('data-wrn-async-retries="3"'); + expect(code).toContain("data-wrn-async-loading"); + expect(code).toContain("data-wrn-async-success"); + expect(code).toContain("data-wrn-async-error"); + expect(code).toContain("__wrnexusClientLoad"); +}); + +test("server named loads render Async success content during SSR", () => { + const code = generate( + parse(`page Users { + load server users { return { name: "Ada" } } + view { + +

Loading

+

{users.name}

+

Failed

+
+ } + }`), + ); + expect(code).toContain('const users = ctx["users"]'); + expect(code).toContain('data-wrn-async-resolved="true"'); + expect(code).toContain('ctx["users"] !== undefined'); +}); + +test("named loads support memoized dependencies and deferred execution", () => { + const code = generate( + parse(`page Data { + load server account { return { id: 7 } } + load server projects after account { return [account.id] } + load server audit after projects defer { return { project: projects[0] } } + view { WaitReady } + }`), + ); + expect(code).toContain("const account = await __load_account()"); + expect(code).toContain("const projects = await __load_projects()"); + expect(code).toContain("__promise_projects ??="); + expect(code).toContain("export async function __wrnexusClientLoad"); + expect(code).toContain('return { "audit": __values[0] }'); +}); + +test("load dependency cycles and cross-phase server dependencies fail compilation", () => { + expect(() => + parse( + `page Cycle { load server first after second { return 1 } load server second after first { return 2 } view {

x

} }`, + ), + ).toThrow("cycle"); + expect(() => + parse( + `page Phase { load client browser { return 1 } load server invalid after browser { return 2 } view {

x

} }`, + ), + ).toThrow("cannot depend"); +}); diff --git a/packages/compiler/test/client-render.test.ts b/packages/compiler/test/client-render.test.ts new file mode 100644 index 00000000..a2463792 --- /dev/null +++ b/packages/compiler/test/client-render.test.ts @@ -0,0 +1,11 @@ +import { expect, test } from "bun:test"; +import { generate, parse } from "../src/index.ts"; + +test("client-rendered pages emit an inert template and browser mount anchor", () => { + const code = generate( + parse('page ClientOnly { render = "client" view {

Browser only

} }'), + ); + expect(code).toContain('data-wrn-client-root="'); + expect(code).toContain('data-wrn-client-template="'); + expect(code.indexOf("Browser only")).toBeGreaterThan(code.indexOf(" { + const output = compileWireFile(`page StaticPage { + render = "static" + state count = 0 + view { } + }`); + expect(output).toContain('export const __wrnexusRender = "static"'); + expect(output).toContain('data-wrn-hydrate="none"'); + expect(output).toContain('export const __wrnexusHydrate = "none"'); +}); + +test("named data loads compile as parallel typed data entries", () => { + const output = compileWireFile(`page Users { + load users { return ["Ada"] } + load server teams { return ["Core"] } + view {

Users

} + }`); + expect(output).toContain("await Promise.all"); + expect(output).toContain('return { "users": __values[0], "teams": __values[1] }'); +}); + let seq = 0; /** Compile a `.wrn` source and import the resulting module. */ async function compileAndImport(src: string): Promise> { diff --git a/packages/compiler/test/declarative-ui.test.ts b/packages/compiler/test/declarative-ui.test.ts new file mode 100644 index 00000000..7ccb3ad2 --- /dev/null +++ b/packages/compiler/test/declarative-ui.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from "bun:test"; +import { compile } from "../src/index.ts"; + +test("compiles declarative portals, transitions and dynamic component cases", () => { + const result = compile(`page Ui { + view { +

Modal

+

Animated

+
Admin
Guest
+ } + }`); + expect(result.code).toContain('data-wrn-portal="#modal"'); + expect(result.code).toContain('data-wrn-transition="fade"'); + expect(result.code).toContain('data-wrn-dynamic-component="Admin"'); +}); diff --git a/packages/compiler/test/keep-alive.test.ts b/packages/compiler/test/keep-alive.test.ts new file mode 100644 index 00000000..36c18bf3 --- /dev/null +++ b/packages/compiler/test/keep-alive.test.ts @@ -0,0 +1,12 @@ +import { expect, test } from "bun:test"; +import { generate, parse } from "../src/index.ts"; + +test("KeepAlive compiles to a keyed live-instance preservation boundary", () => { + const output = generate( + parse( + `page Dashboard { navigation { preserve = ["component"] } view { } }`, + ), + ); + expect(output).toContain('data-wrn-keepalive="filters"'); + expect(output).not.toContain('data-component="KeepAlive"'); +}); diff --git a/packages/compiler/test/optimization.test.ts b/packages/compiler/test/optimization.test.ts new file mode 100644 index 00000000..600f64b0 --- /dev/null +++ b/packages/compiler/test/optimization.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from "bun:test"; +import { analyzeOptimizations, generate, optimizeAst, parse } from "../src/index.ts"; + +test("compiler folds literal branches and reports optimization opportunities", () => { + const ast = parse(`component Optimized { + props { title: string = "Hello" } + state count = 0 + state unused = 1 + functions { + client function increment(): void { count++ } + client function orphan(): void { unused++ } + } + style { .used { color: red } .unused-css { color: blue } } + view {

{title}

{#if false}

dead

{:else}{/if}
} + }`); + const report = analyzeOptimizations(ast); + expect(report.eliminatedBranches).toBeGreaterThan(0); + expect(report.unusedHandlers).toContain("orphan"); + expect(report.unusedLocalCssClasses).toContain("unused-css"); + expect(report.constantProps).toContain("title"); + expect(optimizeAst(ast).ast.view).not.toEqual(ast.view); + expect(generate(ast)).not.toContain("dead"); +}); diff --git a/packages/compiler/test/partial-static.test.ts b/packages/compiler/test/partial-static.test.ts new file mode 100644 index 00000000..a4617bec --- /dev/null +++ b/packages/compiler/test/partial-static.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from "bun:test"; +import { analyzeRuntimeRequirements, generate, parse } from "../src/index.ts"; + +test("partial-static pages compile transparent static and streamed dynamic boundaries", () => { + const ast = parse( + `page Dashboard { render = "partial-static" view {
Docs

User

} }`, + ); + expect(ast.renderMode).toBe("partial-static"); + expect(analyzeRuntimeRequirements(ast).kind).toBe("streaming-ssr"); + const output = generate(ast); + expect(output).toContain("wrn-dynamic-region"); + expect(output).toContain("__wrnexusBuildStaticShell"); + expect(output).toContain(''); + expect(output).not.toContain('data-component="Static"'); +}); diff --git a/packages/compiler/test/resilience.test.ts b/packages/compiler/test/resilience.test.ts new file mode 100644 index 00000000..78bc5ed6 --- /dev/null +++ b/packages/compiler/test/resilience.test.ts @@ -0,0 +1,44 @@ +import { expect, test } from "bun:test"; +import { compile, diagnose } from "../src/index.ts"; + +test("compiler output remains snapshot-compatible for the canonical component contract", () => { + const source = `import Button from "@wrnexus/ui/components/Button.wrn"; + +component Counter { + props { + label: string = "Count" + } + state { + count = 0 + } + outputs { + change(value: number) + } + functions { + client function increment(): void { + count = count + 1 + output.change(count) + } + } + view { + + } +} +`; + const result = compile(source, "Counter.wrn"); + expect({ code: result.code, diagnostics: result.richDiagnostics }).toMatchSnapshot(); +}); + +test("diagnostics tolerate deterministic malformed-source fuzz cases", () => { + let state = 0x8f3a21; + const alphabet = "{}[]()<>=:/@#$'\"` abcdefghijklmnopqrstuvwxyz0123456789\n\t"; + for (let sample = 0; sample < 500; sample++) { + let source = ""; + const length = 1 + (state % 180); + for (let index = 0; index < length; index++) { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + source += alphabet[state % alphabet.length]; + } + expect(() => diagnose(source, { file: `fuzz-${sample}.wrn` })).not.toThrow(); + } +}); diff --git a/packages/compiler/test/runtime-capabilities.test.ts b/packages/compiler/test/runtime-capabilities.test.ts new file mode 100644 index 00000000..e397b992 --- /dev/null +++ b/packages/compiler/test/runtime-capabilities.test.ts @@ -0,0 +1,12 @@ +import { expect, test } from "bun:test"; +import { analyzeRuntimeImports, runtimeCapabilities } from "../src/index.ts"; + +test("edge and workers reject Node capabilities with stable diagnostics", () => { + const source = `import fs from "node:fs";\nimport { connect } from "node:net";`; + expect(analyzeRuntimeImports(source, "edge").map((item) => item.capability)).toEqual([ + "filesystem", + "tcp", + ]); + expect(analyzeRuntimeImports(source, "bun")).toEqual([]); + expect(runtimeCapabilities("service-worker").has("filesystem")).toBe(false); +}); diff --git a/packages/content/README.md b/packages/content/README.md new file mode 100644 index 00000000..1a047c83 --- /dev/null +++ b/packages/content/README.md @@ -0,0 +1,20 @@ +# @wrnexus/content + +Typed content collections for Markdown/MDX-like documents and remote CMS records. Collections +validate frontmatter through any `{ parse(input) }` schema, render escaped HTML, and expose draft +preview, versions, references, headings, search indexes, pagination, RSS and sitemaps. + +```ts +const posts = defineCollection({ + name: "posts", + schema: PostSchema, + loader: localContentLoader("content/posts"), + previewToken: process.env.CONTENT_PREVIEW_TOKEN, +}); + +const published = await posts.load(); +const preview = await posts.load({ previewToken: request.headers.get("x-preview-token") ?? "" }); +``` + +Remote systems implement `CmsAdapter`, use `cmsContentLoader`, or return JSON records through +`remoteContentLoader`. Markdown HTML is escaped by default; raw executable HTML is never trusted. diff --git a/packages/content/package.json b/packages/content/package.json new file mode 100644 index 00000000..25894773 --- /dev/null +++ b/packages/content/package.json @@ -0,0 +1,19 @@ +{ + "name": "@wrnexus/content", + "version": "0.8.0", + "type": "module", + "description": "Typed Markdown content collections, loaders, indexes, feeds, and preview workflows for WRNexusJS.", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src", + "README.md" + ], + "devDependencies": { + "@types/bun": "^1.3.14", + "typescript": "^5.9.2" + } +} diff --git a/packages/content/src/advanced.ts b/packages/content/src/advanced.ts new file mode 100644 index 00000000..ab1b6914 --- /dev/null +++ b/packages/content/src/advanced.ts @@ -0,0 +1,152 @@ +export type MdxComponent = (props: Record, children: string) => string; + +const escapeHtml = (value: string) => + value.replace( + /[&<>"']/g, + (character) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]!, + ); + +/** Execute explicitly registered MDX components without evaluating arbitrary JavaScript. */ +export function renderMdxComponents( + source: string, + components: Record, +): string { + let output = source; + const parseProps = (raw: string) => + Object.fromEntries( + [...raw.matchAll(/([A-Za-z_$][\w$-]*)\s*=\s*["']([^"']*)["']/g)].map((match) => [ + match[1]!, + match[2]!, + ]), + ); + for (let pass = 0; pass < 20; pass++) { + let changed = false; + output = output.replace( + /<([A-Z][A-Za-z0-9_$]*)\b([^>]*)>([\s\S]*?)<\/\1>|<([A-Z][A-Za-z0-9_$]*)\b([^>]*)\/>/g, + (whole, pairedName, pairedProps, children, singleName, singleProps) => { + const name = pairedName ?? singleName; + const component = components[name]; + if (!component) throw new Error(`WRN-CONTENT-MDX-COMPONENT: '${name}' is not registered.`); + changed = true; + return component(parseProps(pairedProps ?? singleProps ?? ""), children ?? ""); + }, + ); + if (!changed) break; + } + if (/<[A-Z][A-Za-z0-9_$]*\b/.test(output)) + throw new Error("WRN-CONTENT-MDX-DEPTH: component expansion exceeded its bound."); + return output; +} + +export interface SyntaxLanguageBundle { + highlight(source: string): string; +} +export function createIncrementalHighlighter( + loaders: Record SyntaxLanguageBundle | Promise>, +) { + const loaded = new Map>(); + return { + languages: () => [...loaded.keys()], + async highlight(language: string, source: string) { + const load = loaders[language]; + if (!load) return escapeHtml(source); + let bundle = loaded.get(language); + if (!bundle) { + bundle = Promise.resolve(load()); + loaded.set(language, bundle); + } + return (await bundle).highlight(source); + }, + async render(html: string) { + const matches = [ + ...html.matchAll(/
([\s\S]*?)<\/code><\/pre>/g),
+      ];
+      let output = html;
+      for (const match of matches)
+        output = output.replace(
+          match[0],
+          `
${await this.highlight(match[1]!, match[2]!)}
`, + ); + return output; + }, + }; +} + +export interface VendorAdapterOptions { + fetch?: (input: string | URL | Request, init?: RequestInit) => Promise; + token?: string; + map?: (entry: any) => { id: string; content: string; source?: string }; +} +const requestJson = async ( + url: string, + options: VendorAdapterOptions, + headers: HeadersInit = {}, +) => { + const response = await (options.fetch ?? globalThis.fetch)(url, { headers }); + if (!response.ok) throw new Error(`WRN-CONTENT-CMS: ${response.status}`); + return response.json(); +}; +const normalize = (entry: any, source: string, map?: VendorAdapterOptions["map"]) => + map?.(entry) ?? { + id: String(entry.id ?? entry.sys?.id ?? entry._id), + content: String(entry.content ?? entry.body ?? entry.fields?.body ?? ""), + source, + }; + +export function contentfulAdapter( + space: string, + environment = "master", + options: VendorAdapterOptions = {}, +) { + return { + async list(collection: string) { + const url = `https://cdn.contentful.com/spaces/${encodeURIComponent(space)}/environments/${encodeURIComponent(environment)}/entries?content_type=${encodeURIComponent(collection)}`; + const value = await requestJson( + url, + options, + options.token ? { authorization: `Bearer ${options.token}` } : {}, + ); + return (value.items ?? []).map((entry: any) => + normalize(entry, `contentful:${space}:${entry.sys?.id}`, options.map), + ); + }, + }; +} +export function sanityAdapter( + project: string, + dataset: string, + options: VendorAdapterOptions & { apiVersion?: string } = {}, +) { + return { + async list(collection: string) { + const query = encodeURIComponent(`*[_type == $type]`); + const url = `https://${encodeURIComponent(project)}.api.sanity.io/v${options.apiVersion ?? "2024-01-01"}/data/query/${encodeURIComponent(dataset)}?query=${query}&$type=${encodeURIComponent(JSON.stringify(collection))}`; + const value = await requestJson( + url, + options, + options.token ? { authorization: `Bearer ${options.token}` } : {}, + ); + return (value.result ?? []).map((entry: any) => + normalize(entry, `sanity:${project}:${entry._id}`, options.map), + ); + }, + }; +} +export function strapiAdapter(baseUrl: string, options: VendorAdapterOptions = {}) { + const base = new URL(baseUrl); + if (!/^https?:$/.test(base.protocol)) throw new Error("Strapi URL must use HTTP(S)"); + return { + async list(collection: string) { + if (!/^[A-Za-z0-9_-]+$/.test(collection)) throw new Error("Invalid Strapi collection"); + const value = await requestJson( + new URL(`/api/${collection}`, base).href, + options, + options.token ? { authorization: `Bearer ${options.token}` } : {}, + ); + return (value.data ?? []).map((entry: any) => + normalize(entry, `strapi:${base.host}:${entry.id}`, options.map), + ); + }, + }; +} diff --git a/packages/content/src/index.ts b/packages/content/src/index.ts new file mode 100644 index 00000000..11432e03 --- /dev/null +++ b/packages/content/src/index.ts @@ -0,0 +1,297 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { extname, join, relative, resolve } from "node:path"; + +export interface ContentSchema { + parse(input: unknown): T; +} +export interface ContentEntry> { + id: string; + slug: string; + collection: string; + data: T; + body: string; + html: string; + excerpt: string; + headings: ContentHeading[]; + draft: boolean; + version?: string; + source: string; +} +export interface ContentHeading { + depth: number; + text: string; + slug: string; +} +export interface ContentLoaderResult { + id: string; + source: string; + content: string; +} +export interface ContentLoader { + load(): ContentLoaderResult[] | Promise; +} +export interface ContentCollectionOptions { + name: string; + schema: ContentSchema; + loader: ContentLoader; + includeDrafts?: boolean; + previewToken?: string; + references?: Record>; +} +export interface ContentCollection { + name: string; + load(options?: { + drafts?: boolean; + previewToken?: string; + version?: string; + }): Promise[]>; + get( + id: string, + options?: { drafts?: boolean; previewToken?: string; version?: string }, + ): Promise | null>; +} + +function scalar(value: string): unknown { + const text = value.trim(); + if (/^(true|false)$/i.test(text)) return text.toLowerCase() === "true"; + if (/^-?\d+(?:\.\d+)?$/.test(text)) return Number(text); + if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) + return text.slice(1, -1); + if (text.startsWith("[") && text.endsWith("]")) + return text + .slice(1, -1) + .split(",") + .map((item) => scalar(item)); + return text; +} +export function parseFrontmatter(source: string): { data: Record; body: string } { + if (!source.startsWith("---\n") && !source.startsWith("---\r\n")) + return { data: {}, body: source }; + const normalized = source.replace(/\r\n/g, "\n"); + const end = normalized.indexOf("\n---\n", 4); + if (end < 0) throw new Error("WRN-CONTENT-FRONTMATTER: closing delimiter is missing."); + const data: Record = {}; + for (const line of normalized.slice(4, end).split("\n")) { + if (!line.trim() || line.trimStart().startsWith("#")) continue; + const match = /^([A-Za-z_$][\w$.-]*):\s*(.*)$/.exec(line); + if (!match) throw new Error(`WRN-CONTENT-FRONTMATTER: invalid line '${line}'.`); + data[match[1]!] = scalar(match[2]!); + } + return { data, body: normalized.slice(end + 5) }; +} +const escape = (value: string) => + value.replace( + /[&<>"']/g, + (character) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]!, + ); +const slugify = (value: string) => + value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); +export function renderMarkdown(source: string): { + html: string; + headings: ContentHeading[]; + excerpt: string; +} { + const headings: ContentHeading[] = []; + const lines = source.replace(/\r\n/g, "\n").split("\n"); + const output: string[] = []; + let code: string[] | null = null; + let language = ""; + for (const line of lines) { + const fence = /^```([\w-]*)/.exec(line); + if (fence) { + if (code) { + output.push( + `
${escape(code.join("\n"))}
`, + ); + code = null; + } else { + code = []; + language = fence[1] ?? ""; + } + continue; + } + if (code) { + code.push(line); + continue; + } + const heading = /^(#{1,6})\s+(.+)$/.exec(line); + if (heading) { + const text = heading[2]!.trim(); + const item = { depth: heading[1]!.length, text, slug: slugify(text) }; + headings.push(item); + output.push(`${escape(text)}`); + } else if (line.trim()) + output.push( + `

${escape(line.trim()).replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+|\/[^\s)]*)\)/g, '$1')}

`, + ); + } + if (code) throw new Error("WRN-CONTENT-MARKDOWN: code fence is not closed."); + const excerpt = + lines + .find((line) => line.trim() && !line.startsWith("#") && !line.startsWith("---")) + ?.trim() + .slice(0, 240) ?? ""; + return { html: output.join("\n"), headings, excerpt }; +} + +export function localContentLoader(directory: string): ContentLoader { + const root = resolve(directory); + return { + load() { + if (!existsSync(root)) return []; + const values: ContentLoaderResult[] = []; + const walk = (current: string) => { + for (const entry of readdirSync(current, { withFileTypes: true })) { + if (entry.name.startsWith(".")) continue; + const path = join(current, entry.name); + if (entry.isDirectory()) walk(path); + else if ([".md", ".mdx"].includes(extname(entry.name))) + values.push({ + id: relative(root, path) + .replace(/\\/g, "/") + .replace(/\.mdx?$/, ""), + source: path, + content: readFileSync(path, "utf8"), + }); + } + }; + walk(root); + return values.sort((left, right) => left.id.localeCompare(right.id)); + }, + }; +} +export function remoteContentLoader( + url: string, + options: { + fetch?: (input: string | URL | Request, init?: RequestInit) => Promise; + headers?: HeadersInit; + } = {}, +): ContentLoader { + return { + async load() { + const response = await (options.fetch ?? globalThis.fetch)(url, { headers: options.headers }); + if (!response.ok) throw new Error(`WRN-CONTENT-REMOTE: ${response.status}`); + const value = (await response.json()) as Array<{ + id: string; + content: string; + source?: string; + }>; + if (!Array.isArray(value)) throw new TypeError("WRN-CONTENT-REMOTE: expected an array."); + return value.map((entry) => ({ + id: entry.id, + content: entry.content, + source: entry.source ?? url, + })); + }, + }; +} +export function defineCollection(options: ContentCollectionOptions): ContentCollection { + const parse = (raw: ContentLoaderResult): ContentEntry => { + const parsed = parseFrontmatter(raw.content); + const rendered = renderMarkdown(parsed.body); + const data = options.schema.parse(parsed.data); + const record = data as Record; + return { + id: raw.id, + slug: String(record.slug ?? raw.id), + collection: options.name, + data, + body: parsed.body, + ...rendered, + draft: record.draft === true, + version: typeof record.version === "string" ? record.version : undefined, + source: raw.source, + }; + }; + const load = async ( + filter: { drafts?: boolean; previewToken?: string; version?: string } = {}, + ) => { + const preview = Boolean(options.previewToken && filter.previewToken === options.previewToken); + const drafts = options.includeDrafts || filter.drafts || preview; + return (await options.loader.load()) + .map(parse) + .filter( + (entry) => + (drafts || !entry.draft) && (!filter.version || entry.version === filter.version), + ); + }; + return { + name: options.name, + load, + async get(id, filter) { + return (await load(filter)).find((entry) => entry.id === id || entry.slug === id) ?? null; + }, + }; +} +export function resolveContentReference( + collections: Record>, + reference: string, +) { + const [collection, id] = reference.split(":", 2); + if (!collection || !id || !collections[collection]) + throw new Error(`WRN-CONTENT-REFERENCE: '${reference}' is invalid.`); + return collections[collection].get(id); +} +export function paginateContent(entries: T[], page = 1, pageSize = 10) { + if (!Number.isInteger(page) || page < 1 || !Number.isInteger(pageSize) || pageSize < 1) + throw new RangeError("content pagination values must be positive integers"); + const totalPages = Math.max(1, Math.ceil(entries.length / pageSize)); + return { + items: entries.slice((page - 1) * pageSize, page * pageSize), + page, + pageSize, + total: entries.length, + totalPages, + hasNext: page < totalPages, + hasPrevious: page > 1, + }; +} +export function createSearchIndex(entries: ContentEntry[]) { + return entries.map((entry) => ({ + id: entry.id, + slug: entry.slug, + title: String((entry.data as Record).title ?? entry.id), + text: `${entry.excerpt} ${entry.headings.map((heading) => heading.text).join(" ")}`.toLowerCase(), + })); +} +export function searchContent(index: ReturnType, query: string) { + const terms = query.toLowerCase().split(/\s+/).filter(Boolean); + return index.filter((entry) => + terms.every((term) => `${entry.title} ${entry.text}`.toLowerCase().includes(term)), + ); +} +export function contentSitemap(entries: ContentEntry[], baseUrl: string) { + return `${entries.map((entry) => `${escape(new URL(entry.slug, baseUrl).href)}`).join("")}`; +} +export function contentRss( + entries: ContentEntry[], + options: { title: string; baseUrl: string; description?: string }, +) { + return `${escape(options.title)}${escape(options.baseUrl)}${escape(options.description ?? options.title)}${entries.map((entry) => `${escape(String((entry.data as Record<string, unknown>).title ?? entry.id))}${escape(new URL(entry.slug, options.baseUrl).href)}${escape(entry.id)}${escape(entry.excerpt)}`).join("")}`; +} +export interface CmsAdapter { + list(collection: string): Promise>; +} +export function cmsContentLoader(adapter: CmsAdapter, collection: string): ContentLoader { + return { + async load() { + return (await adapter.list(collection)).map((entry) => ({ + ...entry, + source: entry.source ?? `cms:${collection}:${entry.id}`, + })); + }, + }; +} +export { + renderMdxComponents, + createIncrementalHighlighter, + contentfulAdapter, + sanityAdapter, + strapiAdapter, +} from "./advanced.ts"; +export type { MdxComponent, SyntaxLanguageBundle, VendorAdapterOptions } from "./advanced.ts"; diff --git a/packages/content/test/advanced.test.ts b/packages/content/test/advanced.test.ts new file mode 100644 index 00000000..d48d05fb --- /dev/null +++ b/packages/content/test/advanced.test.ts @@ -0,0 +1,53 @@ +import { expect, test } from "bun:test"; +import { + contentfulAdapter, + createIncrementalHighlighter, + renderMdxComponents, + sanityAdapter, + strapiAdapter, +} from "../src/index.ts"; + +test("MDX executes only explicitly registered bounded components", () => { + const html = renderMdxComponents(`Safe`, { + Callout: (props, children) => ``, + }); + expect(html).toBe(``); + expect(() => renderMdxComponents(``, {})).toThrow("not registered"); +}); + +test("syntax language bundles load once and only when encountered", async () => { + let loads = 0; + const highlighter = createIncrementalHighlighter({ + ts: async () => { + loads++; + return { highlight: (source) => `${source}` }; + }, + }); + expect(highlighter.languages()).toEqual([]); + const html = await highlighter.render(`
const x = 1;
`); + expect(html).toContain("const x = 1;"); + await highlighter.highlight("ts", "again"); + expect(loads).toBe(1); +}); + +test("vendor adapters construct encoded authenticated requests and normalize records", async () => { + const urls: string[] = []; + const fetcher = async (input: RequestInfo | URL) => { + urls.push(String(input)); + return Response.json({ + items: [{ sys: { id: "c1" }, fields: { body: "Contentful" } }], + result: [{ _id: "s1", body: "Sanity" }], + data: [{ id: "t1", body: "Strapi" }], + }); + }; + expect((await contentfulAdapter("space", "master", { fetch: fetcher }).list("post"))[0]?.id).toBe( + "c1", + ); + expect((await sanityAdapter("project", "dataset", { fetch: fetcher }).list("post"))[0]?.id).toBe( + "s1", + ); + expect((await strapiAdapter("https://cms.test", { fetch: fetcher }).list("posts"))[0]?.id).toBe( + "t1", + ); + expect(urls.every((url) => url.startsWith("https://"))).toBeTrue(); +}); diff --git a/packages/content/test/content.test.ts b/packages/content/test/content.test.ts new file mode 100644 index 00000000..bb14abd2 --- /dev/null +++ b/packages/content/test/content.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + contentRss, + contentSitemap, + createSearchIndex, + defineCollection, + localContentLoader, + paginateContent, + parseFrontmatter, + remoteContentLoader, + searchContent, +} from "../src/index.ts"; +const roots: string[] = []; +afterEach(async () => + Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))), +); +const schema = { + parse(input: unknown) { + const value = input as Record; + if (typeof value.title !== "string") throw new Error("title required"); + return { + title: value.title, + draft: value.draft === true, + version: String(value.version ?? "v1"), + }; + }, +}; +describe("typed content collections", () => { + test("loads, validates, renders, filters drafts and supports preview/versioning", async () => { + const root = join(tmpdir(), `wrn-content-${crypto.randomUUID()}`); + roots.push(root); + await mkdir(root); + await writeFile( + join(root, "hello.md"), + "---\ntitle: Hello\nversion: v1\n---\n# Welcome\nUseful documentation.", + ); + await writeFile(join(root, "draft.mdx"), "---\ntitle: Draft\ndraft: true\n---\nHidden"); + const collection = defineCollection({ + name: "docs", + schema, + loader: localContentLoader(root), + previewToken: "secret", + }); + expect(await collection.load()).toHaveLength(1); + const preview = await collection.load({ previewToken: "secret" }); + expect(preview).toHaveLength(2); + expect(preview[1]!.html).toContain(" { + const collection = defineCollection({ + name: "docs", + schema, + loader: remoteContentLoader("https://cms.test/docs", { + fetch: async () => + new Response( + JSON.stringify([ + { id: "guide", content: "---\ntitle: Guide\n---\n# Start\nSearch words" }, + ]), + ) as any, + }), + }); + const entries = await collection.load(); + expect(searchContent(createSearchIndex(entries), "search words")).toHaveLength(1); + expect(paginateContent(entries, 1, 1)).toMatchObject({ total: 1, totalPages: 1 }); + expect(contentRss(entries, { title: "Docs", baseUrl: "https://example.test/" })).toContain( + " { + expect(() => parseFrontmatter("---\ntitle nope\n---\nbody")).toThrow("WRN-CONTENT-FRONTMATTER"); + }); +}); diff --git a/packages/core/README.md b/packages/core/README.md index e338b6b5..0a08481e 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -110,6 +110,37 @@ instances. The default store is process-local memory. (default `console.log`), `requestIdKey` (default `"requestId"`), `now`. `RequestRecord` = `{ time, id, method, path, status, durationMs }`. +### Resilience — `@wrnexus/core` + +`resilientCall` standardizes cancellation-aware timeouts, controlled retries, +fixed or exponential backoff, fallback responses, circuit breaking, and bounded +concurrency. Reuse a declarative circuit/bulkhead options object, or an explicit +`CircuitBreaker`/`Bulkhead` instance, wherever calls must share health and +capacity state. + +```ts +import { resilientCall } from "@wrnexus/core"; + +const paymentCircuit = { failures: 5, resetAfter: "30s" } as const; + +const status = await resilientCall({ + timeout: "5s", + retries: 3, + retryDelay: "100ms", + backoff: "exponential", + circuitBreaker: paymentCircuit, + bulkhead: { concurrency: 20, queue: 100 }, + run: (signal) => paymentProvider.checkStatus({ signal }), + fallback: () => ({ state: "unavailable" }), +}); +``` + +`CircuitBreaker.snapshot()` reports `closed`, `open`, or `half-open`, failure +and success counts, and the remaining retry delay for health endpoints and +development tooling. Fail-fast conditions use stable `WRN-RESILIENCE-*` codes. +Core's existing `HealthRegistry`, `withIdempotency`, and pluggable stores/locks +cover health reporting, idempotent requests, and distributed coordination. + ### Caching — `@wrnexus/core` | Export | Kind | Notes | diff --git a/packages/core/package.json b/packages/core/package.json index b7122fd1..65b2213b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/core", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/core/src/endpoint.ts b/packages/core/src/endpoint.ts index ce29346f..1d3e6b27 100644 --- a/packages/core/src/endpoint.ts +++ b/packages/core/src/endpoint.ts @@ -4,6 +4,13 @@ export interface SchemaLike { parse(input: unknown): T; } +export interface OutputSchemaLike { + readonly __output: T; + parse(input: unknown): unknown; +} +export type InferEndpointSchema = + TSchema extends OutputSchemaLike ? TValue : never; + export interface EndpointErrorBody { code: string; message: string; @@ -23,8 +30,8 @@ export class EndpointError extends Error { } export interface EndpointDefinition { - input?: SchemaLike; - output?: SchemaLike; + input?: SchemaLike | OutputSchemaLike; + output?: SchemaLike | OutputSchemaLike; auth?: "optional" | "required"; description?: string; tags?: string[]; @@ -43,18 +50,55 @@ function json(body: unknown, status = 200): Response { }); } +function schemaValue(schema: SchemaLike | OutputSchemaLike, input: unknown): T { + const parsed = schema.parse(input); + if ( + parsed && + typeof parsed === "object" && + "ok" in parsed && + "value" in parsed && + typeof (parsed as { ok?: unknown }).ok === "boolean" + ) { + const result = parsed as { ok: boolean; value: T; errors?: unknown }; + if (!result.ok) + throw new EndpointError( + 400, + "VALIDATION_ERROR", + "Endpoint validation failed.", + result.errors, + ); + return result.value; + } + return parsed as T; +} + /** Define a validated, typed endpoint that can also drive SDK/OpenAPI generation. */ +export function defineEndpoint< + InputSchema extends OutputSchemaLike, + OutputSchema extends OutputSchemaLike, +>( + definition: Omit< + EndpointDefinition, InferEndpointSchema>, + "input" | "output" + > & { + input: InputSchema; + output: OutputSchema; + }, +): DefinedEndpoint, InferEndpointSchema>; export function defineEndpoint( definition: EndpointDefinition, -): DefinedEndpoint { +): DefinedEndpoint; +export function defineEndpoint( + definition: EndpointDefinition, +): DefinedEndpoint { const endpoint = async (ctx: Context, rawInput?: unknown): Promise => { try { if (definition.auth === "required" && !ctx.user) { throw new EndpointError(401, "UNAUTHENTICATED", "Authentication is required."); } - const input = definition.input ? definition.input.parse(rawInput) : (rawInput as I); + const input = definition.input ? schemaValue(definition.input, rawInput) : rawInput; const rawOutput = await definition.handler(input, ctx); - const output = definition.output ? definition.output.parse(rawOutput) : rawOutput; + const output = definition.output ? schemaValue(definition.output, rawOutput) : rawOutput; return output instanceof Response ? output : json({ data: output }); } catch (error) { if (error instanceof EndpointError) { diff --git a/packages/core/src/execution-context.ts b/packages/core/src/execution-context.ts new file mode 100644 index 00000000..6761cdf6 --- /dev/null +++ b/packages/core/src/execution-context.ts @@ -0,0 +1,139 @@ +import type { Context } from "./context.ts"; +import type { Tenant } from "./tenant.ts"; +import type { Tracer } from "./observability.ts"; + +export type ExecutionKind = + "http" | "api" | "action" | "loader" | "middleware" | "realtime" | "queue" | "cron" | "webhook"; +export interface ResponseContext { + status: number; + headers: Headers; + setStatus(status: number): void; +} +export interface ExecutionContext { + kind: ExecutionKind; + id: string; + request: Request; + response: ResponseContext; + user: unknown | null; + session: unknown | null; + tenant: Tenant | null; + locale: string; + timezone: string; + db?: unknown; + cache?: unknown; + logger?: unknown; + trace?: Tracer; + signal: AbortSignal; + deadline: Date | null; + metadata: Record; + authorize(permission: string): void | Promise; +} +export interface ExecutionContextInput extends Partial< + Omit< + ExecutionContext, + "kind" | "id" | "request" | "response" | "signal" | "deadline" | "metadata" | "authorize" + > +> { + kind: ExecutionKind; + id?: string; + request?: Request; + response?: Partial> & { headers?: HeadersInit }; + signal?: AbortSignal; + deadline?: Date | number | null; + timeoutMs?: number; + metadata?: Record; + authorize?: (permission: string) => void | Promise; +} +export function createExecutionContext(input: ExecutionContextInput): ExecutionContext { + const controller = new AbortController(); + const source = input.signal; + if (source?.aborted) controller.abort(source.reason); + else source?.addEventListener("abort", () => controller.abort(source.reason), { once: true }); + const deadline = + input.deadline instanceof Date + ? input.deadline + : typeof input.deadline === "number" + ? new Date(input.deadline) + : input.timeoutMs !== undefined + ? new Date(Date.now() + input.timeoutMs) + : null; + let timer: ReturnType | undefined; + if (deadline) { + const delay = deadline.getTime() - Date.now(); + if (delay <= 0) + controller.abort(new DOMException("Execution deadline exceeded", "TimeoutError")); + else { + timer = setTimeout( + () => controller.abort(new DOMException("Execution deadline exceeded", "TimeoutError")), + delay, + ); + timer.unref?.(); + } + } + controller.signal.addEventListener( + "abort", + () => { + if (timer) clearTimeout(timer); + }, + { once: true }, + ); + const response: ResponseContext = { + status: input.response?.status ?? 200, + headers: new Headers(input.response?.headers), + setStatus(status) { + if (!Number.isInteger(status) || status < 100 || status > 599) + throw new RangeError("response status must be an HTTP status code"); + this.status = status; + }, + }; + return { + kind: input.kind, + id: input.id ?? crypto.randomUUID(), + request: input.request ?? new Request(`https://execution.wrnexus.invalid/${input.kind}`), + response, + user: input.user ?? null, + session: input.session ?? null, + tenant: input.tenant ?? null, + locale: input.locale ?? "en", + timezone: input.timezone ?? "UTC", + db: input.db, + cache: input.cache, + logger: input.logger, + trace: input.trace, + signal: controller.signal, + deadline, + metadata: { ...(input.metadata ?? {}) }, + authorize: + input.authorize ?? + (() => { + throw new Error("WRN-AUTHORIZATION-NOT-CONFIGURED"); + }), + }; +} +export function executionContextFromHttp( + context: Context, + kind: Extract< + ExecutionKind, + "http" | "api" | "action" | "loader" | "middleware" | "webhook" + > = "http", + input: Omit< + ExecutionContextInput, + "kind" | "request" | "user" | "tenant" | "locale" | "trace" + > = {}, +): ExecutionContext { + return createExecutionContext({ + ...input, + kind, + request: context.req, + user: context.user ?? null, + session: context.session, + tenant: context.tenant ?? null, + locale: context.lang || "en", + trace: context.tracer, + signal: input.signal ?? context.req.signal, + db: input.db ?? context.locals.db, + cache: input.cache ?? context.locals.cache, + logger: input.logger ?? context.locals.logger, + metadata: { ...context.locals, ...(input.metadata ?? {}) }, + }); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2b9e6443..dbf0d0ba 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,6 +11,13 @@ export type { TFunction, } from "./context.ts"; export { createContext, withContextHeaders } from "./context.ts"; +export { createExecutionContext, executionContextFromHttp } from "./execution-context.ts"; +export type { + ExecutionContext, + ExecutionContextInput, + ExecutionKind, + ResponseContext, +} from "./execution-context.ts"; export { escapeHtml, isSafeIslandName, isSafeRequestPath } from "./security.ts"; export { csrfToken, verifyCsrf, csrfProtection, CSRF_COOKIE, CSRF_HEADER } from "./csrf.ts"; @@ -132,6 +139,8 @@ export type { EndpointErrorBody, RpcClientOptions, SchemaLike, + OutputSchemaLike, + InferEndpointSchema, } from "./endpoint.ts"; export { defineAction, defineLoader, dedupe } from "./data.ts"; @@ -143,8 +152,36 @@ export type { LoaderDefinition, } from "./data.ts"; -export { requireTenant, tenantFromSubdomain, tenantMiddleware, tenantScope } from "./tenant.ts"; -export type { Tenant, TenantMiddlewareOptions, TenantResolver } from "./tenant.ts"; +export { + assertTenantAccess, + composeTenantResolvers, + createTenantDirectory, + createPersistentTenantDirectory, + memoryTenantDirectoryStore, + postgresTenantDirectoryStore, + migrateTenants, + POSTGRES_TENANT_DIRECTORY_SCHEMA, + requireTenant, + tenantFromDomain, + tenantFromHeader, + tenantFromPath, + tenantFromSession, + tenantFromSubdomain, + tenantKey, + tenantMiddleware, + tenantScope, +} from "./tenant.ts"; +export type { + Tenant, + TenantAuditEvent, + TenantMembership, + TenantQuota, + TenantDirectoryStore, + TenantSqlClient, + TenantMiddlewareOptions, + TenantResolver, + TenantResource, +} from "./tenant.ts"; export { createTracer, tracingMiddleware, withSpan } from "./observability.ts"; export type { Span, SpanRecord, Tracer } from "./observability.ts"; @@ -154,6 +191,21 @@ export type { FeatureFlags, FeatureRule, FeatureValue } from "./features.ts"; export { checkPerformanceBudgets, recommendedWebBudgets } from "./performance.ts"; export type { BudgetViolation, PerformanceBudgets, PerformanceMeasurement } from "./performance.ts"; +export { + Bulkhead, + CircuitBreaker, + ResilienceError, + durationMs, + resilientCall, +} from "./resilience.ts"; +export type { + BackoffStrategy, + BulkheadOptions, + CircuitBreakerOptions, + CircuitBreakerSnapshot, + Duration, + ResilientCallOptions, +} from "./resilience.ts"; export { problem, serviceToken, diff --git a/packages/core/src/realtime.ts b/packages/core/src/realtime.ts index 69c52ca0..76ff19ca 100644 --- a/packages/core/src/realtime.ts +++ b/packages/core/src/realtime.ts @@ -119,7 +119,7 @@ export interface RealtimeSecurityOptions { onViolation?(reason: string, client?: RoomClient): void; } -export interface RoomHandlers> { +export interface RoomHandlers, TMessage = any> { /** Per-room abuse and payload controls. */ security?: RealtimeSecurityOptions; /** @@ -130,20 +130,20 @@ export interface RoomHandlers> { /** A client connected (a new tab joined the room). */ onConnect?(client: RoomClient): void | Promise; /** A message arrived (JSON is parsed; non-JSON arrives as a string). */ - onMessage?(client: RoomClient, message: any): void | Promise; + onMessage?(client: RoomClient, message: TMessage): void | Promise; /** A client disconnected. */ onLeave?(client: RoomClient): void | Promise; } -export interface RoomDefinition> { +export interface RoomDefinition, TMessage = any> { readonly __wrnexusRoom: true; - readonly handlers: RoomHandlers; + readonly handlers: RoomHandlers; } /** Define a realtime room. Export the result as the `default` of a realtime file. */ -export function defineRoom>( - handlers: RoomHandlers, -): RoomDefinition { +export function defineRoom, TMessage = any>( + handlers: RoomHandlers, +): RoomDefinition { return { __wrnexusRoom: true, handlers }; } diff --git a/packages/core/src/resilience.ts b/packages/core/src/resilience.ts new file mode 100644 index 00000000..af41c5a0 --- /dev/null +++ b/packages/core/src/resilience.ts @@ -0,0 +1,257 @@ +export type Duration = number | `${number}${"ms" | "s" | "m" | "h"}`; + +export type BackoffStrategy = "fixed" | "exponential" | ((attempt: number) => Duration); + +export interface CircuitBreakerOptions { + failures: number; + resetAfter: Duration; + successesToClose?: number; +} + +export interface CircuitBreakerSnapshot { + state: "closed" | "open" | "half-open"; + failures: number; + successes: number; + retryAfterMs: number; +} + +export class ResilienceError extends Error { + constructor( + public readonly code: + | "WRN-RESILIENCE-TIMEOUT" + | "WRN-RESILIENCE-ABORTED" + | "WRN-RESILIENCE-CIRCUIT-OPEN" + | "WRN-RESILIENCE-BULKHEAD-FULL", + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = "ResilienceError"; + } +} + +export function durationMs(value: Duration): number { + if (typeof value === "number") { + if (!Number.isFinite(value) || value < 0) + throw new TypeError("Duration must be finite and non-negative."); + return value; + } + const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)$/.exec(value); + if (!match) throw new TypeError(`Invalid duration: ${value}`); + const scale = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000 }[match[2]!]!; + return Number(match[1]) * scale; +} + +export class CircuitBreaker { + private failures = 0; + private successes = 0; + private openedAt = 0; + private probing = false; + + constructor(private readonly options: CircuitBreakerOptions) { + if (!Number.isInteger(options.failures) || options.failures < 1) { + throw new TypeError("Circuit breaker failures must be a positive integer."); + } + durationMs(options.resetAfter); + } + + snapshot(now = Date.now()): CircuitBreakerSnapshot { + const resetAfter = durationMs(this.options.resetAfter); + const elapsed = now - this.openedAt; + const open = this.openedAt > 0 && elapsed < resetAfter; + return { + state: open ? "open" : this.openedAt > 0 ? "half-open" : "closed", + failures: this.failures, + successes: this.successes, + retryAfterMs: open ? Math.max(0, resetAfter - elapsed) : 0, + }; + } + + async execute(operation: () => Promise): Promise { + const health = this.snapshot(); + if (health.state === "open" || (health.state === "half-open" && this.probing)) { + throw new ResilienceError( + "WRN-RESILIENCE-CIRCUIT-OPEN", + `Circuit is open; retry after ${health.retryAfterMs}ms.`, + ); + } + if (health.state === "half-open") this.probing = true; + try { + const value = await operation(); + this.failures = 0; + this.successes += 1; + if (this.successes >= (this.options.successesToClose ?? 1)) this.openedAt = 0; + return value; + } catch (error) { + this.successes = 0; + this.failures += 1; + if (this.failures >= this.options.failures) this.openedAt = Date.now(); + throw error; + } finally { + this.probing = false; + } + } +} + +export interface BulkheadOptions { + concurrency: number; + queue?: number; +} + +export class Bulkhead { + private active = 0; + private readonly waiting: Array<() => void> = []; + + constructor(private readonly options: BulkheadOptions) { + if (!Number.isInteger(options.concurrency) || options.concurrency < 1) { + throw new TypeError("Bulkhead concurrency must be a positive integer."); + } + if (options.queue !== undefined && (!Number.isInteger(options.queue) || options.queue < 0)) { + throw new TypeError("Bulkhead queue must be a non-negative integer."); + } + } + + get snapshot(): Readonly<{ active: number; queued: number; capacity: number }> { + return { active: this.active, queued: this.waiting.length, capacity: this.options.concurrency }; + } + + async execute(operation: () => Promise): Promise { + if (this.active >= this.options.concurrency) { + if (this.waiting.length >= (this.options.queue ?? 0)) { + throw new ResilienceError( + "WRN-RESILIENCE-BULKHEAD-FULL", + "Bulkhead capacity is exhausted.", + ); + } + await new Promise((resolve) => this.waiting.push(resolve)); + } + this.active += 1; + try { + return await operation(); + } finally { + this.active -= 1; + this.waiting.shift()?.(); + } + } +} + +export interface ResilientCallOptions { + run: (signal: AbortSignal, attempt: number) => Promise; + timeout?: Duration; + retries?: number; + retryDelay?: Duration; + backoff?: BackoffStrategy; + circuitBreaker?: CircuitBreaker | CircuitBreakerOptions; + bulkhead?: Bulkhead | BulkheadOptions; + signal?: AbortSignal; + retryWhen?: (error: unknown, attempt: number) => boolean | Promise; + fallback?: (error: unknown, signal: AbortSignal) => T | Promise; + onRetry?: (error: unknown, attempt: number, delayMs: number) => void; +} + +const breakerInstances = new WeakMap(); +const bulkheadInstances = new WeakMap(); + +function breakerFor(value: CircuitBreaker | CircuitBreakerOptions): CircuitBreaker { + if (value instanceof CircuitBreaker) return value; + const existing = breakerInstances.get(value); + if (existing) return existing; + const created = new CircuitBreaker(value); + breakerInstances.set(value, created); + return created; +} + +function bulkheadFor(value: Bulkhead | BulkheadOptions): Bulkhead { + if (value instanceof Bulkhead) return value; + const existing = bulkheadInstances.get(value); + if (existing) return existing; + const created = new Bulkhead(value); + bulkheadInstances.set(value, created); + return created; +} + +async function abortable(operation: Promise, signal: AbortSignal): Promise { + if (signal.aborted) throw signal.reason; + let cleanup = () => {}; + const aborted = new Promise((_resolve, reject) => { + const listener = () => reject(signal.reason); + signal.addEventListener("abort", listener, { once: true }); + cleanup = () => signal.removeEventListener("abort", listener); + }); + try { + return await Promise.race([operation, aborted]); + } finally { + cleanup(); + } +} + +function abortError(signal: AbortSignal): ResilienceError { + return new ResilienceError("WRN-RESILIENCE-ABORTED", "Resilient call was aborted.", { + cause: signal.reason, + }); +} + +async function delay(ms: number, signal?: AbortSignal): Promise { + if (signal?.aborted) throw abortError(signal); + await new Promise((resolve, reject) => { + const timer = setTimeout(resolve, ms); + signal?.addEventListener( + "abort", + () => { + clearTimeout(timer); + reject(abortError(signal)); + }, + { once: true }, + ); + }); +} + +export async function resilientCall(options: ResilientCallOptions): Promise { + const retries = options.retries ?? 0; + if (!Number.isInteger(retries) || retries < 0) + throw new TypeError("Retries must be a non-negative integer."); + const breaker = options.circuitBreaker ? breakerFor(options.circuitBreaker) : undefined; + const bulkhead = options.bulkhead ? bulkheadFor(options.bulkhead) : undefined; + const invoke = async (): Promise => { + let lastError: unknown; + for (let attempt = 1; attempt <= retries + 1; attempt += 1) { + if (options.signal?.aborted) throw abortError(options.signal); + const controller = new AbortController(); + const forwardAbort = () => controller.abort(options.signal?.reason); + options.signal?.addEventListener("abort", forwardAbort, { once: true }); + const timeout = options.timeout === undefined ? undefined : durationMs(options.timeout); + const timer = + timeout === undefined ? undefined : setTimeout(() => controller.abort("timeout"), timeout); + try { + const run = () => abortable(options.run(controller.signal, attempt), controller.signal); + return await (breaker ? breaker.execute(run) : run()); + } catch (caught) { + lastError = + controller.signal.aborted && !options.signal?.aborted + ? new ResilienceError( + "WRN-RESILIENCE-TIMEOUT", + `Operation timed out after ${timeout}ms.`, + { cause: caught }, + ) + : caught; + if (attempt > retries || !(await (options.retryWhen?.(lastError, attempt) ?? true))) break; + const base = durationMs(options.retryDelay ?? 100); + const wait = + typeof options.backoff === "function" + ? durationMs(options.backoff(attempt)) + : options.backoff === "exponential" + ? base * 2 ** (attempt - 1) + : base; + options.onRetry?.(lastError, attempt, wait); + await delay(wait, options.signal); + } finally { + if (timer !== undefined) clearTimeout(timer); + options.signal?.removeEventListener("abort", forwardAbort); + } + } + if (options.fallback) + return options.fallback(lastError, options.signal ?? new AbortController().signal); + throw lastError; + }; + return bulkhead ? bulkhead.execute(invoke) : invoke(); +} diff --git a/packages/core/src/tenant.ts b/packages/core/src/tenant.ts index ab6d42c0..4c2fa78b 100644 --- a/packages/core/src/tenant.ts +++ b/packages/core/src/tenant.ts @@ -7,6 +7,38 @@ export interface Tenant { metadata?: Record; } +export interface TenantResource { + tenantId: string; +} +export interface TenantMembership { + tenantId: string; + userId: string; + roles?: string[]; + workspaceIds?: string[]; +} +export interface TenantAuditEvent { + tenantId: string; + action: string; + actorId?: string; + resource?: string; + metadata?: Record; + createdAt: number; +} + +export interface TenantQuota { + tenantId: string; + resource: string; + limit: number; + usage: number; +} +export interface TenantDirectoryStore { + putMembership(value: TenantMembership): Promise; + getMembership(tenantId: string, userId: string): Promise; + listMemberships(tenantId: string): Promise; + putQuota(value: TenantQuota): Promise; + getQuota(tenantId: string, resource: string): Promise; +} + export type TenantResolver = (ctx: Context) => Tenant | null | Promise; export interface TenantMiddlewareOptions { @@ -41,6 +73,55 @@ export function tenantFromSubdomain( }; } +export function tenantFromDomain( + lookup: (domain: string, ctx: Context) => Tenant | null | Promise, +): TenantResolver { + return (ctx) => lookup(ctx.url.hostname.toLowerCase(), ctx); +} + +export function tenantFromPath( + lookup: (slug: string, ctx: Context) => Tenant | null | Promise, + prefix = "", +): TenantResolver { + return (ctx) => { + const segments = ctx.url.pathname.split("/").filter(Boolean); + const normalized = prefix.replace(/^\/+|\/+$/g, ""); + const slug = normalized ? (segments[0] === normalized ? segments[1] : undefined) : segments[0]; + return slug ? lookup(slug, ctx) : null; + }; +} + +/** Header resolution is intentionally opt-in and must only be used behind a trusted proxy. */ +export function tenantFromHeader( + lookup: (id: string, ctx: Context) => Tenant | null | Promise, + header = "x-wrnexus-tenant", +): TenantResolver { + return (ctx) => { + const value = ctx.req.headers.get(header)?.trim(); + return value ? lookup(value, ctx) : null; + }; +} + +export function tenantFromSession( + resolveId: (ctx: Context) => string | null | Promise, + lookup: (id: string, ctx: Context) => Tenant | null | Promise, +): TenantResolver { + return async (ctx) => { + const id = await resolveId(ctx); + return id ? lookup(id, ctx) : null; + }; +} + +export function composeTenantResolvers(...resolvers: TenantResolver[]): TenantResolver { + return async (ctx) => { + for (const resolver of resolvers) { + const tenant = await resolver(ctx); + if (tenant) return tenant; + } + return null; + }; +} + export function requireTenant(ctx: Context): Tenant { if (!ctx.tenant) throw new Error("WRN-TENANT-REQUIRED: tenant middleware has not resolved a tenant."); @@ -54,3 +135,230 @@ export function tenantScope( ): T & { tenantId: string } { return Object.assign(Object.create(repository), { tenantId: tenant.id }); } + +export function assertTenantAccess(tenant: Tenant, resource: TenantResource): void { + if (!resource.tenantId || resource.tenantId !== tenant.id) + throw new Error("WRN-TENANT-CROSS-ACCESS: resource does not belong to the active tenant."); +} + +export function tenantKey(tenant: Tenant | string, ...parts: Array): string { + const id = typeof tenant === "string" ? tenant : tenant.id; + if (!id.trim() || id.includes(":")) + throw new TypeError("WRN-TENANT-KEY: tenant id must be non-empty and cannot contain ':'."); + return [ + "tenant", + encodeURIComponent(id), + ...parts.map((part) => encodeURIComponent(String(part))), + ].join(":"); +} + +export function createTenantDirectory( + options: { audit?: (event: TenantAuditEvent) => void | Promise; now?: () => number } = {}, +) { + const memberships = new Map(); + const quotas = new Map>(); + const now = options.now ?? Date.now; + const key = (tenantId: string, userId: string) => `${tenantId}\0${userId}`; + return { + async addMembership(membership: TenantMembership, actorId?: string) { + if (!membership.tenantId || !membership.userId) + throw new TypeError("tenantId and userId are required"); + memberships.set(key(membership.tenantId, membership.userId), structuredClone(membership)); + await options.audit?.({ + tenantId: membership.tenantId, + action: "membership.added", + actorId, + resource: membership.userId, + createdAt: now(), + }); + }, + membership(tenantId: string, userId: string) { + const value = memberships.get(key(tenantId, userId)); + return value ? structuredClone(value) : null; + }, + async switchWorkspace(tenantId: string, userId: string, workspaceId: string) { + const membership = memberships.get(key(tenantId, userId)); + if (!membership?.workspaceIds?.includes(workspaceId)) + throw new Error("WRN-TENANT-WORKSPACE-DENIED"); + await options.audit?.({ + tenantId, + action: "workspace.switched", + actorId: userId, + resource: workspaceId, + createdAt: now(), + }); + return { tenantId, workspaceId }; + }, + setQuota(tenantId: string, resource: string, limit: number) { + if (!Number.isFinite(limit) || limit < 0) + throw new RangeError("tenant quota must be non-negative"); + const values = quotas.get(tenantId) ?? new Map(); + values.set(resource, limit); + quotas.set(tenantId, values); + }, + enforceQuota(tenantId: string, resource: string, usage: number, requested = 0) { + const limit = quotas.get(tenantId)?.get(resource); + if (limit !== undefined && usage + requested > limit) + throw new Error(`WRN-TENANT-QUOTA: ${resource} quota exceeded.`); + return { usage, requested, limit }; + }, + }; +} + +export function memoryTenantDirectoryStore(): TenantDirectoryStore { + const memberships = new Map(); + const quotas = new Map(); + return { + async putMembership(value) { + memberships.set(`${value.tenantId}\0${value.userId}`, structuredClone(value)); + }, + async getMembership(tenantId, userId) { + const value = memberships.get(`${tenantId}\0${userId}`); + return value ? structuredClone(value) : null; + }, + async listMemberships(tenantId) { + return [...memberships.values()] + .filter((value) => value.tenantId === tenantId) + .map((value) => structuredClone(value)); + }, + async putQuota(value) { + quotas.set(`${value.tenantId}\0${value.resource}`, structuredClone(value)); + }, + async getQuota(tenantId, resource) { + const value = quotas.get(`${tenantId}\0${resource}`); + return value ? structuredClone(value) : null; + }, + }; +} + +export function createPersistentTenantDirectory( + store: TenantDirectoryStore, + options: { audit?: (event: TenantAuditEvent) => void | Promise; now?: () => number } = {}, +) { + const now = options.now ?? Date.now; + return { + async addMembership(membership: TenantMembership, actorId?: string) { + if (!membership.tenantId || !membership.userId) + throw new TypeError("tenantId and userId are required"); + await store.putMembership(structuredClone(membership)); + await options.audit?.({ + tenantId: membership.tenantId, + action: "membership.added", + actorId, + resource: membership.userId, + createdAt: now(), + }); + }, + membership: (tenantId: string, userId: string) => store.getMembership(tenantId, userId), + memberships: (tenantId: string) => store.listMemberships(tenantId), + async switchWorkspace(tenantId: string, userId: string, workspaceId: string) { + const membership = await store.getMembership(tenantId, userId); + if (!membership?.workspaceIds?.includes(workspaceId)) + throw new Error("WRN-TENANT-WORKSPACE-DENIED"); + await options.audit?.({ + tenantId, + action: "workspace.switched", + actorId: userId, + resource: workspaceId, + createdAt: now(), + }); + return { tenantId, workspaceId }; + }, + async setQuota(tenantId: string, resource: string, limit: number, usage = 0) { + if (!Number.isFinite(limit) || limit < 0 || !Number.isFinite(usage) || usage < 0) + throw new RangeError("tenant quota values must be non-negative"); + await store.putQuota({ tenantId, resource, limit, usage }); + }, + async consumeQuota(tenantId: string, resource: string, requested: number) { + if (!Number.isFinite(requested) || requested < 0) + throw new RangeError("requested quota must be non-negative"); + const quota = await store.getQuota(tenantId, resource); + if (quota && quota.usage + requested > quota.limit) + throw new Error(`WRN-TENANT-QUOTA: ${resource} quota exceeded.`); + if (quota) { + quota.usage += requested; + await store.putQuota(quota); + } + return quota; + }, + }; +} + +export interface TenantSqlClient { + query>(sql: string, parameters?: unknown[]): Promise<{ rows: T[] }>; +} +export function postgresTenantDirectoryStore(db: TenantSqlClient): TenantDirectoryStore { + return { + async putMembership(value) { + await db.query( + `INSERT INTO wrnexus_tenant_memberships (tenant_id,user_id,roles,workspace_ids) VALUES ($1,$2,$3,$4) ON CONFLICT (tenant_id,user_id) DO UPDATE SET roles=$3,workspace_ids=$4`, + [ + value.tenantId, + value.userId, + JSON.stringify(value.roles ?? []), + JSON.stringify(value.workspaceIds ?? []), + ], + ); + }, + async getMembership(tenantId, userId) { + const result = await db.query( + `SELECT tenant_id AS "tenantId",user_id AS "userId",roles,workspace_ids AS "workspaceIds" FROM wrnexus_tenant_memberships WHERE tenant_id=$1 AND user_id=$2`, + [tenantId, userId], + ); + return result.rows[0] ?? null; + }, + async listMemberships(tenantId) { + const result = await db.query( + `SELECT tenant_id AS "tenantId",user_id AS "userId",roles,workspace_ids AS "workspaceIds" FROM wrnexus_tenant_memberships WHERE tenant_id=$1 ORDER BY user_id`, + [tenantId], + ); + return result.rows; + }, + async putQuota(value) { + await db.query( + `INSERT INTO wrnexus_tenant_quotas (tenant_id,resource,quota_limit,usage) VALUES ($1,$2,$3,$4) ON CONFLICT (tenant_id,resource) DO UPDATE SET quota_limit=$3,usage=$4`, + [value.tenantId, value.resource, value.limit, value.usage], + ); + }, + async getQuota(tenantId, resource) { + const result = await db.query( + `SELECT tenant_id AS "tenantId",resource,quota_limit AS "limit",usage FROM wrnexus_tenant_quotas WHERE tenant_id=$1 AND resource=$2`, + [tenantId, resource], + ); + return result.rows[0] ?? null; + }, + }; +} + +export const POSTGRES_TENANT_DIRECTORY_SCHEMA = `CREATE TABLE IF NOT EXISTS wrnexus_tenant_memberships (tenant_id text NOT NULL,user_id text NOT NULL,roles jsonb NOT NULL DEFAULT '[]',workspace_ids jsonb NOT NULL DEFAULT '[]',PRIMARY KEY (tenant_id,user_id)); CREATE TABLE IF NOT EXISTS wrnexus_tenant_quotas (tenant_id text NOT NULL,resource text NOT NULL,quota_limit bigint NOT NULL,usage bigint NOT NULL DEFAULT 0,PRIMARY KEY (tenant_id,resource));`; + +export async function migrateTenants( + tenants: T[], + migrate: (tenant: T) => void | Promise, + options: { concurrency?: number; continueOnError?: boolean } = {}, +) { + const concurrency = options.concurrency ?? 4; + if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 32) + throw new RangeError("Tenant migration concurrency must be between 1 and 32"); + const pending = [...tenants]; + const migrated: string[] = []; + const failed: Array<{ tenantId: string; error: string }> = []; + await Promise.all( + Array.from({ length: Math.min(concurrency, pending.length) }, async () => { + while (pending.length) { + const tenant = pending.shift()!; + try { + await migrate(tenant); + migrated.push(tenant.id); + } catch (error) { + failed.push({ + tenantId: tenant.id, + error: error instanceof Error ? error.message : String(error), + }); + if (!options.continueOnError) pending.length = 0; + } + } + }), + ); + return { migrated, failed }; +} diff --git a/packages/core/test/endpoint-schema.test.ts b/packages/core/test/endpoint-schema.test.ts new file mode 100644 index 00000000..a3eeb05a --- /dev/null +++ b/packages/core/test/endpoint-schema.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from "bun:test"; +import { createContext, defineEndpoint } from "../src/index.ts"; +import { v } from "@wrnexus/validation"; + +const user = v.object({ name: v.string().min(2), email: v.string().email() }); +const endpoint = defineEndpoint({ + input: user, + output: user, + handler(input) { + return input; + }, +}); + +test("typed endpoints unwrap official validation schemas and return bounded validation errors", async () => { + const request = new Request("https://example.test/api/user"); + const ctx = createContext(request, new URL(request.url)); + const invalid = await endpoint(ctx, { name: "A", email: "bad" }); + expect(invalid.status).toBe(400); + expect(await invalid.json()).toEqual({ + error: { + code: "VALIDATION_ERROR", + message: "Endpoint validation failed.", + details: { + name: "Must be at least 2 characters", + email: "Must be a valid email", + }, + }, + }); + const valid = await endpoint(ctx, { name: "Ada", email: "ada@example.test" }); + expect(await valid.json()).toEqual({ data: { name: "Ada", email: "ada@example.test" } }); +}); diff --git a/packages/core/test/execution-context.test.ts b/packages/core/test/execution-context.test.ts new file mode 100644 index 00000000..a4bbc1db --- /dev/null +++ b/packages/core/test/execution-context.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test"; +import { createContext, createExecutionContext, executionContextFromHttp } from "../src/index.ts"; + +test("unified execution context spans HTTP and background operations", async () => { + const http = createContext( + new Request("https://app.test/users"), + new URL("https://app.test/users"), + ); + http.lang = "fr"; + http.user = { id: "u1" }; + http.locals.db = { users: true }; + const execution = executionContextFromHttp(http, "action", { + authorize: (permission) => { + expect(permission).toBe("users.create"); + }, + }); + await execution.authorize("users.create"); + expect(execution).toMatchObject({ + kind: "action", + locale: "fr", + user: { id: "u1" }, + db: { users: true }, + }); + execution.response.setStatus(201); + expect(execution.response.status).toBe(201); + const queue = createExecutionContext({ kind: "queue", metadata: { job: "email" } }); + expect(queue.request.url).toBe("https://execution.wrnexus.invalid/queue"); +}); diff --git a/packages/core/test/fullstack-primitives.test.ts b/packages/core/test/fullstack-primitives.test.ts index 5cd99015..5651809f 100644 --- a/packages/core/test/fullstack-primitives.test.ts +++ b/packages/core/test/fullstack-primitives.test.ts @@ -9,6 +9,9 @@ import { defineFeatureFlags, defineLoader, tenantFromSubdomain, + assertTenantAccess, + createTenantDirectory, + tenantKey, tracingMiddleware, } from "../src/index.ts"; @@ -37,6 +40,31 @@ test("typed endpoints validate authentication and preserve a stable JSON envelop }); }); +test("tenant boundaries, memberships, workspaces, quotas, and audit events fail closed", async () => { + const events: string[] = []; + const directory = createTenantDirectory({ + audit: (event) => { + events.push(event.action); + }, + now: () => 10, + }); + await directory.addMembership({ tenantId: "acme", userId: "u1", workspaceIds: ["north"] }); + expect(await directory.switchWorkspace("acme", "u1", "north")).toEqual({ + tenantId: "acme", + workspaceId: "north", + }); + await expect(directory.switchWorkspace("acme", "u1", "south")).rejects.toThrow( + "WRN-TENANT-WORKSPACE-DENIED", + ); + directory.setQuota("acme", "storage", 100); + expect(() => directory.enforceQuota("acme", "storage", 90, 11)).toThrow("WRN-TENANT-QUOTA"); + expect(() => assertTenantAccess({ id: "acme" }, { tenantId: "other" })).toThrow( + "WRN-TENANT-CROSS-ACCESS", + ); + expect(tenantKey("acme", "cache", 1)).toBe("tenant:acme:cache:1"); + expect(events).toEqual(["membership.added", "workspace.switched"]); +}); + test("loaders, actions, and request-local dedupe remain framework-agnostic", async () => { let calls = 0; const loader = defineLoader({ load: async () => ({ ready: true }) }); diff --git a/packages/core/test/resilience.test.ts b/packages/core/test/resilience.test.ts new file mode 100644 index 00000000..8ef0fad8 --- /dev/null +++ b/packages/core/test/resilience.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from "bun:test"; +import { + Bulkhead, + CircuitBreaker, + ResilienceError, + durationMs, + resilientCall, +} from "../src/index.ts"; + +describe("resilience primitives", () => { + test("parses durations and validates bad configuration", () => { + expect(durationMs("1.5s")).toBe(1_500); + expect(durationMs("2m")).toBe(120_000); + expect(() => durationMs("soon" as never)).toThrow("Invalid duration"); + }); + + test("retries with exponential backoff and reports attempts", async () => { + const waits: number[] = []; + let calls = 0; + const value = await resilientCall({ + retries: 2, + retryDelay: 1, + backoff: "exponential", + onRetry: (_error, _attempt, wait) => waits.push(wait), + run: async (_signal, attempt) => { + calls += 1; + if (attempt < 3) throw new Error("temporary"); + return "ready"; + }, + }); + expect(value).toBe("ready"); + expect(calls).toBe(3); + expect(waits).toEqual([1, 2]); + }); + + test("times out cooperative operations and supports fallback", async () => { + const value = await resilientCall({ + timeout: "5ms", + fallback: (error) => (error as ResilienceError).code, + run: (signal) => + new Promise((_resolve, reject) => + signal.addEventListener("abort", () => reject(signal.reason)), + ), + }); + expect(value).toBe("WRN-RESILIENCE-TIMEOUT"); + }); + + test("times out integrations that ignore cancellation", async () => { + await expect( + resilientCall({ timeout: "2ms", run: () => new Promise(() => {}) }), + ).rejects.toMatchObject({ code: "WRN-RESILIENCE-TIMEOUT" }); + }); + + test("opens a circuit and exposes health", async () => { + const breaker = new CircuitBreaker({ failures: 2, resetAfter: "1h" }); + for (let index = 0; index < 2; index += 1) { + await expect( + breaker.execute(async () => { + throw new Error("down"); + }), + ).rejects.toThrow("down"); + } + expect(breaker.snapshot().state).toBe("open"); + await expect(breaker.execute(async () => "nope")).rejects.toMatchObject({ + code: "WRN-RESILIENCE-CIRCUIT-OPEN", + }); + }); + + test("retains circuit state for a reused declarative configuration", async () => { + const circuitBreaker = { failures: 1, resetAfter: "1h" } as const; + await expect( + resilientCall({ + circuitBreaker, + run: async () => { + throw new Error("down"); + }, + }), + ).rejects.toThrow("down"); + await expect( + resilientCall({ circuitBreaker, run: async () => "unreachable" }), + ).rejects.toMatchObject({ code: "WRN-RESILIENCE-CIRCUIT-OPEN" }); + }); + + test("bulkhead bounds concurrency and queue depth", async () => { + const bulkhead = new Bulkhead({ concurrency: 1, queue: 1 }); + let release!: () => void; + const first = bulkhead.execute( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + const second = bulkhead.execute(async () => "second"); + await expect(bulkhead.execute(async () => "third")).rejects.toMatchObject({ + code: "WRN-RESILIENCE-BULKHEAD-FULL", + }); + expect(bulkhead.snapshot).toEqual({ active: 1, queued: 1, capacity: 1 }); + release(); + await first; + expect(await second).toBe("second"); + }); +}); diff --git a/packages/core/test/tenant-persistence.test.ts b/packages/core/test/tenant-persistence.test.ts new file mode 100644 index 00000000..d66f8435 --- /dev/null +++ b/packages/core/test/tenant-persistence.test.ts @@ -0,0 +1,62 @@ +import { expect, test } from "bun:test"; +import { + createPersistentTenantDirectory, + memoryTenantDirectoryStore, + migrateTenants, + postgresTenantDirectoryStore, +} from "../src/index.ts"; + +test("persistent tenant directory stores memberships, workspace access and quota usage", async () => { + const events: string[] = []; + const directory = createPersistentTenantDirectory(memoryTenantDirectoryStore(), { + audit: (event) => { + events.push(event.action); + }, + }); + await directory.addMembership({ + tenantId: "acme", + userId: "u1", + roles: ["admin"], + workspaceIds: ["w1"], + }); + expect(await directory.membership("acme", "u1")).toMatchObject({ roles: ["admin"] }); + expect(await directory.switchWorkspace("acme", "u1", "w1")).toEqual({ + tenantId: "acme", + workspaceId: "w1", + }); + await directory.setQuota("acme", "projects", 2); + expect(await directory.consumeQuota("acme", "projects", 1)).toMatchObject({ usage: 1 }); + await expect(directory.consumeQuota("acme", "projects", 2)).rejects.toThrow("QUOTA"); + expect(events).toEqual(["membership.added", "workspace.switched"]); +}); + +test("tenant migration orchestrator bounds concurrency and reports isolated failures", async () => { + let active = 0, + peak = 0; + const result = await migrateTenants( + [{ id: "a" }, { id: "b" }, { id: "bad" }], + async (tenant) => { + active++; + peak = Math.max(peak, active); + await Promise.resolve(); + active--; + if (tenant.id === "bad") throw new Error("migration failed"); + }, + { concurrency: 2, continueOnError: true }, + ); + expect(result.migrated.sort()).toEqual(["a", "b"]); + expect(result.failed[0]?.tenantId).toBe("bad"); + expect(peak).toBeLessThanOrEqual(2); +}); + +test("PostgreSQL tenant store parameterizes identities", async () => { + const calls: unknown[][] = []; + const store = postgresTenantDirectoryStore({ + async query(_sql: string, params?: unknown[]) { + calls.push(params ?? []); + return { rows: [] as T[] }; + }, + }); + await store.putMembership({ tenantId: "tenant", userId: "user" }); + expect(calls[0]?.slice(0, 2)).toEqual(["tenant", "user"]); +}); diff --git a/packages/csr/README.md b/packages/csr/README.md index cb42e418..7a0288eb 100644 --- a/packages/csr/README.md +++ b/packages/csr/README.md @@ -1,5 +1,34 @@ # @wrnexus/csr +## Navigation state preservation + +Pages can opt into restoration across client navigation: + +```wrn +page Users { + navigation { + preserve = ["filters", "pagination", "scroll", "tabs", "expanded"] + } +} +``` + +Form-like categories restore named inputs, selects, and textareas. Password, +file, hidden, CSRF/token/secret/credential fields, and elements marked +`data-no-preserve` are never saved. For tab, expanded, or component UI state, +mark stable elements with `data-wrn-preserve="key"`; their value and ARIA +selected/expanded state are restored. State is scoped to pathname plus query. + +## Typed server actions + +`createActionClient(route, name)` supports programmatic calls. +Schema-backed WRN actions also export `__wrnexusActionClients`, whose input and +output are inferred automatically. Enhanced forms expose +`data-wrn-action-state="pending|success|error"` and dispatch bubbling +`wrnexus:action:optimistic`, `:pending`, `:success`, and `:error` events. +Success details contain returned data and invalidated cache tags; error details +contain field errors. Without JavaScript, the same form posts to its page and +receives a 303 redirect or accessible validation response. + > 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. @@ -49,16 +78,21 @@ getRealtimeRuntime(): string // → REALTIME_RUNTIME 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-="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. index and `key item.id`) | Per-item rendering; stable keys preserve DOM identity during reorder | -| `data-key="item.id"` | Alternative key declaration for `data-for` templates | -| `{{expr}}` or `{expr}` | Interpolation inside text nodes and attribute values | -| `data-wrnexus-csr="id"` | Target for a generated CSR fetch binding (fetches `/__wrnexus/csr?...`) | +| Directive | Purpose | +| ---------------------------------- | ---------------------------------------------------- | +| `data-scope="count: 0, name: 'x'"` | Declare reactive state on a subtree | +| `data-on-="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 while preserving interactive state | + +Compiled conditional rendering and dynamic component cases omit inactive elements from the live +DOM. `data-show` is a visibility directive for stateful controls and keeps its element mounted. +Neither mechanism is authorization: never place secrets in client-rendered branches. Authorize on +the server and return only data the current request may access. +| `data-for="item in list"` (opt. index and `key item.id`) | Per-item rendering; stable keys preserve DOM identity during reorder | +| `data-key="item.id"` | Alternative key declaration for `data-for` templates | +| `{{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. diff --git a/packages/csr/package.json b/packages/csr/package.json index bc09fb4a..a6f969d6 100644 --- a/packages/csr/package.json +++ b/packages/csr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/csr", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/csr/src/action-runtime.ts b/packages/csr/src/action-runtime.ts new file mode 100644 index 00000000..db386a08 --- /dev/null +++ b/packages/csr/src/action-runtime.ts @@ -0,0 +1,58 @@ +export const ACTION_RUNTIME = String.raw` +(function () { + if (window.__wrnexusActionsInstalled) return; + window.__wrnexusActionsInstalled = true; + + function csrf() { + var match = document.cookie.match(/(?:^|;\s*)wire-csrf=([^;]+)/); + return match ? decodeURIComponent(match[1]) : ""; + } + + function detail(form, name, extra) { + return Object.assign({ form: form, name: name }, extra || {}); + } + + function emit(form, phase, name, extra, cancelable) { + return form.dispatchEvent(new CustomEvent("wrnexus:action:" + phase, { + bubbles: true, + cancelable: !!cancelable, + detail: detail(form, name, extra), + })); + } + + document.addEventListener("submit", function (event) { + var form = event.target && event.target.closest && event.target.closest("form[data-wrn-action]"); + if (!form || event.defaultPrevented) return; + var name = form.getAttribute("data-wrn-action"); + if (!name) return; + event.preventDefault(); + var data = new FormData(form); + data.set("_wrnexus_action", name); + data.set("_csrf", csrf()); + emit(form, "optimistic", name, { input: data }, true); + form.setAttribute("aria-busy", "true"); + form.setAttribute("data-wrn-action-state", "pending"); + emit(form, "pending", name, { input: data }); + fetch(form.action || location.href, { + method: "POST", + body: data, + credentials: "same-origin", + headers: { accept: "application/json", "x-wrnexus-action": name, "x-csrf-token": csrf() }, + }).then(async function (response) { + var payload; + try { payload = await response.json(); } catch (_) { payload = { error: await response.text() }; } + if (!response.ok) throw Object.assign(new Error(payload.error || "Action failed"), { response: response, payload: payload }); + form.setAttribute("data-wrn-action-state", "success"); + emit(form, "success", name, { data: payload.data, invalidated: payload.invalidated || [] }); + if (payload.invalidated && payload.invalidated.length) { + window.dispatchEvent(new CustomEvent("wrnexus:cache:invalidate", { detail: { tags: payload.invalidated } })); + } + }).catch(function (error) { + form.setAttribute("data-wrn-action-state", "error"); + emit(form, "error", name, { error: error, errors: error.payload && error.payload.errors }); + }).finally(function () { + form.removeAttribute("aria-busy"); + }); + }); +})(); +`; diff --git a/packages/csr/src/actions.ts b/packages/csr/src/actions.ts new file mode 100644 index 00000000..7e2aa9bf --- /dev/null +++ b/packages/csr/src/actions.ts @@ -0,0 +1,46 @@ +export interface ActionClientOptions { + signal?: AbortSignal; + csrfToken?: string; + headers?: HeadersInit; + serialize?: (input: I) => BodyInit; +} + +export interface ActionResult { + data: O; + invalidated: string[]; +} + +export class ActionClientError extends Error { + constructor( + public readonly status: number, + public readonly errors?: Record, + ) { + super(`Server action failed with status ${status}.`); + this.name = "ActionClientError"; + } +} + +export function createActionClient(route: string, name: string) { + return async (input: I, options: ActionClientOptions = {}): Promise> => { + const headers = new Headers(options.headers); + headers.set("accept", "application/json"); + headers.set("x-wrnexus-action", name); + if (options.csrfToken) headers.set("x-csrf-token", options.csrfToken); + const body = options.serialize ? options.serialize(input) : JSON.stringify(input); + if (!options.serialize) headers.set("content-type", "application/json"); + const response = await fetch(route, { + method: "POST", + credentials: "same-origin", + signal: options.signal, + headers, + body, + }); + const payload = (await response.json()) as { + data?: O; + invalidated?: string[]; + errors?: Record; + }; + if (!response.ok) throw new ActionClientError(response.status, payload.errors); + return { data: payload.data as O, invalidated: payload.invalidated ?? [] }; + }; +} diff --git a/packages/csr/src/index.ts b/packages/csr/src/index.ts index a9e76a65..f6d7ad13 100644 --- a/packages/csr/src/index.ts +++ b/packages/csr/src/index.ts @@ -10,10 +10,12 @@ import { REACTIVE_RUNTIME } from "./reactive-runtime.ts"; import { NAV_RUNTIME } from "./nav-runtime.ts"; import { REALTIME_RUNTIME } from "./realtime-runtime.ts"; +import { ACTION_RUNTIME } from "./action-runtime.ts"; export { REACTIVE_RUNTIME } from "./reactive-runtime.ts"; export { NAV_RUNTIME } from "./nav-runtime.ts"; export { REALTIME_RUNTIME } from "./realtime-runtime.ts"; +export { ACTION_RUNTIME } from "./action-runtime.ts"; /** The reactive runtime served at `/__wrnexus/reactive.js` (plain browser JS). */ export function getReactiveRuntime(): string { @@ -30,8 +32,13 @@ export function getRealtimeRuntime(): string { return REALTIME_RUNTIME; } +export function getActionRuntime(): string { + return ACTION_RUNTIME; +} + export * from "./outputs.ts"; export * from "./server-client.ts"; export * from "./refs.ts"; export * from "./client-functions.ts"; +export * from "./actions.ts"; export type * from "./types.ts"; diff --git a/packages/csr/src/nav-runtime.ts b/packages/csr/src/nav-runtime.ts index a7c6bf49..2840b76a 100644 --- a/packages/csr/src/nav-runtime.ts +++ b/packages/csr/src/nav-runtime.ts @@ -34,6 +34,138 @@ export const NAV_RUNTIME = String.raw` var APP_ID = "app"; var inFlight = null; + var memory = Object.create(null); + var keepAlive = Object.create(null); + var keepAliveOrder = []; + + function keepAliveKey(node) { + return String(node && node.getAttribute("data-wrn-keepalive") || ""); + } + + function retainKeepAlive(root) { + Array.prototype.forEach.call(root.querySelectorAll("[data-wrn-keepalive]"), function (node) { + var key = keepAliveKey(node); + if (!key) return; + if (!keepAlive[key]) keepAliveOrder.push(key); + keepAlive[key] = node; + if (node.parentNode) node.parentNode.removeChild(node); + }); + while (keepAliveOrder.length > 32) { + var expired = keepAliveOrder.shift(); + if (expired && keepAlive[expired]) { + dispose(keepAlive[expired]); + delete keepAlive[expired]; + } + } + } + + function restoreKeepAlive(root) { + var placeholders = []; + if (root.matches && root.matches("[data-wrn-keepalive]")) placeholders.push(root); + Array.prototype.forEach.call(root.querySelectorAll("[data-wrn-keepalive]"), function (node) { placeholders.push(node); }); + placeholders.forEach(function (placeholder) { + var saved = keepAlive[keepAliveKey(placeholder)]; + if (saved && placeholder.parentNode) placeholder.parentNode.replaceChild(saved, placeholder); + }); + } + + function preservationPolicy(doc) { + var meta = doc.querySelector('meta[name="wrnexus-preserve"]'); + var out = Object.create(null); + String(meta && meta.getAttribute("content") || "").split(",").forEach(function (name) { + if (name) out[name] = true; + }); + return out; + } + + function syncPreservationPolicy(nextDocument) { + var selector = 'meta[name="wrnexus-preserve"]'; + var current = document.querySelector(selector); + var next = nextDocument.querySelector(selector); + if (!next) { + if (current) current.remove(); + return; + } + if (!current) { + current = document.createElement("meta"); + current.setAttribute("name", "wrnexus-preserve"); + document.head.appendChild(current); + } + current.setAttribute("content", next.getAttribute("content") || ""); + } + + function stateKey(url) { + try { + var parsed = new URL(url, location.href); + return "wrnexus:navigation:" + parsed.pathname + parsed.search; + } catch (_) { + return "wrnexus:navigation:" + String(url); + } + } + + function safeField(field) { + var type = String(field.type || "").toLowerCase(); + var name = String(field.name || field.id || "").toLowerCase(); + return type !== "password" && type !== "file" && type !== "hidden" && + !field.hasAttribute("data-no-preserve") && + !/(?:csrf|token|secret|password|credential)/.test(name); + } + + function capturePage(url) { + var policy = preservationPolicy(document); + var state = { fields: Object.create(null), elements: Object.create(null) }; + if (policy.scroll) state.scroll = [window.scrollX || 0, window.scrollY || 0]; + if (policy.forms || policy.filters || policy.pagination || policy.workflow) { + Array.prototype.forEach.call(document.querySelectorAll("#app input,#app select,#app textarea"), function (field, index) { + if (!safeField(field)) return; + var key = field.name || field.id || String(index); + state.fields[key] = { value: field.value, checked: !!field.checked, selectedIndex: field.selectedIndex }; + }); + } + if (policy.tabs || policy.expanded || policy.component) { + Array.prototype.forEach.call(document.querySelectorAll("#app [data-wrn-preserve]"), function (node, index) { + var key = node.getAttribute("data-wrn-preserve") || node.id || String(index); + state.elements[key] = { + selected: node.getAttribute("aria-selected"), + expanded: node.getAttribute("aria-expanded"), + value: "value" in node ? node.value : null, + }; + }); + } + memory[stateKey(url)] = state; + try { sessionStorage.setItem(stateKey(url), JSON.stringify(state)); } catch (_) {} + } + + function restorePage(url, isPop) { + var policy = preservationPolicy(document); + var state = memory[stateKey(url)]; + if (!state) { + try { state = JSON.parse(sessionStorage.getItem(stateKey(url)) || "null"); } catch (_) {} + } + if (state && (policy.forms || policy.filters || policy.pagination || policy.workflow)) { + Array.prototype.forEach.call(document.querySelectorAll("#app input,#app select,#app textarea"), function (field, index) { + if (!safeField(field)) return; + var saved = state.fields && state.fields[field.name || field.id || String(index)]; + if (!saved) return; + if (field.type === "checkbox" || field.type === "radio") field.checked = !!saved.checked; + else field.value = saved.value; + if (field.tagName === "SELECT") field.selectedIndex = saved.selectedIndex; + field.dispatchEvent(new Event("input", { bubbles: true })); + }); + } + if (state && (policy.tabs || policy.expanded || policy.component)) { + Array.prototype.forEach.call(document.querySelectorAll("#app [data-wrn-preserve]"), function (node, index) { + var key = node.getAttribute("data-wrn-preserve") || node.id || String(index); + var saved = state.elements && state.elements[key]; + if (!saved) return; + if (saved.selected != null) node.setAttribute("aria-selected", saved.selected); + if (saved.expanded != null) node.setAttribute("aria-expanded", saved.expanded); + if (saved.value != null && "value" in node) node.value = saved.value; + }); + } + if (state && policy.scroll && state.scroll) window.scrollTo(state.scroll[0], state.scroll[1]); + else if (!isPop) window.scrollTo(0, 0); + } function pathOf(src) { return String(src).split("?")[0]; @@ -365,6 +497,8 @@ export const NAV_RUNTIME = String.raw` return; } + capturePage(location.href); + var incomingApp = doc.getElementById(APP_ID); @@ -380,6 +514,8 @@ export const NAV_RUNTIME = String.raw` document.title = doc.title; } + syncPreservationPolicy(doc); + syncWrnStyles(doc); var importedNodes = []; @@ -402,6 +538,7 @@ export const NAV_RUNTIME = String.raw` * connected. This is important for components that remove window or * document listeners during lifecycle.unmount. */ + retainKeepAlive(currentApp); dispose(currentApp); try { @@ -413,6 +550,7 @@ export const NAV_RUNTIME = String.raw` currentApp, importedNodes, ); + restoreKeepAlive(currentApp); } catch (_) { hardNavigate(url); return; @@ -439,9 +577,10 @@ export const NAV_RUNTIME = String.raw` url, ); - window.scrollTo(0, 0); } + restorePage(url, isPop); + dispatchNavigationEvent(url); } @@ -449,50 +588,65 @@ export const NAV_RUNTIME = String.raw` var token = {}; inFlight = token; + window.dispatchEvent(new CustomEvent("wrnexus:navigation-start", { detail: { url: url } })); - fetch(url, { + var pending = prefetched.get(url) || requestDocument(url, false); + prefetched.delete(url); + pending + .then(function (result) { + if (inFlight !== token) return null; + if (result.redirectedUrl) url = result.redirectedUrl; + if (result.contentType.indexOf("text/html") === -1) { + hardNavigate(url); + return null; + } + render(result.text, url, isPop); + return null; + }) + .catch(function () { + if (inFlight === token) hardNavigate(url); + }); + } + + var prefetched = new Map(); + function requestDocument(url, isPrefetch) { + return fetch(url, { headers: { "x-wrnexus-nav": "1", + ...(isPrefetch ? { "x-wrnexus-prefetch": "1" } : {}), accept: "text/html", }, credentials: "same-origin", - }) - .then(function (response) { - if (inFlight !== token) { - return null; - } - - if (response.redirected && response.url) { - url = response.url; - } - - var contentType = - response.headers.get("content-type") || - ""; - - if ( - contentType.indexOf("text/html") === -1 - ) { - hardNavigate(url); - return null; - } - - return response.text().then(function (text) { - if (inFlight !== token) { - return; - } - - render(text, url, isPop); - }); - }) - .catch(function () { - if (inFlight === token) { - hardNavigate(url); - } + }).then(function (response) { + var contentType = response.headers.get("content-type") || ""; + return response.text().then(function (text) { + return { + text: text, + contentType: contentType, + redirectedUrl: response.redirected && response.url ? response.url : "", + }; }); + }); } + function prefetch(anchor) { + if (!isLocalLink(anchor) || anchor.hasAttribute("data-no-prefetch")) return; + var url = anchor.href; + if (url === location.href || prefetched.has(url)) return; + prefetched.set(url, requestDocument(url, true)); + while (prefetched.size > 20) prefetched.delete(prefetched.keys().next().value); + } + + document.addEventListener("pointerover", function (event) { + var anchor = event.target && event.target.closest ? event.target.closest("a") : null; + prefetch(anchor); + }, { passive: true }); + document.addEventListener("focusin", function (event) { + var anchor = event.target && event.target.closest ? event.target.closest("a") : null; + prefetch(anchor); + }); + document.addEventListener( "click", function (event) { diff --git a/packages/csr/src/reactive-runtime.ts b/packages/csr/src/reactive-runtime.ts index d2409a8f..48f76f73 100644 --- a/packages/csr/src/reactive-runtime.ts +++ b/packages/csr/src/reactive-runtime.ts @@ -199,6 +199,17 @@ export const REACTIVE_RUNTIME = String.raw` ? input[nextIndex] : ""; + var currentLine = input + .slice(start, previousIndex + 1) + .trim(); + + // Postfix updates are complete statements. Treating their trailing + or + // - as a multiline operator merges the following assignment into the + // same statement (for example count++ followed by message = ...). + if (/\+\+$|--$/.test(currentLine)) { + return false; + } + // Newlines are formatting whitespace while either side is an // incomplete expression. This covers multiline assignments and // ternaries emitted by formatted .wrn component functions. @@ -1650,7 +1661,10 @@ export const REACTIVE_RUNTIME = String.raw` }); }); - // data-show="expr" — toggle visibility on truthiness. + // data-show="expr" — toggle visibility on truthiness. This directive is + // intentionally non-destructive because popovers, selects and remote data + // controls keep event wiring and state while closed. Use a compiled {#if} + // block when the inactive branch must not be rendered. el.querySelectorAll("[data-show]").forEach(function (node) { if (!owns(node)) return; @@ -3651,6 +3665,56 @@ export const REACTIVE_RUNTIME = String.raw` return special.value; } + // Template literals are evaluated without eval so they remain compatible + // with a strict CSP. Each interpolation uses the same bounded expression + // evaluator as every other reactive binding. + if ( + expr.length >= 2 && + expr[0] === "\`" && + expr[expr.length - 1] === "\`" + ) { + var template = expr.slice(1, -1); + var rendered = ""; + var cursor = 0; + + while (cursor < template.length) { + if (template[cursor] === "\\") { + cursor++; + var escaped = template[cursor++]; + rendered += escaped === "n" ? "\n" : escaped === "t" ? "\t" : escaped || ""; + continue; + } + + if (template[cursor] === "$" && template[cursor + 1] === "{") { + var expressionStart = cursor + 2; + var expressionEnd = expressionStart; + var depth = 1; + var quote = ""; + + for (; expressionEnd < template.length; expressionEnd++) { + var character = template[expressionEnd]; + if (quote) { + if (character === "\\") expressionEnd++; + else if (character === quote) quote = ""; + continue; + } + if (character === '"' || character === "'") quote = character; + else if (character === "{") depth++; + else if (character === "}" && --depth === 0) break; + } + + if (depth !== 0) throw new Error("Unclosed template interpolation in '" + expr + "'"); + rendered += String(evaluateExpression(template.slice(expressionStart, expressionEnd), read)); + cursor = expressionEnd + 1; + continue; + } + + rendered += template[cursor++]; + } + + return rendered; + } + var tokens = tokenizeExpression(expr); @@ -3959,18 +4023,169 @@ export const REACTIVE_RUNTIME = String.raw` } } + var asyncLoads = new Map(); + function asyncValueAt(value, path) { + var current = value; + var parts = String(path || "").split(".").filter(Boolean); + for (var i = 0; i < parts.length; i++) { + if (current == null || typeof current !== "object") return ""; + current = current[parts[i]]; + } + return current == null ? "" : current; + } + function interpolateAsync(root, model, prefix) { + function visit(node) { + if (node.nodeType === 3) { + node.textContent = String(node.textContent || "").replace(/\{\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\}/g, function (whole, path) { + var parts = path.split("."); + if (parts[0] === prefix) parts.shift(); + if (prefix === "error" && parts[0] !== "message") return whole; + var value = asyncValueAt(model, parts.join(".")); + return typeof value === "object" ? JSON.stringify(value) : String(value); + }); + } + Array.from(node.childNodes || []).forEach(visit); + } + visit(root); + root.querySelectorAll("[data-wrn-async-value]").forEach(function (node) { + var path = node.getAttribute("data-wrn-async-value") || ""; + var value = asyncValueAt(model, path); + node.textContent = typeof value === "object" ? JSON.stringify(value, null, 2) : String(value); + }); + } + function showAsync(boundary, state, model) { + var template = boundary.querySelector("template[data-wrn-async-" + state + "]"); + var content = boundary.querySelector("[data-wrn-async-content]"); + if (!template || !content) return; + var fragment = template.content.cloneNode(true); + interpolateAsync(fragment, model, state === "error" ? "error" : boundary.getAttribute("data-wrn-async")); + content.replaceChildren(fragment); + boundary.setAttribute("aria-busy", state === "loading" ? "true" : "false"); + boundary.setAttribute("data-wrn-async-state", state); + } + function setupAsyncBoundary(boundary) { + if (boundary.__wrnexusAsync) return; + boundary.__wrnexusAsync = true; + if (boundary.getAttribute("data-wrn-async-resolved") === "true") { + boundary.setAttribute("data-wrn-async-state", "success"); + boundary.setAttribute("aria-busy", "false"); + return; + } + var name = boundary.getAttribute("data-wrn-async") || ""; + if (!/^[A-Za-z_$][\w$]{0,63}$/.test(name)) return showAsync(boundary, "error", { message: "Invalid data source" }); + var url = "/__wrnexus/client-load?route=" + encodeURIComponent(location.pathname) + "&name=" + encodeURIComponent(name); + var attempts = Math.min(5, Math.max(0, Number(boundary.getAttribute("data-wrn-async-retries")) || 0)); + var controller = new AbortController(); + boundary.__wrnexusAsyncAbort = controller; + window.addEventListener("wrnexus:navigation-start", function () { controller.abort(); }, { once: true }); + function request(remaining) { + var pending = asyncLoads.get(url); + if (!pending) { + pending = fetch(url, { headers: { accept: "application/json" }, signal: controller.signal }).then(function (response) { + if (!response.ok) throw new Error("Client load returned " + response.status); + return response.json(); + }).finally(function () { asyncLoads.delete(url); }); + asyncLoads.set(url, pending); + } + pending.then(function (result) { + showAsync(boundary, "success", result.data); + }).catch(function (error) { + if (controller.signal.aborted) return; + if (remaining > 0) return setTimeout(function () { request(remaining - 1); }, 100 * (attempts - remaining + 1)); + showAsync(boundary, "error", { message: error && error.message ? error.message : "Loading failed" }); + }); + } + showAsync(boundary, "loading", {}); + request(attempts); + } + function hydrateAsyncBoundaries(root) { + (root || document).querySelectorAll("[data-wrn-async]").forEach(setupAsyncBoundary); + } + + function mountClientRoots(root) { + (root || document).querySelectorAll("template[data-wrn-client-template]").forEach(function (template) { + var id = template.getAttribute("data-wrn-client-template"); + var mount = null; + (root || document).querySelectorAll("[data-wrn-client-root]").forEach(function (candidate) { + if (!mount && candidate.getAttribute("data-wrn-client-root") === id) mount = candidate; + }); + if (!mount || !template.content) return; + var fragment = template.content.cloneNode(true); + mount.replaceWith(fragment); + template.remove(); + window.dispatchEvent(new window.CustomEvent("wrnexus:client-mounted", { detail: { id: id } })); + }); + } + + function hydrateDeclarativeUi(root) { + var host = root || document; + host.querySelectorAll("[data-wrn-portal]").forEach(function (portal) { + if (portal.__wrnPortalMounted) return; + var selector = portal.getAttribute("data-wrn-portal") || "body"; + var target = null; try { target = document.querySelector(selector); } catch (_) {} + if (!target || target === portal || portal.contains(target)) return; + portal.__wrnPortalMounted = true; + var marker = document.createComment("wrnexus-portal"); + portal.parentNode && portal.parentNode.insertBefore(marker, portal); + target.appendChild(portal); + }); + host.querySelectorAll("[data-wrn-transition]").forEach(function (element) { + if (element.__wrnTransitionMounted) return; + element.__wrnTransitionMounted = true; + var name = element.getAttribute("data-wrn-transition") || "wrn-transition"; + element.classList.add(name + "-enter", name + "-enter-active"); + requestAnimationFrame(function () { element.classList.remove(name + "-enter"); element.classList.add(name + "-enter-to"); }); + element.addEventListener("transitionend", function () { element.classList.remove(name + "-enter-active", name + "-enter-to"); }, { once: true }); + }); + host.querySelectorAll("[data-wrn-dynamic-component]").forEach(function (element) { + if (element.__wrnDynamicMounted) return; + element.__wrnDynamicMounted = true; + var cases = Array.prototype.slice.call(element.children).filter(function (candidate) { + return candidate.hasAttribute("data-component-case"); + }).map(function (candidate) { + var marker = document.createComment("wrnexus-component-case:" + (candidate.getAttribute("data-component-case") || "")); + element.insertBefore(marker, candidate); + return { candidate: candidate, marker: marker }; + }); + var update = function () { + var selected = element.getAttribute("data-wrn-dynamic-component") || ""; + cases.forEach(function (record) { + var candidate = record.candidate; + var active = candidate.getAttribute("data-component-case") === selected; + candidate.hidden = false; + if (active && !candidate.isConnected && record.marker.parentNode) { + record.marker.parentNode.insertBefore(candidate, record.marker.nextSibling); + } else if (!active && candidate.parentNode) { + candidate.parentNode.removeChild(candidate); + } + }); + window.dispatchEvent(new window.CustomEvent("wrnexus:dynamic-component", { detail: { component: selected } })); + }; + update(); + new MutationObserver(update).observe(element, { attributes: true, attributeFilter: ["data-wrn-dynamic-component"] }); + }); + } + + window.__wrnexusMountClientRoots = mountClientRoots; + window.__wrnexusHydrateAsyncBoundaries = hydrateAsyncBoundaries; window.__wrnexusHydrateScopes = hydrateScopes; window.__wrnexusInvalidateClientModule = function (url) { clientModuleCache.delete(url); }; window.__wrnexusHydrateCsrFetches = hydrateCsrFetches; window.__wrnexusDisposeBehaviors = disposeBehaviors; if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", function () { + mountClientRoots(document); hydrateScopes(document); hydrateCsrFetches(document); + hydrateAsyncBoundaries(document); + hydrateDeclarativeUi(document); }); } else { + mountClientRoots(document); hydrateScopes(document); hydrateCsrFetches(document); + hydrateAsyncBoundaries(document); + hydrateDeclarativeUi(document); } })(); `.trim(); diff --git a/packages/csr/test/actions.test.ts b/packages/csr/test/actions.test.ts new file mode 100644 index 00000000..03ca0da4 --- /dev/null +++ b/packages/csr/test/actions.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { ACTION_RUNTIME } from "../src/action-runtime.ts"; +import { createActionClient } from "../src/actions.ts"; + +const originalGlobals = new Map( + ["window", "document", "location", "CustomEvent", "FormData", "fetch"].map((key) => [ + key, + (globalThis as Record)[key], + ]), +); + +beforeEach(() => { + for (const key of ["window", "document", "location", "CustomEvent", "FormData", "fetch"]) { + delete (globalThis as Record)[key]; + } +}); +afterEach(() => { + for (const [key, value] of originalGlobals) { + if (value === undefined) delete (globalThis as Record)[key]; + else (globalThis as Record)[key] = value; + } +}); + +test("action runtime exposes pending, optimistic, success, and invalidation state", async () => { + const win = new Window({ url: "https://example.test/users" }); + win.document.cookie = "wire-csrf=token"; + win.document.body.innerHTML = `
`; + const phases: string[] = []; + ["optimistic", "pending", "success"].forEach((phase) => + win.document.addEventListener(`wrnexus:action:${phase}`, () => phases.push(phase)), + ); + let invalidated: unknown; + win.addEventListener("wrnexus:cache:invalidate", (event) => { + invalidated = (event as unknown as CustomEvent).detail.tags; + }); + const g = globalThis as Record; + Object.assign(g, { + window: win, + document: win.document, + location: win.location, + CustomEvent: win.CustomEvent, + FormData: win.FormData, + fetch: async () => Response.json({ data: { id: 1 }, invalidated: ["users"] }), + }); + (0, eval)(ACTION_RUNTIME); + win.document + .querySelector("form")! + .dispatchEvent(new win.Event("submit", { bubbles: true, cancelable: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(phases).toEqual(["optimistic", "pending", "success"]); + expect(invalidated).toEqual(["users"]); + expect(win.document.querySelector("form")?.getAttribute("data-wrn-action-state")).toBe("success"); +}); + +test("typed action client returns invalidations and structured validation errors", async () => { + const original = globalThis.fetch; + try { + globalThis.fetch = (async () => + Response.json({ data: { id: 1 }, invalidated: ["users"] })) as unknown as typeof fetch; + const call = createActionClient<{ name: string }, { id: number }>("/users", "createUser"); + expect(await call({ name: "Ada" }, { csrfToken: "token" })).toEqual({ + data: { id: 1 }, + invalidated: ["users"], + }); + globalThis.fetch = (async () => + Response.json({ errors: { name: "Required" } }, { status: 422 })) as unknown as typeof fetch; + await expect(call({ name: "" })).rejects.toMatchObject({ + status: 422, + errors: { name: "Required" }, + }); + } finally { + globalThis.fetch = original; + } +}); diff --git a/packages/csr/test/nav.test.ts b/packages/csr/test/nav.test.ts index bf979ded..a074efec 100644 --- a/packages/csr/test/nav.test.ts +++ b/packages/csr/test/nav.test.ts @@ -18,6 +18,7 @@ function install(bodyHtml: string): void { g.location = win.location; g.DOMParser = win.DOMParser; g.CustomEvent = win.CustomEvent; + g.Event = win.Event; g.fetch = win.fetch = (url: string, opts: any) => { fetchCalls.push({ url, opts }); return Promise.resolve({ @@ -44,6 +45,7 @@ beforeEach(() => { "location", "DOMParser", "CustomEvent", + "Event", "fetch", ]) { delete g[k]; @@ -64,6 +66,22 @@ test("intercepts an internal link click and swaps #app in place", async () => { expect(win.document.title).toBe("About"); }); +test("prefetches focused routes once and reuses the document during navigation", async () => { + install(``); + nextHtml = + `About` + + `

Prefetched

`; + const link = win.document.getElementById("prefetch"); + link.dispatchEvent(new win.FocusEvent("focusin", { bubbles: true })); + await flush(); + expect(fetchCalls).toHaveLength(1); + expect(fetchCalls[0]?.opts.headers["x-wrnexus-prefetch"]).toBe("1"); + link.click(); + await flush(); + expect(fetchCalls).toHaveLength(1); + expect(win.document.querySelector("h1")?.textContent).toBe("Prefetched"); +}); + test("ignores cross-origin links (full navigation)", async () => { install(``); win.document.getElementById("lnk").click(); @@ -151,3 +169,37 @@ test("synchronizes page styles during client navigation and preserves the curren expect(style.textContent).toContain("color:blue"); expect(style.getAttribute("nonce")).toBe("current-nonce"); }); + +test("restores declared form state but never preserves sensitive fields", async () => { + install( + `` + + `
`, + ); + nextHtml = `
Next
`; + win.__wrnexusNavigate("/next"); + await flush(); + + nextHtml = + `
` + + `
`; + win.__wrnexusNavigate("/"); + await flush(); + + expect(win.document.querySelector('[name="query"]').value).toBe("draft"); + expect(win.document.querySelector('[name="password"]').value).toBe(""); +}); + +test("KeepAlive preserves the same live DOM instance across routes", async () => { + install( + `
`, + ); + const original = win.document.querySelector("[data-wrn-keepalive]"); + original.runtimeState = { count: 7 }; + nextHtml = `
`; + win.__wrnexusNavigate("/next"); + await flush(); + const restored = win.document.querySelector("[data-wrn-keepalive]"); + expect(restored).toBe(original); + expect(restored.runtimeState).toEqual({ count: 7 }); + expect(restored.querySelector("input").value).toBe("live"); +}); diff --git a/packages/csr/test/reactive.test.ts b/packages/csr/test/reactive.test.ts index 0f6079bc..79120a13 100644 --- a/packages/csr/test/reactive.test.ts +++ b/packages/csr/test/reactive.test.ts @@ -9,9 +9,13 @@ function mount(html: string): Window { win.document.body.innerHTML = `
${html}
`; (globalThis as Record).window = win; (globalThis as Record).document = win.document; + (globalThis as Record).location = win.location; (globalThis as Record).NodeFilter = ( win as unknown as { NodeFilter: unknown } ).NodeFilter; + (globalThis as Record).MutationObserver = ( + win as unknown as { MutationObserver: unknown } + ).MutationObserver; (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. @@ -23,6 +27,9 @@ function mount(html: string): Window { beforeEach(() => { delete (globalThis as Record).window; delete (globalThis as Record).document; + delete (globalThis as Record).location; + delete (globalThis as Record).fetch; + delete (globalThis as Record).MutationObserver; }); test("hydrates {expr} mustaches from data-scope", () => { @@ -30,6 +37,40 @@ test("hydrates {expr} mustaches from data-scope", () => { expect(win.document.querySelector("span")!.textContent).toBe("0, 0"); }); +test("mounts client-only templates before hydrating their scopes", () => { + const win = mount( + `
` + + ``, + ); + const runtime = win as unknown as { + __wrnexusMountClientRoots?: (root: unknown) => void; + __wrnexusHydrateScopes?: (root: unknown) => void; + }; + runtime.__wrnexusMountClientRoots?.(win.document); + runtime.__wrnexusHydrateScopes?.(win.document); + expect(win.document.querySelector("template")).toBeNull(); + expect(win.document.querySelector("main b")?.textContent).toBe("2"); +}); + +test("orchestrates named client loads and renders the success template", async () => { + (globalThis as Record).fetch = () => + Promise.resolve(Response.json({ data: { name: "Ada" } })); + const win = mount( + `
` + + `
Loading
` + + `` + + `` + + `
`, + ); + const runtime = win as unknown as { __wrnexusHydrateAsyncBoundaries?: (root: unknown) => void }; + runtime.__wrnexusHydrateAsyncBoundaries?.(win.document); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(win.document.querySelector("section")?.getAttribute("data-wrn-async-state")).toBe( + "success", + ); + expect(win.document.querySelector("strong")?.textContent).toBe("Ada"); +}); + test("@event (data-on-click) mutates a signal and re-renders", () => { const win = mount( `
`, @@ -83,6 +124,26 @@ test("component functions support formatted multiline assignments and ternaries" expect(increment.textContent).toBe("0"); }); +test("component functions evaluate template literals without unsafe eval", () => { + const behavior = Buffer.from( + JSON.stringify({ + functions: `function increment() { + count++ + message = \`Count is now \${count}.\` + }`, + watches: [], + lifecycle: {}, + }), + ).toString("base64"); + const win = mount( + `
` + + `
`, + ); + const button = win.document.querySelector("button")!; + button.click(); + expect(button.textContent).toBe("Count is now 1."); +}); + test("declared component events emit through the generic $emit function", () => { const win = mount( `
@@ -220,7 +281,7 @@ test("expression evaluator supports flatMap callbacks", () => { expect(win.document.querySelector("span")!.textContent).toBe("1,2,3"); }); -test("data-show toggles visibility on a reactive expression (tabs pattern)", () => { +test("data-show toggles visibility without destroying interactive state", () => { const win = mount( `
@@ -228,17 +289,37 @@ test("data-show toggles visibility on a reactive expression (tabs pattern)", ()
B
`, ); - const disp = (id: string) => - (win.document.getElementById(id) as unknown as HTMLElement).style.display; - expect(disp("a")).toBe(""); - expect(disp("b")).toBe("none"); - expect(win.document.getElementById("a")!.getAttribute("data-show")).toBe("true"); - expect(win.document.getElementById("b")!.getAttribute("data-show")).toBe("false"); + const a = win.document.getElementById("a") as unknown as HTMLElement; + const b = win.document.getElementById("b") as unknown as HTMLElement; + expect(a.style.display).toBe(""); + expect(b.style.display).toBe("none"); (win.document.querySelector("button") as unknown as HTMLElement).click(); - expect(disp("a")).toBe("none"); - expect(disp("b")).toBe(""); - expect(win.document.getElementById("a")!.getAttribute("data-show")).toBe("false"); - expect(win.document.getElementById("b")!.getAttribute("data-show")).toBe("true"); + expect(a.style.display).toBe("none"); + expect(b.style.display).toBe(""); +}); + +test("dynamic components remove inactive cases from the live DOM", async () => { + const win = mount( + `
+ +
+
Administrator secret
+
Guest dashboard
+
+
`, + ); + win.document.dispatchEvent(new win.Event("DOMContentLoaded")); + expect(win.document.querySelector('[data-component-case="Admin"]')?.textContent).toContain( + "Administrator", + ); + expect(win.document.querySelector('[data-component-case="Guest"]')).toBeNull(); + + (win.document.querySelector("button") as unknown as HTMLElement).click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(win.document.querySelector('[data-component-case="Admin"]')).toBeNull(); + expect(win.document.querySelector('[data-component-case="Guest"]')?.textContent).toContain( + "Guest", + ); }); test("reactive data attributes preserve explicit boolean strings", () => { diff --git a/packages/db/README.md b/packages/db/README.md index 0d4495ff..ee7f906b 100644 --- a/packages/db/README.md +++ b/packages/db/README.md @@ -1,5 +1,14 @@ # @wrnexus/db +## Rollout-safe migrations + +Run `wrnexus db check` in CI before deployment. The analyzer reports stable +diagnostics for drops, renames, type changes, new/enforced required columns, +and potentially blocking index creation, with an expand/backfill/switch/contract +recommendation. `wrnexus db migrate` blocks critical issues in pending +migrations. `--allow-breaking` is an explicit operator override; already-applied +migrations do not block later releases. + > 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. @@ -85,7 +94,8 @@ A `Driver` (implemented by adapters) exposes `dialect`, `query(sql, params?)`, - `exec(sql, params?)` — `Promise` (`{ 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()`. +- `close()` — idempotently rejects new top-level work, drains active queries and + transactions, then closes the underlying pool. Every query is parameterized (positional params). `createTableSql(model, dialect, ifNotExists?)` renders `CREATE TABLE` directly; `Dialect` is `"sqlite" | "postgres" | "mysql"`. @@ -98,7 +108,9 @@ A process-wide registry the runtime configures at startup from `wrnexus.config.t - `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()`. +- `hasDb(name?)`, `databaseNames()`, `closeDatabases()`. Registry shutdown clears + registrations first, attempts every open database, and reports close failures + together with `AggregateError` instead of leaking later pools. ```ts const users = await getDb().all("SELECT * FROM users"); @@ -121,8 +133,8 @@ Migrations are `.sql` files (in e.g. `app/db/migrations`), each split into - `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`. +- `migrate(db, dir, options?)` — apply all pending (each in a transaction); returns applied names. +- `rollback(db, dir, options?)` — 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. @@ -199,6 +211,21 @@ const pageTwo = await paginate( ); ``` +For deployments, `{ dryRun: true }` reports pending names without applying +their SQL, `signal` cancels safely between migrations, and the default +database-backed lock prevents concurrent deploy runners. A live lock produces +`WRN-DB-MIGRATION-LOCKED`; crash-stale locks expire after `lockTimeoutMs` (five +minutes by default). Disable it with `lock: false` only when an external deploy +coordinator already guarantees exclusivity. + +```ts +const pending = await migrate(db, "app/db/migrations", { dryRun: true }); +await migrate(db, "app/db/migrations", { + signal: shutdownController.signal, + lockTimeoutMs: 10 * 60_000, +}); +``` + MongoDB (document API): ```ts @@ -226,3 +253,47 @@ SQL driver — use `@wrnexus/db/mongo` directly. `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. + +## Repository and transaction helpers + +Repositories accept an immutable equality `scope`, normally `{ column: "tenant_id", value: +ctx.tenant.id }`. The scope is injected into every read, count, update and delete, while create +overwrites any caller-supplied tenant value. This makes accidental cross-tenant CRUD through the +repository API fail closed. + +```ts +import { createRepository, retryTransaction, databaseHealth, batch } from "@wrnexus/db"; + +const users = createRepository(db, { + table: "users", + allowedColumns: ["email", "name", "active"], +}); + +const user = await users.require(42); +await users.update(42, { active: true }); +``` + +Repository identifiers are validated, writes may be restricted to an allowlist, and values always use query parameters. Infrastructure packages remain helper-only and do not add UI dependencies to server code. + +## 0.8 repository and transaction helpers + +```ts +import { createRepository, databaseHealth, firstOrThrow, retryTransaction } from "@wrnexus/db"; + +const usersRepo = createRepository(db, { + table: "users", + allowedColumns: ["email", "name", "active"], + maxListLimit: 250, +}); + +const users = await usersRepo.all({ + orderBy: "name", + direction: "asc", + limit: 50, + offset: 0, +}); +``` + +Repository SQL identifiers are validated and values remain parameterized. Placeholder generation is dialect-aware: PostgreSQL uses `$1`, `$2`, and SQLite/MySQL use `?`. List limits are bounded. + +`retryTransaction()` retries recognized serialization, deadlock, and database-lock errors by default. Supply `shouldRetry` for application-specific retryable errors; ordinary validation or business errors are not retried automatically. diff --git a/packages/db/package.json b/packages/db/package.json index 84799573..abd65e41 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -1,9 +1,9 @@ { "name": "@wrnexus/db", - "version": "0.7.0", + "version": "0.8.0", "private": true, "type": "module", - "main": "src/index.ts", + "main": "./src/index.ts", "exports": { ".": "./src/index.ts", "./connect": "./src/connect.ts", @@ -12,5 +12,20 @@ "./postgres": "./src/adapters/postgres.ts", "./mysql": "./src/adapters/mysql.ts", "./mongo": "./src/adapters/mongo.ts" + }, + "description": "Typed database drivers, migrations, instrumentation, repositories, pagination, and transaction helpers.", + "types": "./src/index.ts", + "files": [ + "src", + "README.md" + ], + "scripts": { + "test": "bun test", + "typecheck": "tsc --noEmit", + "check": "bun run typecheck && bun run test" + }, + "devDependencies": { + "@types/bun": "^1.3.14", + "typescript": "^5.9.2" } } diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 70daae19..d52018c1 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -70,8 +70,9 @@ export function databaseNames(): string[] { /** Close every configured database and clear the registry. */ export async function closeDatabases(): Promise { - for (const entry of registry.values()) { - if (entry.db) await entry.db.close(); - } + const databases = [...registry.values()].flatMap((entry) => (entry.db ? [entry.db] : [])); registry.clear(); + const results = await Promise.allSettled(databases.map((db) => db.close())); + const errors = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])); + if (errors.length) throw new AggregateError(errors, "One or more databases failed to close"); } diff --git a/packages/db/src/driver.ts b/packages/db/src/driver.ts index a3bae38c..1d8d0495 100644 --- a/packages/db/src/driver.ts +++ b/packages/db/src/driver.ts @@ -50,12 +50,32 @@ export interface Db { close(): void | Promise; } +interface DbLifecycle { + closing: boolean; + active: Set>; + closePromise?: Promise; +} + /** Build a `Db` over a query runner (the driver at top level, or a transaction). */ -function dbOver(runner: TxHandle, driver: Driver): Db { +function dbOver( + runner: TxHandle, + driver: Driver, + lifecycle: DbLifecycle, + transactionScope = false, +): Db { + function run(operation: () => Promise): Promise { + if (lifecycle.closing && !transactionScope) { + return Promise.reject(new Error("WRN-DB-CLOSED: database is closing or closed")); + } + const promise = Promise.resolve().then(operation); + lifecycle.active.add(promise); + void promise.finally(() => lifecycle.active.delete(promise)).catch(() => {}); + return promise; + } const db: Db = { driver, async all(sql, params = [], model) { - const rows = await runner.query(sql, params); + const rows = await run(() => runner.query(sql, params)); return (model ? rows.map((r) => model.parse(r)) : rows) as never; }, async one(sql, params = [], model) { @@ -63,18 +83,25 @@ function dbOver(runner: TxHandle, driver: Driver): Db { return (rows[0] ?? null) as never; }, exec(sql, params = []) { - return runner.exec(sql, params); + return run(() => 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))); + if (runner === driver) + return run(() => driver.transaction((tx) => fn(dbOver(tx, driver, lifecycle, true)))); return fn(db); }, async createTable(model) { - await runner.exec(createTableSql(model, driver.dialect)); + await run(() => runner.exec(createTableSql(model, driver.dialect))); }, close() { - return driver.close(); + if (lifecycle.closePromise) return lifecycle.closePromise; + lifecycle.closing = true; + lifecycle.closePromise = (async () => { + await Promise.allSettled([...lifecycle.active]); + await driver.close(); + })(); + return lifecycle.closePromise; }, }; return db; @@ -82,5 +109,5 @@ function dbOver(runner: TxHandle, driver: Driver): Db { /** Build a `Db` client from a driver. */ export function createDb(driver: Driver): Db { - return dbOver(driver, driver); + return dbOver(driver, driver, { closing: false, active: new Set() }); } diff --git a/packages/db/src/helpers.ts b/packages/db/src/helpers.ts new file mode 100644 index 00000000..088b22a0 --- /dev/null +++ b/packages/db/src/helpers.ts @@ -0,0 +1,274 @@ +import type { Db, ExecResult, Row } from "./driver.ts"; +import type { Model } from "./schema.ts"; +import type { Dialect } from "./sql.ts"; + +const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/; + +function identifier(value: string): string { + if (!IDENTIFIER.test(value)) throw new TypeError(`Unsafe SQL identifier: ${value}`); + return value; +} + +function placeholder(dialect: Dialect, index: number): string { + return dialect === "postgres" ? `$${index}` : "?"; +} + +function placeholders(dialect: Dialect, count: number, start = 1): string[] { + return Array.from({ length: count }, (_value, index) => placeholder(dialect, start + index)); +} + +function finiteInteger(value: number | undefined, fallback: number, minimum: number): number { + if (value === undefined) return fallback; + if (!Number.isFinite(value)) throw new RangeError("Expected a finite integer."); + return Math.max(minimum, Math.floor(value)); +} + +export class RecordNotFoundError extends Error { + constructor(message = "Record not found") { + super(message); + this.name = "RecordNotFoundError"; + } +} + +export async function firstOrThrow( + db: Db, + sql: string, + params: unknown[] = [], + model?: Model, + message?: string, +): Promise { + const row = await db.one(sql, params, model); + if (row === null) throw new RecordNotFoundError(message); + return row; +} + +export async function exists(db: Db, sql: string, params: unknown[] = []): Promise { + return (await db.one(sql, params)) !== null; +} + +export async function countRows( + db: Db, + table: string, + where = "", + params: unknown[] = [], +): Promise { + const row = await db.one<{ count: number | string }>( + `SELECT COUNT(*) AS count FROM ${identifier(table)}${where ? ` WHERE ${where}` : ""}`, + params, + ); + return Number(row?.count ?? 0); +} + +export function withTransaction(db: Db, callback: (tx: Db) => Promise): Promise { + return db.tx(callback); +} + +/** Conservative default classifier for deadlock/serialization retry errors. */ +export function isRetryableTransactionError(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const value = error as { code?: unknown; errno?: unknown; message?: unknown }; + const code = String(value.code ?? value.errno ?? "").toUpperCase(); + if (["40001", "40P01", "SQLITE_BUSY", "SQLITE_LOCKED", "1213", "1205"].includes(code)) { + return true; + } + const message = String(value.message ?? "").toLowerCase(); + return /deadlock|serialization failure|database is locked|lock wait timeout/.test(message); +} + +export async function retryTransaction( + db: Db, + callback: (tx: Db, attempt: number) => Promise, + options: { + attempts?: number; + baseDelayMs?: number; + maxDelayMs?: number; + jitter?: boolean; + shouldRetry?: (error: unknown) => boolean; + } = {}, +): Promise { + const attempts = finiteInteger(options.attempts, 3, 1); + const baseDelayMs = finiteInteger(options.baseDelayMs, 25, 0); + const maxDelayMs = finiteInteger(options.maxDelayMs, 1_000, 0); + const shouldRetry = options.shouldRetry ?? isRetryableTransactionError; + let lastError: unknown; + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + return await db.tx((tx) => callback(tx, attempt)); + } catch (error) { + lastError = error; + if (attempt >= attempts || !shouldRetry(error)) throw error; + const exponential = Math.min(maxDelayMs, baseDelayMs * 2 ** (attempt - 1)); + const delay = + options.jitter === false + ? exponential + : Math.round(exponential * (0.5 + Math.random() * 0.5)); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + throw lastError; +} + +export function batch(values: readonly T[], size = 100): T[][] { + const chunkSize = finiteInteger(size, 100, 1); + const chunks: T[][] = []; + for (let index = 0; index < values.length; index += chunkSize) { + chunks.push(values.slice(index, index + chunkSize)); + } + return chunks; +} + +export async function databaseHealth( + db: Db, +): Promise<{ ok: boolean; latencyMs: number; error?: string }> { + const start = performance.now(); + try { + await db.one("SELECT 1 AS healthy"); + return { ok: true, latencyMs: Math.round((performance.now() - start) * 100) / 100 }; + } catch (error) { + return { + ok: false, + latencyMs: Math.round((performance.now() - start) * 100) / 100, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +export interface RepositoryListOptions { + limit?: number; + offset?: number; + orderBy?: keyof T & string; + direction?: "asc" | "desc"; +} + +export interface Repository { + all(options?: RepositoryListOptions): Promise; + find(id: string | number): Promise; + require(id: string | number): Promise; + create(values: Partial): Promise; + update(id: string | number, values: Partial): Promise; + remove(id: string | number): Promise; + exists(id: string | number): Promise; + count(): Promise; +} + +export function createRepository( + db: Db, + input: { + table: string; + idColumn?: string; + model?: Model; + allowedColumns?: readonly (keyof T & string)[]; + maxListLimit?: number; + /** Immutable equality scope (normally tenant_id) applied to every operation. */ + scope?: { column: keyof T & string; value: unknown }; + }, +): Repository { + const table = identifier(input.table); + const idColumn = identifier(input.idColumn ?? "id"); + const allowed = input.allowedColumns ? new Set(input.allowedColumns.map(identifier)) : null; + const dialect = db.driver.dialect; + const maxListLimit = finiteInteger(input.maxListLimit, 1_000, 1); + const scopeColumn = input.scope ? identifier(input.scope.column) : null; + const valuesOf = (values: Partial) => { + const entries = Object.entries(values).filter(([key, value]) => { + if (value === undefined || key === idColumn) return false; + identifier(key); + return !allowed || allowed.has(key) || key === scopeColumn; + }); + if (!entries.length) + throw new TypeError("Repository write requires at least one allowed column."); + return entries; + }; + const allowedReadColumn = (value: string): string => { + const safe = identifier(value); + if (allowed && safe !== idColumn && !allowed.has(safe)) { + throw new TypeError(`Repository column is not allowed: ${safe}`); + } + return safe; + }; + + return { + all: (options = {}) => { + const clauses: string[] = scopeColumn + ? [`WHERE ${scopeColumn} = ${placeholder(dialect, 1)}`] + : []; + const params: unknown[] = scopeColumn ? [input.scope!.value] : []; + if (options.orderBy) { + clauses.push( + `ORDER BY ${allowedReadColumn(options.orderBy)} ${(options.direction ?? "asc").toUpperCase()}`, + ); + } + if (options.limit !== undefined) { + const limit = Math.min(maxListLimit, finiteInteger(options.limit, maxListLimit, 1)); + params.push(limit); + clauses.push(`LIMIT ${placeholder(dialect, params.length)}`); + } + if (options.offset !== undefined) { + const offset = finiteInteger(options.offset, 0, 0); + if (options.limit === undefined) { + params.push(maxListLimit); + clauses.push(`LIMIT ${placeholder(dialect, params.length)}`); + } + params.push(offset); + clauses.push(`OFFSET ${placeholder(dialect, params.length)}`); + } + return db.all( + `SELECT * FROM ${table}${clauses.length ? ` ${clauses.join(" ")}` : ""}`, + params, + input.model, + ); + }, + find: (id) => + db.one( + `SELECT * FROM ${table} WHERE ${idColumn} = ${placeholder(dialect, 1)}${scopeColumn ? ` AND ${scopeColumn} = ${placeholder(dialect, 2)}` : ""} LIMIT 1`, + scopeColumn ? [id, input.scope!.value] : [id], + input.model, + ), + require: (id) => + firstOrThrow( + db, + `SELECT * FROM ${table} WHERE ${idColumn} = ${placeholder(dialect, 1)}${scopeColumn ? ` AND ${scopeColumn} = ${placeholder(dialect, 2)}` : ""} LIMIT 1`, + scopeColumn ? [id, input.scope!.value] : [id], + input.model, + ), + async create(values) { + const scoped = scopeColumn ? { ...values, [scopeColumn]: input.scope!.value } : values; + const entries = valuesOf(scoped); + return db.exec( + `INSERT INTO ${table} (${entries.map(([key]) => identifier(key)).join(", ")}) VALUES (${placeholders(dialect, entries.length).join(", ")})`, + entries.map(([, value]) => value), + ); + }, + async update(id, values) { + const entries = valuesOf(values); + return db.exec( + `UPDATE ${table} SET ${entries + .map(([key], index) => `${identifier(key)} = ${placeholder(dialect, index + 1)}`) + .join( + ", ", + )} WHERE ${idColumn} = ${placeholder(dialect, entries.length + 1)}${scopeColumn ? ` AND ${scopeColumn} = ${placeholder(dialect, entries.length + 2)}` : ""}`, + [...entries.map(([, value]) => value), id, ...(scopeColumn ? [input.scope!.value] : [])], + ); + }, + remove: (id) => + db.exec( + `DELETE FROM ${table} WHERE ${idColumn} = ${placeholder(dialect, 1)}${scopeColumn ? ` AND ${scopeColumn} = ${placeholder(dialect, 2)}` : ""}`, + scopeColumn ? [id, input.scope!.value] : [id], + ), + exists: (id) => + exists( + db, + `SELECT 1 FROM ${table} WHERE ${idColumn} = ${placeholder(dialect, 1)}${scopeColumn ? ` AND ${scopeColumn} = ${placeholder(dialect, 2)}` : ""} LIMIT 1`, + scopeColumn ? [id, input.scope!.value] : [id], + ), + count: () => + scopeColumn + ? db + .one<{ count: number | string }>( + `SELECT COUNT(*) AS count FROM ${table} WHERE ${scopeColumn} = ${placeholder(dialect, 1)}`, + [input.scope!.value], + ) + .then((row) => Number(row?.count ?? 0)) + : countRows(db, table), + }; +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 1f845687..fe54c2ba 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -31,7 +31,9 @@ export { status, scaffoldMigration, } from "./migrate.ts"; -export type { Migration } from "./migrate.ts"; +export type { Migration, MigrationRunOptions } from "./migrate.ts"; +export { analyzeMigrationSafety, analyzeMigrations } from "./migration-safety.ts"; +export type { MigrationSafetyIssue } from "./migration-safety.ts"; export { parseQueries, generateQueriesFile } from "./generate.ts"; export type { QueryDef, QueryKind, ModelRef } from "./generate.ts"; export { paginate, loadRelated } from "./query.ts"; @@ -39,5 +41,22 @@ export type { Paginated, PageOptions, RelationOptions } from "./query.ts"; export { cursorPaginate, optimisticUpdate, tenantScope, softDeleteClause } from "./advanced.ts"; export type { CursorPage, CursorPageOptions } from "./advanced.ts"; -export { instrumentDb, queryOperation } from "./performance.ts"; +export { + instrumentDb, + queryOperation, + getDbPerformanceSnapshot, + resetDbPerformanceSnapshot, +} from "./performance.ts"; export type { QueryIssue, QueryPolicy, QueryRecord } from "./performance.ts"; +export { + RecordNotFoundError, + firstOrThrow, + exists, + countRows, + withTransaction, + retryTransaction, + batch, + databaseHealth, + createRepository, +} from "./helpers.ts"; +export type { Repository } from "./helpers.ts"; diff --git a/packages/db/src/migrate.ts b/packages/db/src/migrate.ts index 5d6b40c2..464529d0 100644 --- a/packages/db/src/migrate.ts +++ b/packages/db/src/migrate.ts @@ -20,6 +20,18 @@ export interface Migration { } const MIGRATIONS_TABLE = "_wire_migrations"; +const MIGRATION_LOCKS_TABLE = "_wire_migration_locks"; + +export interface MigrationRunOptions { + /** Return pending migration names without executing their SQL. */ + dryRun?: boolean; + /** Stop safely between migrations. Active database statements cannot be interrupted portably. */ + signal?: AbortSignal; + /** Coordinate migration runners through the database. Default true. */ + lock?: boolean; + /** Allow recovery of a lock left by a crashed process. Default 5 minutes. */ + lockTimeoutMs?: number; +} /** Split a migration file into its `up` and `down` SQL sections. */ export function parseMigration(name: string, content: string): Migration { @@ -50,7 +62,7 @@ export function loadMigrations(dir: string): Migration[] { async function ensureTable(db: Db): Promise { await db.exec( - `CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)`, + `CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (name VARCHAR(255) PRIMARY KEY, applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)`, ); } @@ -63,38 +75,113 @@ export async function appliedMigrations(db: Db): Promise { return rows.map((r) => r.name); } +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) throw signal.reason ?? new DOMException("Migration aborted", "AbortError"); +} + +async function acquireMigrationLock(db: Db, timeoutMs: number): Promise<() => Promise> { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new RangeError("migration lockTimeoutMs must be a positive number"); + } + await db.exec( + `CREATE TABLE IF NOT EXISTS ${MIGRATION_LOCKS_TABLE} (name VARCHAR(255) PRIMARY KEY, owner VARCHAR(255) NOT NULL, expires_at VARCHAR(40) NOT NULL)`, + ); + const owner = crypto.randomUUID(); + const now = new Date(); + await db.exec(`DELETE FROM ${MIGRATION_LOCKS_TABLE} WHERE name = ? AND expires_at <= ?`, [ + "global", + now.toISOString(), + ]); + try { + await db.exec( + `INSERT INTO ${MIGRATION_LOCKS_TABLE} (name, owner, expires_at) VALUES (?, ?, ?)`, + ["global", owner, new Date(now.getTime() + timeoutMs).toISOString()], + ); + } catch (error) { + const held = await db.all(`SELECT owner FROM ${MIGRATION_LOCKS_TABLE} WHERE name = ?`, [ + "global", + ]); + if (held.length === 0) throw error; + throw new Error("WRN-DB-MIGRATION-LOCKED: another process is running database migrations", { + cause: error, + }); + } + return async () => { + await db.exec(`DELETE FROM ${MIGRATION_LOCKS_TABLE} WHERE name = ? AND owner = ?`, [ + "global", + owner, + ]); + }; +} + /** Apply an ordered migration list (each in a transaction). Returns applied names. */ -export async function applyMigrations(db: Db, migrations: readonly Migration[]): Promise { +export async function applyMigrations( + db: Db, + migrations: readonly Migration[], + options: MigrationRunOptions = {}, +): Promise { if (migrations.length === 0) return []; + throwIfAborted(options.signal); const applied = new Set(await appliedMigrations(db)); const pending = migrations.filter((migration) => !applied.has(migration.name)); + if (options.dryRun || pending.length === 0) return pending.map(({ name }) => name); + const release = + options.lock === false + ? undefined + : await acquireMigrationLock(db, options.lockTimeoutMs ?? 300_000); const done: string[] = []; - for (const migration of pending) { - await db.tx(async (tx) => { - if (migration.up) await tx.exec(migration.up); - await tx.exec(`INSERT INTO ${MIGRATIONS_TABLE} (name) VALUES (?)`, [migration.name]); - }); - done.push(migration.name); + try { + // Re-read after locking because another runner may have completed while we waited. + const current = new Set(await appliedMigrations(db)); + for (const migration of pending.filter(({ name }) => !current.has(name))) { + throwIfAborted(options.signal); + await db.tx(async (tx) => { + if (migration.up) await tx.exec(migration.up); + await tx.exec(`INSERT INTO ${MIGRATIONS_TABLE} (name) VALUES (?)`, [migration.name]); + }); + done.push(migration.name); + } + return done; + } finally { + await release?.(); } - return done; } /** Apply all pending migrations from a directory. */ -export async function migrate(db: Db, dir: string): Promise { - return applyMigrations(db, loadMigrations(dir)); +export async function migrate( + db: Db, + dir: string, + options: MigrationRunOptions = {}, +): Promise { + return applyMigrations(db, loadMigrations(dir), options); } /** Roll back the most recently applied migration. Returns its name, or null. */ -export async function rollback(db: Db, dir: string): Promise { +export async function rollback( + db: Db, + dir: string, + options: Omit & { dryRun?: boolean } = {}, +): Promise { + throwIfAborted(options.signal); const applied = await appliedMigrations(db); const last = applied[applied.length - 1]; if (!last) return null; + if (options.dryRun) return last; + const release = + options.lock === false + ? undefined + : await acquireMigrationLock(db, options.lockTimeoutMs ?? 300_000); 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; + try { + throwIfAborted(options.signal); + 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; + } finally { + await release?.(); + } } /** Full status: every migration file with whether it has been applied. */ diff --git a/packages/db/src/migration-safety.ts b/packages/db/src/migration-safety.ts new file mode 100644 index 00000000..cfab806d --- /dev/null +++ b/packages/db/src/migration-safety.ts @@ -0,0 +1,107 @@ +import type { Migration } from "./migrate.ts"; + +export interface MigrationSafetyIssue { + code: + | "WRN-DB-DROP-TABLE" + | "WRN-DB-DROP-COLUMN" + | "WRN-DB-RENAME" + | "WRN-DB-TYPE-CHANGE" + | "WRN-DB-SET-NOT-NULL" + | "WRN-DB-ADD-REQUIRED" + | "WRN-DB-BLOCKING-INDEX"; + severity: "error" | "warning"; + migration: string; + statement: string; + recommendation: string; +} + +function statements(sql: string): string[] { + return sql + .replace(/\/\*[\s\S]*?\*\//g, " ") + .split(";") + .map((statement) => + statement + .replace(/--[^\r\n]*/g, " ") + .replace(/\s+/g, " ") + .trim(), + ) + .filter(Boolean); +} + +export function analyzeMigrationSafety(migration: Migration): MigrationSafetyIssue[] { + const issues: MigrationSafetyIssue[] = []; + const add = ( + code: MigrationSafetyIssue["code"], + severity: MigrationSafetyIssue["severity"], + statement: string, + recommendation: string, + ): void => { + issues.push({ code, severity, migration: migration.name, statement, recommendation }); + }; + for (const statement of statements(migration.up)) { + if (/\bDROP\s+TABLE\b/i.test(statement)) + add( + "WRN-DB-DROP-TABLE", + "error", + statement, + "Deprecate reads/writes first; drop in a later contract release.", + ); + if ( + /\bDROP\s+(?:COLUMN\s+)?[A-Za-z_][\w$]*/i.test(statement) && + /\bALTER\s+TABLE\b/i.test(statement) + ) + add( + "WRN-DB-DROP-COLUMN", + "error", + statement, + "Stop all old-version reads before a separate contract migration.", + ); + if (/\bRENAME\s+(?:COLUMN\s+)?\b|\bRENAME\s+TO\b/i.test(statement)) + add( + "WRN-DB-RENAME", + "error", + statement, + "Add the new name, dual-write/backfill, switch readers, then remove the old name.", + ); + if ( + /\bALTER\s+(?:COLUMN\s+)?[A-Za-z_][\w$]*\s+(?:TYPE|SET\s+DATA\s+TYPE)\b|\bMODIFY\s+(?:COLUMN\s+)?[A-Za-z_]/i.test( + statement, + ) + ) + add( + "WRN-DB-TYPE-CHANGE", + "error", + statement, + "Add a compatible column and backfill before switching readers.", + ); + if (/\bALTER\s+(?:COLUMN\s+)?[A-Za-z_][\w$]*\s+SET\s+NOT\s+NULL\b/i.test(statement)) + add( + "WRN-DB-SET-NOT-NULL", + "error", + statement, + "Backfill and validate existing rows before enforcing NOT NULL.", + ); + if ( + /\bADD\s+(?:COLUMN\s+)?[A-Za-z_][\w$]*[\s\S]*\bNOT\s+NULL\b/i.test(statement) && + !/\bDEFAULT\b/i.test(statement) + ) + add( + "WRN-DB-ADD-REQUIRED", + "error", + statement, + "Add nullable, backfill in batches, then enforce the constraint.", + ); + if (/^CREATE\s+(?:UNIQUE\s+)?INDEX\b/i.test(statement) && !/\bCONCURRENTLY\b/i.test(statement)) + add( + "WRN-DB-BLOCKING-INDEX", + "warning", + statement, + "Use an online/concurrent index operation when the database supports it.", + ); + } + return issues; +} + +export function analyzeMigrations(migrations: readonly Migration[]): MigrationSafetyIssue[] { + return migrations.flatMap(analyzeMigrationSafety); +} diff --git a/packages/db/src/performance.ts b/packages/db/src/performance.ts index 1b9084ba..c3b5c6e8 100644 --- a/packages/db/src/performance.ts +++ b/packages/db/src/performance.ts @@ -28,6 +28,24 @@ export interface QueryPolicy { onIssue?: (issue: QueryIssue) => void | Promise; } +const recentQueries: QueryRecord[] = []; +const recentQueryIssues: QueryIssue[] = []; +const TELEMETRY_LIMIT = 200; + +export function getDbPerformanceSnapshot(): { queries: QueryRecord[]; issues: QueryIssue[] } { + return { queries: structuredClone(recentQueries), issues: structuredClone(recentQueryIssues) }; +} + +export function resetDbPerformanceSnapshot(): void { + recentQueries.length = 0; + recentQueryIssues.length = 0; +} + +function remember(items: T[], value: T): void { + items.push(structuredClone(value)); + if (items.length > TELEMETRY_LIMIT) items.splice(0, items.length - TELEMETRY_LIMIT); +} + function normalizedSql(sql: string): string { return sql .replace(/--.*$/gm, " ") @@ -70,12 +88,14 @@ export function instrumentDb(db: Db, policy: QueryPolicy = {}): Db { const inspect = async (sql: string): Promise => { const normalized = normalizedSql(sql); if (policy.warnSelectStar !== false && /^SELECT\s+\*/i.test(normalized)) { - await policy.onIssue?.({ + const issue: QueryIssue = { code: "WRN-DB-SELECT-STAR", severity: "warning", message: "Avoid SELECT * in production queries.", sql, - }); + }; + remember(recentQueryIssues, issue); + await policy.onIssue?.(issue); } if ( policy.warnUnboundedSelect !== false && @@ -83,12 +103,14 @@ export function instrumentDb(db: Db, policy: QueryPolicy = {}): Db { !/\bLIMIT\b/i.test(normalized) && !/\bCOUNT\s*\(/i.test(normalized) ) { - await policy.onIssue?.({ + const issue: QueryIssue = { code: "WRN-DB-UNBOUNDED-SELECT", severity: "warning", message: "SELECT query has no LIMIT. Prefer cursor pagination for large datasets.", sql, - }); + }; + remember(recentQueryIssues, issue); + await policy.onIssue?.(issue); } }; @@ -110,22 +132,27 @@ export function instrumentDb(db: Db, policy: QueryPolicy = {}): Db { operation, duplicateCount, }; + remember(recentQueries, queryRecord); await policy.onQuery?.(queryRecord); if (durationMs >= slowQueryMs) { - await policy.onIssue?.({ + const issue: QueryIssue = { code: "WRN-DB-SLOW-QUERY", severity: durationMs >= slowQueryMs * 5 ? "error" : "warning", message: `Query took ${durationMs.toFixed(2)} ms.`, sql, - }); + }; + remember(recentQueryIssues, issue); + await policy.onIssue?.(issue); } if (duplicateCount === duplicateWarningCount) { - await policy.onIssue?.({ + const issue: QueryIssue = { code: "WRN-DB-DUPLICATE-QUERY", severity: "warning", message: `The same query ran ${duplicateCount} times in one request scope (possible N+1).`, sql, - }); + }; + remember(recentQueryIssues, issue); + await policy.onIssue?.(issue); } }; diff --git a/packages/db/test/db.test.ts b/packages/db/test/db.test.ts index 155b06a1..1caea898 100644 --- a/packages/db/test/db.test.ts +++ b/packages/db/test/db.test.ts @@ -15,7 +15,7 @@ import { parseQueries, generateQueriesFile, } from "../src/index.ts"; -import type { Db } from "../src/index.ts"; +import type { Db, Driver } from "../src/index.ts"; import { sqlite } from "../src/adapters/sqlite.ts"; import { bunSql } from "../src/adapters/bunsql.ts"; @@ -39,6 +39,39 @@ test("model.parse coerces DB rows to typed values", () => { expect(row.active).toBe(true); }); +test("database close drains active work, rejects new queries, and is idempotent", async () => { + let release!: () => void; + const pending = new Promise((resolve) => (release = resolve)); + let closes = 0; + const driver: Driver = { + dialect: "sqlite", + async query() { + await pending; + return [{ ok: true }]; + }, + async exec() { + return { changes: 0 }; + }, + async transaction(fn) { + return fn(this); + }, + close() { + closes++; + }, + }; + const db = createDb(driver); + const query = db.all("SELECT 1"); + await Promise.resolve(); + const closing = Promise.resolve(db.close()); + await expect(db.all("SELECT 2")).rejects.toThrow("WRN-DB-CLOSED"); + expect(closes).toBe(0); + release(); + expect(await query).toEqual([{ ok: true }]); + await closing; + await db.close(); + expect(closes).toBe(1); +}); + for (const [label, driver] of [ ["bun:sqlite", () => sqlite()], ["Bun.sql/sqlite", () => bunSql("sqlite://:memory:", "sqlite")], @@ -111,6 +144,41 @@ test("an empty migration set does not touch the database", async () => { expect(await applyMigrations(db, [])).toEqual([]); }); +test("migration dry-run plans changes without applying schema and honors cancellation", async () => { + const db = createDb(sqlite()); + const migrations = [ + { name: "0001_plan", up: "CREATE TABLE planned (id INTEGER)", down: "DROP TABLE planned" }, + ]; + expect(await applyMigrations(db, migrations, { dryRun: true })).toEqual(["0001_plan"]); + expect( + await db.all("SELECT name FROM sqlite_master WHERE type='table' AND name='planned'"), + ).toHaveLength(0); + + const controller = new AbortController(); + controller.abort(new Error("deploy cancelled")); + await expect(applyMigrations(db, migrations, { signal: controller.signal })).rejects.toThrow( + "deploy cancelled", + ); + await db.close(); +}); + +test("migration lock rejects a concurrent runner and recovers expired locks", async () => { + const db = createDb(sqlite()); + const migrations = [{ name: "0001_lock", up: "CREATE TABLE locked_test (id INTEGER)", down: "" }]; + await db.exec( + "CREATE TABLE _wire_migration_locks (name TEXT PRIMARY KEY, owner TEXT NOT NULL, expires_at TEXT NOT NULL)", + ); + await db.exec("INSERT INTO _wire_migration_locks (name, owner, expires_at) VALUES (?, ?, ?)", [ + "global", + "other", + new Date(Date.now() + 60_000).toISOString(), + ]); + await expect(applyMigrations(db, migrations)).rejects.toThrow("WRN-DB-MIGRATION-LOCKED"); + await db.exec("UPDATE _wire_migration_locks SET expires_at = ?", [new Date(0).toISOString()]); + expect(await applyMigrations(db, migrations)).toEqual(["0001_lock"]); + await db.close(); +}); + test("query generator infers params and result types", () => { const q = parseQueries( "-- name: GetByEmail :one\nSELECT * FROM users WHERE email = :email;\n" + diff --git a/packages/db/test/helpers.test.ts b/packages/db/test/helpers.test.ts new file mode 100644 index 00000000..53957b81 --- /dev/null +++ b/packages/db/test/helpers.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from "bun:test"; +import { + batch, + createDb, + createRepository, + databaseHealth, + retryTransaction, +} from "../src/index.ts"; +import type { Driver, Row } from "../src/index.ts"; + +function memoryDriver(): Driver { + const rows: Row[] = [{ id: 1, name: "One" }]; + return { + dialect: "sqlite", + async query(sql) { + if (/COUNT/.test(sql)) return [{ count: rows.length }]; + if (/SELECT 1 AS healthy/.test(sql)) return [{ healthy: 1 }]; + return rows; + }, + async exec() { + return { changes: 1, lastInsertId: 2 }; + }, + async transaction(callback) { + return callback(this); + }, + close() {}, + }; +} + +describe("database helper kit", () => { + test("provides repository CRUD helpers and health checks", async () => { + const db = createDb(memoryDriver()); + const repository = createRepository<{ id: number; name: string }>(db, { + table: "items", + allowedColumns: ["name"], + }); + expect((await repository.find(1))?.name).toBe("One"); + expect(await repository.count()).toBe(1); + expect((await databaseHealth(db)).ok).toBe(true); + expect(batch([1, 2, 3], 2)).toEqual([[1, 2], [3]]); + }); + + test("retries transaction callbacks with bounded attempts", async () => { + const db = createDb(memoryDriver()); + let calls = 0; + const value = await retryTransaction( + db, + async () => { + calls += 1; + if (calls < 2) throw new Error("retry"); + return "done"; + }, + { attempts: 2, baseDelayMs: 0, shouldRetry: () => true }, + ); + expect(value).toBe("done"); + expect(calls).toBe(2); + }); + + test("uses dialect-aware placeholders and bounded list options", async () => { + const queries: string[] = []; + const driver = memoryDriver(); + driver.dialect = "postgres"; + const originalQuery = driver.query; + driver.query = async (sql, params) => { + queries.push(sql); + return originalQuery.call(driver, sql, params); + }; + const originalExec = driver.exec; + driver.exec = async (sql, params) => { + queries.push(sql); + return originalExec.call(driver, sql, params); + }; + const repository = createRepository<{ id: number; name: string }>(createDb(driver), { + table: "items", + allowedColumns: ["name"], + maxListLimit: 50, + }); + await repository.find(1); + await repository.create({ name: "Two" }); + await repository.update(1, { name: "Changed" }); + await repository.all({ limit: 10, offset: 5, orderBy: "name", direction: "desc" }); + expect(queries.some((sql) => sql.includes("id = $1"))).toBe(true); + expect(queries.some((sql) => sql.includes("VALUES ($1)"))).toBe(true); + expect(queries.some((sql) => sql.includes("LIMIT $1 OFFSET $2"))).toBe(true); + }); + + test("automatically applies an immutable tenant scope to every repository operation", async () => { + const calls: Array<{ sql: string; params: unknown[] }> = []; + const driver: Driver = { + dialect: "sqlite", + async query(sql, params = []) { + calls.push({ sql, params: [...params] }); + return /COUNT/.test(sql) ? [{ count: 0 }] : []; + }, + async exec(sql, params = []) { + calls.push({ sql, params: [...params] }); + return { changes: 1 }; + }, + async transaction(callback) { + return callback(this); + }, + close() {}, + }; + type RecordRow = { id: number; name: string; tenant_id: string }; + const repository = createRepository(createDb(driver), { + table: "records", + allowedColumns: ["name"], + scope: { column: "tenant_id", value: "acme" }, + }); + await repository.all(); + await repository.find(1); + await repository.create({ name: "A", tenant_id: "other" }); + await repository.update(1, { name: "B" }); + await repository.remove(1); + await repository.count(); + expect(calls.every((call) => call.sql.includes("tenant_id"))).toBe(true); + expect(calls.every((call) => call.params.includes("acme"))).toBe(true); + expect(calls.some((call) => call.params.includes("other"))).toBe(false); + }); + + test("rejects unsafe repository identifiers", () => { + const db = createDb(memoryDriver()); + expect(() => createRepository(db, { table: "items; DROP TABLE items" })).toThrow( + "Unsafe SQL identifier", + ); + }); +}); diff --git a/packages/db/test/migration-safety.test.ts b/packages/db/test/migration-safety.test.ts new file mode 100644 index 00000000..11e68873 --- /dev/null +++ b/packages/db/test/migration-safety.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { analyzeMigrationSafety } from "../src/index.ts"; + +describe("expand and contract migration analysis", () => { + test("detects destructive and rollout-unsafe SQL with guidance", () => { + const issues = analyzeMigrationSafety({ + name: "0002_breaking", + up: `ALTER TABLE users RENAME COLUMN name TO full_name; + ALTER TABLE users ADD COLUMN tenant_id UUID NOT NULL; + ALTER TABLE users ALTER COLUMN age TYPE BIGINT; + DROP TABLE legacy_users; + CREATE INDEX users_email_idx ON users(email);`, + down: "", + }); + expect(issues.map((issue) => issue.code)).toEqual([ + "WRN-DB-RENAME", + "WRN-DB-ADD-REQUIRED", + "WRN-DB-TYPE-CHANGE", + "WRN-DB-DROP-TABLE", + "WRN-DB-BLOCKING-INDEX", + ]); + expect(issues.every((issue) => issue.recommendation.length > 20)).toBe(true); + }); + + test("accepts the additive phase of an expand/contract rollout", () => { + expect( + analyzeMigrationSafety({ + name: "0002_expand", + up: "ALTER TABLE users ADD COLUMN full_name TEXT;", + down: "", + }), + ).toEqual([]); + }); +}); diff --git a/packages/db/test/registry.test.ts b/packages/db/test/registry.test.ts index 192d1223..52f0facf 100644 --- a/packages/db/test/registry.test.ts +++ b/packages/db/test/registry.test.ts @@ -10,6 +10,7 @@ import { closeDatabases, } from "../src/index.ts"; import { sqlite } from "../src/adapters/sqlite.ts"; +import type { Db } from "../src/index.ts"; test("multi-database registry: default + named connections", async () => { await closeDatabases(); // isolate from any prior state @@ -78,3 +79,16 @@ test("closing the registry does not instantiate unused lazy databases", async () await closeDatabases(); expect(calls).toBe(0); }); + +test("registry closes every database and clears itself when one close fails", async () => { + await closeDatabases(); + let secondClosed = false; + const broken = { close: async () => Promise.reject(new Error("close failed")) } as Db; + const healthy = { close: () => void (secondClosed = true) } as Db; + setDb(broken); + registerDb("healthy", healthy); + + await expect(closeDatabases()).rejects.toThrow("databases failed to close"); + expect(secondClosed).toBe(true); + expect(databaseNames()).toEqual([]); +}); diff --git a/packages/dev-server/README.md b/packages/dev-server/README.md index 7d910ce3..9159b79e 100644 --- a/packages/dev-server/README.md +++ b/packages/dev-server/README.md @@ -66,6 +66,12 @@ interface RunningServer { In development, `startServer` also connects `app/db/migrations` (and `app/db//migrations`) and auto-applies migrations, then starts an in-process file watcher. CSS edits hot-swap live. Page, component, layout, API, middleware, realtime, schema, locale, and public-asset edits invalidate only their cached modules, rescan routes where necessary, and morph fresh HTML through the existing HMR WebSocket. The server process and active gateway stay running. +`getWrnCompileMetrics()` exposes cumulative content-addressed compiler cache +`hits`, `misses`, successful `compilations`, `errors`, `totalDurationMs`, and +`lastDurationMs` for the DevToolbar or custom diagnostics. Tests and embedded +servers can call `resetWrnCompileMetrics()` to establish a fresh measurement +window. + ### `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). @@ -296,7 +302,9 @@ Pages get only the scripts they use: `nav.js` always, `reactive.js` when a page - **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. +- `.wrn` files compile into a content-addressed hidden `.wrnexus/` cache. Targeted + invalidation gives changed modules a fresh import identity without restarting + the development server. - 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`. diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json index d2b4492e..b4914115 100644 --- a/packages/dev-server/package.json +++ b/packages/dev-server/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-server", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "main": "src/index.ts", "exports": { @@ -24,6 +24,8 @@ "@wrnexus/plugin": "workspace:*", "@wrnexus/store": "workspace:*", "@wrnexus/security": "workspace:*", - "@wrnexus/observability": "workspace:*" + "@wrnexus/observability": "workspace:*", + "@wrnexus/cache": "workspace:*", + "@wrnexus/pwa": "workspace:*" } } diff --git a/packages/dev-server/src/assets.ts b/packages/dev-server/src/assets.ts index e3501eea..115268fb 100644 --- a/packages/dev-server/src/assets.ts +++ b/packages/dev-server/src/assets.ts @@ -10,7 +10,12 @@ * invalidated in-process by the file watcher so edits show without a restart. */ -import { getReactiveRuntime, getNavRuntime, getRealtimeRuntime } from "@wrnexus/csr"; +import { + getActionRuntime, + getReactiveRuntime, + getNavRuntime, + getRealtimeRuntime, +} from "@wrnexus/csr"; import { renderStyles, renderThemeCss, @@ -91,6 +96,7 @@ export function createDevAssetServer( if (pathname === "/__wrnexus/reactive.js") return jsResponse(getReactiveRuntime()); if (pathname === "/__wrnexus/nav.js") return jsResponse(getNavRuntime()); if (pathname === "/__wrnexus/realtime.js") return jsResponse(getRealtimeRuntime()); + if (pathname === "/__wrnexus/actions.js") return jsResponse(getActionRuntime()); if (pathname === "/__wrnexus/validate.js") return jsResponse(VALIDATE_RUNTIME); if (pathname === "/__wrnexus/i18n.js") return jsResponse(I18N_RUNTIME); if (pathname === UPLOAD_JS_HREF) return jsResponse(UPLOAD_RUNTIME); diff --git a/packages/dev-server/src/index.ts b/packages/dev-server/src/index.ts index 7ad6a67b..9ea669ae 100644 --- a/packages/dev-server/src/index.ts +++ b/packages/dev-server/src/index.ts @@ -6,7 +6,7 @@ * the running process while the HMR socket morphs fresh HTML into the browser. */ -import { readFileSync } from "node:fs"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { resolve, dirname, isAbsolute, join } from "node:path"; import type { Middleware, Mode, SecurityConfig, SeoConfig } from "@wrnexus/core"; import { buildRouter, type Router } from "@wrnexus/router"; @@ -28,6 +28,7 @@ import { setDb, registerDb, registerLazyDb, + getDbPerformanceSnapshot, } from "@wrnexus/db"; import { connectFromConfig } from "@wrnexus/db/connect"; import { configureStorage, type StorageConfig } from "@wrnexus/uploader"; @@ -38,6 +39,7 @@ import { loadWrnServerModule, setCompileCacheDir, setCompileImportOptions, + setDevCompilerPipeline, wrnBrowserArtifactUrl, } from "./pipeline.ts"; import { createRpcHandler } from "@wrnexus/ssr/rpc"; @@ -48,18 +50,27 @@ import { resolvePackageMigrations } from "./plugin-migrations.ts"; import { HmrHub } from "./hmr.ts"; import { startWatcher } from "./watch.ts"; export { RESTART_EXIT_CODE } from "./restart.ts"; +export { expandStaticComponents, precomputePartialStaticShell } from "./partial-build.ts"; +export { getWrnCompileMetrics, resetWrnCompileMetrics } from "./pipeline.ts"; +export type { WrnCompileMetrics } from "./pipeline.ts"; import { resetDevCache } from "./cache.ts"; import type { DevToolbarConfig } from "@wrnexus/dev-toolbar/types"; import { createPluginRunner, discoverPlugins, type PluginInput } from "@wrnexus/plugin"; import type { ObservabilityConfig, TenancyConfig } from "@wrnexus/styles"; -import { createDevToolbarCollector, type DevToolbarCollector } from "@wrnexus/dev-toolbar/server"; +import { + builtinDevToolbarPanels, + createDevToolbarCollector, + type DevToolbarCollector, +} from "@wrnexus/dev-toolbar/server"; export interface ServeOptions { appDir: string; port?: number; hostname?: string; + /** Development TLS material. Production TLS is normally terminated by the deployment proxy. */ + tls?: { cert: string; key: string }; mode?: Mode; /** Inject the live-reload client (defaults to true in development). */ hmr?: boolean; @@ -193,6 +204,12 @@ export async function startServer(opts: ServeOptions): Promise { includeDevDependencies: true, strict: true, warn: (message) => console.warn(`[wrnexus:plugin] ${message}`), + enforcePermissions: (opts.appConfig?.pluginPermissions as { enforce?: boolean } | undefined) + ?.enforce, + grantedPermissions: ( + opts.appConfig?.pluginPermissions as + { grants?: Record } | undefined + )?.grants, }); const pluginRunner = createPluginRunner(discoveredPlugins, { @@ -252,6 +269,30 @@ export async function startServer(opts: ServeOptions): Promise { ); const pluginContributions = await pluginRunner.contributions(); + const virtualModules = new Map(); + const virtualDir = join(appRoot, ".wrnexus", "virtual"); + mkdirSync(virtualDir, { recursive: true }); + for (const [index, module] of pluginContributions.virtualModules.entries()) { + const output = join(virtualDir, `plugin-${index}.ts`); + writeFileSync( + output, + await module.load({ + root: appRoot, + mode, + command: "dev", + profile: process.env.WRNEXUS_PROFILE, + metadata: new Map(), + warn: (message) => console.warn(`[wrnexus:plugin] ${message}`), + }), + "utf8", + ); + virtualModules.set(module.id, output); + } + setDevCompilerPipeline({ + transformAst: (ast, file) => pluginRunner.transformAst(ast, file), + transformCode: (code, file) => pluginRunner.transformCode(code, file), + virtualModules, + }); console.log( `[wrnexus:plugin] discovered: ${ @@ -296,7 +337,7 @@ export async function startServer(opts: ServeOptions): Promise { const schemasJs = await schemaRuntime(router); // i18n is opt-in by the presence of app/locales/*.json. - const localeMessages = loadLocales(join(appDir, "locales")); + const localeMessages = loadLocales(join(appDir, "locales"), { strict: opts.i18n?.strict }); const i18n = Object.keys(localeMessages).length ? resolveI18n(localeMessages, opts.i18n) : undefined; @@ -369,6 +410,34 @@ export async function startServer(opts: ServeOptions): Promise { }) : undefined; const middleware = middlewareLoader(router); + const toolbarPlatform = { + wrnexus060: { + clientFunctions: true, + serverFunctions: true, + sharedFunctions: true, + typedOutputs: true, + requestScopedStores: true, + }, + plugins: pluginRunner.plugins.map((plugin) => ({ name: plugin.name, version: plugin.version })), + runtimes: pluginContributions.clientRuntimes.map((runtime) => ({ + id: runtime.id, + publicPath: runtime.publicPath, + type: runtime.type, + load: runtime.load, + })), + assets: pluginContributions.assets.map((asset) => ({ + id: asset.id, + publicPath: asset.publicPath, + contentType: asset.contentType, + })), + componentDirs, + styles: pluginContributions.styles, + routes: { + pages: router.pages.length, + api: router.api.length, + realtime: router.realtime.length, + }, + }; const runtimeDeps = { mode, @@ -392,13 +461,14 @@ export async function startServer(opts: ServeOptions): Promise { clientRuntimes: pluginContributions.clientRuntimes, hub, realtimeBus: realtimeBusFromConfig(opts.realtime), + renderHtml: (html: string) => pluginRunner.render(html), devToolbar: devToolbarConfig && devToolbarCollector ? { config: devToolbarConfig, collector: devToolbarCollector, root: appRoot, - panels: [ + panels: () => [ { id: "runtime", title: "Runtime", @@ -415,39 +485,23 @@ export async function startServer(opts: ServeOptions): Promise { "Global/page stores, safe client state, computed values, actions, persistence, and hydration.", order: 20, }, + { + id: "cache", + title: "Cache", + icon: "layers", + description: + "Request, data, component, and page cache entries plus hit/miss history.", + order: 30, + }, + ...builtinDevToolbarPanels({ + root: appRoot, + platform: toolbarPlatform, + database: getDbPerformanceSnapshot(), + version: { current: "0.8.0" }, + }), ...pluginToolbarPanels, ], - platform: { - wrnexus060: { - clientFunctions: true, - serverFunctions: true, - sharedFunctions: true, - typedOutputs: true, - requestScopedStores: true, - }, - plugins: pluginRunner.plugins.map((plugin) => ({ - name: plugin.name, - version: plugin.version, - })), - runtimes: pluginContributions.clientRuntimes.map((runtime) => ({ - id: runtime.id, - publicPath: runtime.publicPath, - type: runtime.type, - load: runtime.load, - })), - assets: pluginContributions.assets.map((asset) => ({ - id: asset.id, - publicPath: asset.publicPath, - contentType: asset.contentType, - })), - componentDirs, - styles: pluginContributions.styles, - routes: { - pages: router.pages.length, - api: router.api.length, - realtime: router.realtime.length, - }, - }, + platform: toolbarPlatform, } : undefined, }; @@ -501,6 +555,7 @@ export async function startServer(opts: ServeOptions): Promise { hostname, development: mode === "development", maxRequestBodySize: 10 * 1024 * 1024, + ...(opts.tls ? { tls: opts.tls } : {}), fetch(request, server) { if (new URL(request.url).pathname === "/__wrnexus/rpc") return rpcHandler(request); return handlers.fetch(request, server); @@ -551,13 +606,21 @@ export async function startServer(opts: ServeOptions): Promise { assets.updateSchemas(await schemaRuntime(router)); } if (appFiles.some((file) => file === "locales" || file.startsWith("locales/"))) { - const messages = loadLocales(join(appDir, "locales")); - runtimeDeps.i18n = Object.keys(messages).length - ? resolveI18n(messages, opts.i18n) - : undefined; + try { + const messages = loadLocales(join(appDir, "locales"), { strict: opts.i18n?.strict }); + runtimeDeps.i18n = Object.keys(messages).length + ? resolveI18n(messages, opts.i18n) + : undefined; + } catch (error) { + console.warn( + "[wrnexus] locale hot update was incomplete; keeping the last valid bundle", + error, + ); + } } console.log(`[wrnexus] hot update — ${files.join(", ")}`); + await pluginRunner.hook("hmrUpdate", files); const storeUpdates: Array<{ name: string; url: string; kind: string }> = []; for (const changed of files) { const absolute = isAbsolute(changed) ? changed : resolve(appDir, changed); @@ -634,6 +697,7 @@ export async function startServer(opts: ServeOptions): Promise { watcher?.close(); unsubscribeDevToolbar?.(); server.stop(); + void pluginRunner.hook("shutdown"); }, }; } diff --git a/packages/dev-server/src/partial-build.ts b/packages/dev-server/src/partial-build.ts new file mode 100644 index 00000000..a3fbc414 --- /dev/null +++ b/packages/dev-server/src/partial-build.ts @@ -0,0 +1,73 @@ +import { partialPrerender } from "@wrnexus/ssr"; +import { + fillSlots, + normalizeComponentName, + parseComponentProps, + readElementBody, +} from "./runtime.ts"; + +export interface PartialBuildModule { + default?: unknown; + render?: (props?: Record) => string | Promise; + layout?: string | { name?: string; render?: (props?: Record) => string }; + __wrnexusBuildStaticShell?: (ctx?: Record) => string | Promise; +} + +export interface PartialBuildEntry { + name: string; + mod: PartialBuildModule; +} + +const MOUNT_OPEN_RE = + /<([A-Za-z][A-Za-z0-9-]*)\b([^>]*?\bdata-component="([A-Za-z0-9_-]+)"[^>]*?)(\/?)>/; + +/** Expand compiler component mounts at build time using only their pure render exports. */ +export async function expandStaticComponents( + html: string, + components: readonly PartialBuildEntry[], + depth = 0, +): Promise { + if (depth > 15) throw new Error("WRN-PARTIAL-STATIC-DEPTH: component nesting exceeds 15"); + if (!html.includes("data-component=")) return html; + let output = ""; + let cursor = 0; + for (;;) { + const match = MOUNT_OPEN_RE.exec(html.slice(cursor)); + if (!match) return output + html.slice(cursor); + const start = cursor + match.index; + output += html.slice(cursor, start); + const [open, tag, attributes, name, selfClosing] = match; + const openEnd = start + open.length; + const body = + selfClosing === "/" ? { inner: "", end: openEnd } : readElementBody(html, tag!, openEnd); + const component = components.find( + (entry) => normalizeComponentName(entry.name) === normalizeComponentName(name!), + ); + if (!component || typeof component.mod.render !== "function") { + throw new Error(`WRN-PARTIAL-STATIC-COMPONENT: '${name}' has no build-time renderer`); + } + const rendered = await component.mod.render(parseComponentProps(attributes!)); + output += await expandStaticComponents( + fillSlots(String(rendered), body.inner), + components, + depth + 1, + ); + cursor = body.end; + } +} + +/** Produce the body shell stored in dist; dynamic region bodies are never evaluated here. */ +export async function precomputePartialStaticShell( + page: PartialBuildModule, + components: readonly PartialBuildEntry[], +): Promise<{ shell: string; regions: number }> { + if (typeof page.__wrnexusBuildStaticShell !== "function") { + throw new Error("WRN-PARTIAL-STATIC-EXPORT: compiler did not emit a static-shell renderer"); + } + const body = await expandStaticComponents( + String(await page.__wrnexusBuildStaticShell({})), + components, + ); + const result = partialPrerender(body); + return { shell: result.shell, regions: result.regions.length }; +} diff --git a/packages/dev-server/src/pipeline.ts b/packages/dev-server/src/pipeline.ts index b7c2af30..8eb6f376 100644 --- a/packages/dev-server/src/pipeline.ts +++ b/packages/dev-server/src/pipeline.ts @@ -15,7 +15,13 @@ import { existsSync, } from "node:fs"; import { dirname, join, basename, extname, resolve } from "node:path"; -import { compile, generateTargets, resolveWrnImports, type PageAst } from "@wrnexus/compiler"; +import { + compile, + generate, + generateTargets, + resolveWrnImports, + type PageAst, +} from "@wrnexus/compiler"; import type { Context, Middleware } from "@wrnexus/core"; /** @@ -60,6 +66,18 @@ interface CompileImportOptions { const compileImportOptions = new Map(); const warnedImportDiagnostics = new Set(); +interface DevCompilerPipeline { + transformAst(ast: PageAst, file: string): Promise; + transformCode(code: string, file: string): Promise; + virtualModules: Map; +} +let devCompilerPipeline: DevCompilerPipeline | null = null; + +/** Install the configured plugin compiler pipeline for development compilation. */ +export function setDevCompilerPipeline(pipeline: DevCompilerPipeline | null): void { + devCompilerPipeline = pipeline; +} + export function setCompileImportOptions( appRoot: string, options: { mode?: ImportMode; aliases?: Record; autoImport?: boolean } = {}, @@ -142,34 +160,93 @@ function rewriteArtifactImports( return output; } -export function loadModule(file: string): Promise> { +async function rewriteArtifactImportsAsync( + code: string, + ast: PageAst, + importer: string, + target: "main" | "server" | "browser", +): Promise { + let output = code; + for (const [id, replacement] of devCompilerPipeline?.virtualModules ?? []) { + const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + output = output.replace( + new RegExp(`(["'])${escaped}\\1`, "g"), + JSON.stringify(pathToFileURL(replacement).href), + ); + } + if (!ast.structuredImports.length) return output; + const root = projectRootForFile(importer); + const importOptions = compileImportOptions.get(resolve(root)) ?? { + mode: "compatible" as const, + aliases: { "@": "./app" }, + autoImport: true, + }; + const resolved = resolveWrnImports(ast.structuredImports, importer, { + appRoot: root, + mode: importOptions.mode, + aliases: importOptions.aliases, + }); + for (const entry of resolved) { + if (entry.diagnostic?.severity === "error") throw new Error(entry.diagnostic.message); + if (!entry.resolved || !entry.declaration.source) continue; + if (!entry.declaration.source.startsWith(".") && !entry.declaration.source.startsWith("@/")) + continue; + let replacement = entry.resolved; + if (replacement.endsWith(".wrn")) { + const dependencySource = readFileSync(replacement, "utf8"); + const isStore = /\b(?:global|page)\s+store\s+[A-Za-z_$][\w$]*\s*\{/.test(dependencySource); + const dependency = await compileWireArtifactsAsync( + replacement, + moduleVersions.get(replacement) ?? 0, + ); + if (target === "browser") { + if (!isStore) { + output = output.replace(entry.declaration.raw, ""); + continue; + } + replacement = `/__wrnexus/client/${basename(dependency.browser).replace(/\.client\.mjs$/, ".mjs")}`; + } else replacement = target === "server" ? dependency.server : dependency.main; + } + const escaped = entry.declaration.source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const specifier = replacement.startsWith("/") ? replacement : pathToFileURL(replacement).href; + output = output.replace(new RegExp(`(["'])${escaped}\\1`, "g"), JSON.stringify(specifier)); + } + return output; +} + +export async function loadModule(file: string): Promise> { let mod = moduleCache.get(file); if (!mod) { - const version = moduleVersions.get(file) ?? 0; - // `.wrn` files are compiled to TypeScript first, then imported. - let target = file.endsWith(".wrn") ? compileWireArtifacts(file, version).main : file; - let temporary = false; - // Bun intentionally caches local TS/JS modules by filesystem path and ignores - // URL query strings. A short-lived versioned sibling keeps relative imports - // correct while giving the changed module a genuinely new import identity. - if (version && !file.endsWith(".wrn")) { - const extension = extname(file); - const stem = basename(file, extension); - target = join(dirname(file), `${stem}.wrnexus-hmr-${version}${extension}`); - copyFileSync(file, target); - temporary = true; - } - // pathToFileURL handles Windows drive letters and spaces correctly. - mod = import(pathToFileURL(target).href) as Promise>; - if (temporary) { - mod = mod.finally(() => { - try { - unlinkSync(target); - } catch { - /* best-effort cleanup after Bun has loaded the module */ - } - }); - } + mod = (async () => { + const version = moduleVersions.get(file) ?? 0; + // `.wrn` files are compiled to TypeScript first, then imported. + let target = file.endsWith(".wrn") + ? (await compileWireArtifactsAsync(file, version)).main + : file; + let temporary = false; + // Bun intentionally caches local TS/JS modules by filesystem path and ignores + // URL query strings. A short-lived versioned sibling keeps relative imports + // correct while giving the changed module a genuinely new import identity. + if (version && !file.endsWith(".wrn")) { + const extension = extname(file); + const stem = basename(file, extension); + target = join(dirname(file), `${stem}.wrnexus-hmr-${version}${extension}`); + copyFileSync(file, target); + temporary = true; + } + // pathToFileURL handles Windows drive letters and spaces correctly. + let imported = import(pathToFileURL(target).href) as Promise>; + if (temporary) { + imported = imported.finally(() => { + try { + unlinkSync(target); + } catch { + /* best-effort cleanup after Bun has loaded the module */ + } + }); + } + return imported; + })(); moduleCache.set(file, mod); } return mod; @@ -228,7 +305,18 @@ function validateConfiguredImports(source: string, ast: PageAst, file: string): const usedComponents = new Set( Array.from(source.matchAll(/<([A-Z][A-Za-z0-9_$]*)\b/g), (match) => match[1]!), ); - const missing = [...usedComponents].filter((name) => !imported.has(name)); + const compilerBuiltins = new Set([ + "Async", + "Component", + "Error", + "Loading", + "Portal", + "Success", + "Transition", + ]); + const missing = [...usedComponents].filter( + (name) => !compilerBuiltins.has(name) && !imported.has(name), + ); if (ast.layoutIsSymbol && ast.layout && !imported.has(ast.layout)) missing.push(ast.layout); if (!missing.length) return; const unique = [...new Set(missing)]; @@ -250,6 +338,98 @@ export interface WrnCompileArtifacts { rpc: string; } +export interface WrnCompileMetrics { + hits: number; + misses: number; + compilations: number; + errors: number; + totalDurationMs: number; + lastDurationMs: number; +} + +const compileMetrics: WrnCompileMetrics = { + hits: 0, + misses: 0, + compilations: 0, + errors: 0, + totalDurationMs: 0, + lastDurationMs: 0, +}; +const asyncCompileInProgress = new Map>(); + +export function compileWireArtifactsAsync(file: string, version = 0): Promise { + if (!devCompilerPipeline) return Promise.resolve(compileWireArtifacts(file, version)); + const key = `${file}:${version}`; + const active = asyncCompileInProgress.get(key); + if (active) return active; + const task = (async () => { + const cacheDir = compileCacheDir ?? join(dirname(file), ".wrnexus"); + const name = basename(file).replace(/\.wrn$/, ""); + const suffix = version ? `-hmr-${version}` : ""; + const source = readFileSync(file, "utf8"); + // Plugin output affects the artifact, so use a separate cache generation. + const stem = `${name}-${WRN_COMPILE_CACHE_VERSION}-plugin-${hashPath(file)}-${hashPath(source)}${suffix}`; + const artifacts: WrnCompileArtifacts = { + main: join(cacheDir, `${stem}.wrn.ts`), + browser: join(cacheDir, `${stem}.client.mjs`), + server: join(cacheDir, `${stem}.server.ts`), + declarations: join(cacheDir, `${stem}.d.ts`), + contract: join(cacheDir, `${stem}.contract.json`), + rpc: join(cacheDir, `${stem}.rpc.json`), + }; + const result = compile(source, file); + validateConfiguredImports(source, result.ast, file); + const ast = await devCompilerPipeline!.transformAst(result.ast, file); + const targets = generateTargets(ast); + mkdirSync(cacheDir, { recursive: true }); + const browserPath = `/__wrnexus/client/${stem}.mjs`; + const outputs = { + main: `// compiled from .wrn\n${generate(ast)}`.replaceAll( + "__WRNEXUS_CLIENT_MODULE__", + browserPath, + ), + browser: targets.browser, + server: targets.server, + declarations: targets.declarations, + }; + for (const target of ["main", "browser", "server"] as const) { + const rewritten = await rewriteArtifactImportsAsync(outputs[target], ast, file, target); + writeFileSync( + artifacts[target], + await devCompilerPipeline!.transformCode(rewritten, file), + "utf8", + ); + } + writeFileSync( + artifacts.declarations, + await devCompilerPipeline!.transformCode(outputs.declarations, file), + "utf8", + ); + writeFileSync(artifacts.contract, JSON.stringify(targets.contract, null, 2) + "\n", "utf8"); + writeFileSync(artifacts.rpc, JSON.stringify(targets.rpc, null, 2) + "\n", "utf8"); + browserArtifactPaths.set(browserPath, artifacts.browser); + compileMetrics.compilations++; + return artifacts; + })().finally(() => asyncCompileInProgress.delete(key)); + asyncCompileInProgress.set(key, task); + return task; +} + +export function getWrnCompileMetrics(): Readonly { + return { ...compileMetrics }; +} + +export function resetWrnCompileMetrics(): void { + Object.assign(compileMetrics, { + hits: 0, + misses: 0, + compilations: 0, + errors: 0, + totalDurationMs: 0, + lastDurationMs: 0, + }); +} + export function compileWireArtifacts(file: string, version = 0): WrnCompileArtifacts { const active = compileInProgress.get(file); if (active) return active; @@ -270,39 +450,52 @@ export function compileWireArtifacts(file: string, version = 0): WrnCompileArtif try { try { if (Object.values(artifacts).every((path) => statSync(path).isFile())) { + compileMetrics.hits++; browserArtifactPaths.set(`/__wrnexus/client/${stem}.mjs`, artifacts.browser); return artifacts; } } catch { // Compile missing artifact set below. } - const result = compile(source, file); - validateConfiguredImports(source, result.ast, file); - const targets = generateTargets(result.ast); - mkdirSync(cacheDir, { recursive: true }); - const browserPath = `/__wrnexus/client/${stem}.mjs`; - const mainCode = rewriteArtifactImports( - result.code.replaceAll("__WRNEXUS_CLIENT_MODULE__", browserPath), - result.ast, - file, - "main", - ); - writeFileSync(artifacts.main, mainCode, "utf8"); - writeFileSync( - artifacts.browser, - rewriteArtifactImports(targets.browser, result.ast, file, "browser"), - "utf8", - ); - browserArtifactPaths.set(browserPath, artifacts.browser); - writeFileSync( - artifacts.server, - rewriteArtifactImports(targets.server, result.ast, file, "server"), - "utf8", - ); - writeFileSync(artifacts.declarations, targets.declarations, "utf8"); - writeFileSync(artifacts.contract, JSON.stringify(targets.contract, null, 2) + "\n", "utf8"); - writeFileSync(artifacts.rpc, JSON.stringify(targets.rpc, null, 2) + "\n", "utf8"); - return artifacts; + compileMetrics.misses++; + const started = performance.now(); + try { + const result = compile(source, file); + validateConfiguredImports(source, result.ast, file); + const targets = generateTargets(result.ast); + mkdirSync(cacheDir, { recursive: true }); + const browserPath = `/__wrnexus/client/${stem}.mjs`; + const mainCode = rewriteArtifactImports( + result.code.replaceAll("__WRNEXUS_CLIENT_MODULE__", browserPath), + result.ast, + file, + "main", + ); + writeFileSync(artifacts.main, mainCode, "utf8"); + writeFileSync( + artifacts.browser, + rewriteArtifactImports(targets.browser, result.ast, file, "browser"), + "utf8", + ); + browserArtifactPaths.set(browserPath, artifacts.browser); + writeFileSync( + artifacts.server, + rewriteArtifactImports(targets.server, result.ast, file, "server"), + "utf8", + ); + writeFileSync(artifacts.declarations, targets.declarations, "utf8"); + writeFileSync(artifacts.contract, JSON.stringify(targets.contract, null, 2) + "\n", "utf8"); + writeFileSync(artifacts.rpc, JSON.stringify(targets.rpc, null, 2) + "\n", "utf8"); + compileMetrics.compilations++; + return artifacts; + } catch (error) { + compileMetrics.errors++; + throw error; + } finally { + const duration = performance.now() - started; + compileMetrics.lastDurationMs = duration; + compileMetrics.totalDurationMs += duration; + } } finally { compileInProgress.delete(file); } diff --git a/packages/dev-server/src/plugin-assets.ts b/packages/dev-server/src/plugin-assets.ts index 195aadad..da9a7244 100644 --- a/packages/dev-server/src/plugin-assets.ts +++ b/packages/dev-server/src/plugin-assets.ts @@ -153,5 +153,12 @@ export function mergePluginAssets( routes: [], middleware: [], migrations: [], + directives: [], + cliCommands: [], + virtualModules: [], + deploymentAdapters: [], + configSchemas: [], + documentation: [], + typeDefinitions: [], }); } diff --git a/packages/dev-server/src/prod.ts b/packages/dev-server/src/prod.ts index 0f982fb1..b0eb24b3 100644 --- a/packages/dev-server/src/prod.ts +++ b/packages/dev-server/src/prod.ts @@ -5,7 +5,8 @@ * `wrnexus build` generates an entry that statically imports every route and * component module and hands them here as a manifest. We rebuild the (cheap) * route-matching tables from the raw patterns and run the exact same request - * runtime as dev — just with production error pages and no live-reload client. + * runtime as dev. Normal preview/deploy output has no live-reload client; the + * supervised `dev --production-runtime` mode can explicitly enable it. */ import type { Middleware, Mode, SecurityConfig, SeoConfig } from "@wrnexus/core"; @@ -16,7 +17,12 @@ import { type Route, type Router, } from "@wrnexus/router"; -import { getReactiveRuntime, getNavRuntime, getRealtimeRuntime } from "@wrnexus/csr"; +import { + getActionRuntime, + getReactiveRuntime, + getNavRuntime, + getRealtimeRuntime, +} from "@wrnexus/csr"; import { loadEnv, resolveProfile, @@ -43,6 +49,7 @@ import { realtimeBusFromConfig } from "./realtime-bus.ts"; import { createHandlers, type AssetServer, type WsData } from "./runtime.ts"; import { servePublicAsset } from "./public.ts"; import type { ClientRuntimeDefinition } from "@wrnexus/plugin"; +import { HmrHub } from "./hmr.ts"; type RouteModule = Record; @@ -51,6 +58,8 @@ export interface ManifestRoute { raw: string; /** The statically-imported route module. */ mod: RouteModule; + /** Body shell precomputed by `wrnexus build` for a partial-static page. */ + staticShell?: string; } export interface ProdManifest { @@ -139,6 +148,8 @@ export interface ProdOptions { port?: number; hostname?: string; maxBodyBytes?: number; + /** Enable only for the CLI's supervised exact-production development mode. */ + developmentRuntime?: boolean; } const MODE: Mode = "production"; @@ -181,7 +192,10 @@ function buildProdRouter(manifest: ProdManifest): { const routes = entries.map((e): Route => { const { regex, paramNames } = compileRoutePattern(e.raw); // Use the raw pattern as a stable module key. - modules.set(e.raw, e.mod); + modules.set( + e.raw, + e.staticShell === undefined ? e.mod : { ...e.mod, __wrnexusStaticShell: e.staticShell }, + ); return { raw: e.raw, file: e.raw, regex, paramNames }; }); return sortRoutes(routes); @@ -236,6 +250,8 @@ function createProdAssetServer(opts: ProdOptions): AssetServer { return new Response(getNavRuntime(), { headers: JS_HEADERS }); if (pathname === "/__wrnexus/realtime.js") return new Response(getRealtimeRuntime(), { headers: JS_HEADERS }); + if (pathname === "/__wrnexus/actions.js") + return new Response(getActionRuntime(), { headers: JS_HEADERS }); if (pathname === "/__wrnexus/validate.js") return new Response(VALIDATE_RUNTIME, { headers: JS_HEADERS }); if (pathname === "/__wrnexus/i18n.js") @@ -308,9 +324,11 @@ export function createProductionHandlers( return mod; }; + const productionHmr = opts.developmentRuntime === true; const handlers = createHandlers({ mode: MODE, - hmr: false, + hmr: productionHmr, + hub: productionHmr ? new HmrHub() : undefined, router, loadModule, getMiddleware, diff --git a/packages/dev-server/src/runtime.ts b/packages/dev-server/src/runtime.ts index e1d559a2..7da289d0 100644 --- a/packages/dev-server/src/runtime.ts +++ b/packages/dev-server/src/runtime.ts @@ -14,6 +14,8 @@ import { createCorsPreflightResponse, createRealtimeRegistry, csrfToken, + verifyCsrf, + escapeHtml, etag, isRoomDefinition, isWebSocketOriginAllowed, @@ -25,7 +27,7 @@ import { withSecurityHeaders, resolveRequestUrl, tenantMiddleware, - tracingMiddleware, + HealthRegistry, type Context, type Middleware, type Mode, @@ -38,13 +40,23 @@ import { } from "@wrnexus/core"; import { requestHardening } from "@wrnexus/security"; import { + createLivenessHandler, + createOtlpTraceExporter, + createReadinessHandler, createWebVitalsHandler, defaultMetrics, metricsMiddleware, + traceMiddleware, webVitalsClient, } from "@wrnexus/observability"; import type { Router } from "@wrnexus/router"; -import { renderDocument, type RenderScript, type ScriptAsset } from "@wrnexus/ssr"; +import { + partialPrerender, + renderDocument, + streamPartialDocument, + type RenderScript, + type ScriptAsset, +} from "@wrnexus/ssr"; import { disposeRequestStores, renderStoreHydration, @@ -52,6 +64,8 @@ import { } from "@wrnexus/ssr/store-context"; import type { StoreDefinition } from "@wrnexus/store"; import type { ClientRuntimeDefinition } from "@wrnexus/plugin"; +import { CacheCoordinator } from "@wrnexus/cache"; +import { generateServiceWorker } from "@wrnexus/pwa"; import { runtimeScriptsForMarkup } from "./plugin-assets.ts"; import { ACCENT_COOKIE, @@ -67,8 +81,8 @@ import { type TenancyConfig, } from "@wrnexus/styles"; import { - LANG_COOKIE, I18N_JS_HREF, + renderI18nData, makeT, resolveLang, translateHtml, @@ -106,6 +120,12 @@ export type WsData = type RouteModule = Record; type ApiRegistry = Record; +interface ActionEntry { + run: (input: unknown, ctx: Context) => unknown | Promise; + schema?: { + parse(input: unknown): { ok: boolean; value: unknown; errors: Record }; + }; +} interface CsrBinding { id: string; method?: string; @@ -161,6 +181,8 @@ export interface RuntimeDeps { security?: SecurityConfig; /** Built-in request tracing and Server-Timing policy. */ observability?: ObservabilityConfig; + /** Dependency health checks used by `/readyz` and `/__wrnexus/ready`. */ + health?: HealthRegistry; /** Built-in tenant identity resolution. */ tenancy?: TenancyConfig; /** Max request body size in bytes (413 above this). Default 10 MB. */ @@ -173,13 +195,17 @@ export interface RuntimeDeps { * bus (use the Redis pub/sub driver). Enables realtime across multiple apps. */ realtimeBus?: RealtimeBus; + /** Shared first-class data/component/page caches. */ + cache?: CacheCoordinator; + /** Final document transform supplied by the plugin render lifecycle. */ + renderHtml?: (html: string) => string | Promise; devToolbar?: { config: DevToolbarConfig; collector: DevToolbarCollector; root: string; platform?: DevToolbarPlatformSnapshot; - panels?: DevToolbarPanel[]; + panels?: DevToolbarPanel[] | (() => DevToolbarPanel[] | Promise); }; } @@ -230,24 +256,34 @@ function frameworkMiddleware(deps: RuntimeDeps): Middleware[] { if (deps.observability && deps.observability.enabled !== false) { middleware.push(metricsMiddleware({ registry: defaultMetrics, includePath: false })); + const traceExporter = + deps.observability.exporter === "otlp" && deps.observability.endpoint + ? createOtlpTraceExporter(deps.observability.endpoint, { + serviceName: deps.observability.serviceName, + }) + : undefined; middleware.push( - tracingMiddleware(undefined, { + traceMiddleware({ + serviceName: deps.observability.serviceName, sampleRate: deps.observability.sampleRate, serverTiming: deps.observability.serverTiming, - onComplete: + exporter: traceExporter, + onSpan: deps.observability.exporter === "console" - ? (ctx, records) => { - const total = records.find((record) => record.name === "http.request")?.durationMs; + ? (span) => { console.log( - `[wrnexus:trace] ${ctx.req.method} ${ctx.url.pathname} ${total?.toFixed(2) ?? "0.00"}ms`, + `[wrnexus:trace] ${span.name} ${span.durationMs.toFixed(2)}ms trace=${span.traceId}`, ); } : undefined, + onExportError(error) { + console.error("[wrnexus:trace] export failed", error); + }, }), ); } - if (deps.tenancy && deps.tenancy.mode !== "custom") { + if (deps.tenancy && Object.keys(deps.tenancy).length > 0 && deps.tenancy.mode !== "custom") { middleware.push( tenantMiddleware(tenantIdentityFromConfig(deps.tenancy), { required: deps.tenancy.required, @@ -277,30 +313,20 @@ export const PWA_CLIENT = `if ("serviceWorker" in navigator) { addEventListener("load", () => navigator.serviceWorker.register(swUrl).catch(() => {})); }`; +export const PWA_DEV_CLEANUP_CLIENT = `if ("serviceWorker" in navigator && !sessionStorage.getItem("wrnexus-pwa-dev-cleaned")) { + sessionStorage.setItem("wrnexus-pwa-dev-cleaned", "1"); + navigator.serviceWorker.getRegistrations().then(function (registrations) { + return Promise.all(registrations.filter(function (registration) { + return new URL(registration.active?.scriptURL || registration.installing?.scriptURL || registration.waiting?.scriptURL || location.origin, location.origin).pathname === "/sw.js"; + }).map(function (registration) { return registration.unregister(); })); + }).catch(function () {}); + if (window.caches) caches.keys().then(function (keys) { + return Promise.all(keys.filter(function (key) { return key.indexOf("wrnexus-pwa-") === 0; }).map(function (key) { return caches.delete(key); })); + }).catch(function () {}); +}`; + function renderPwaServiceWorker(pwa: PwaConfig): string { - const offlineUrl = pwa.offlineUrl ?? pwa.startUrl ?? "/"; - const cacheUrls = [...new Set([offlineUrl, ...(pwa.cacheUrls ?? [])])]; - return `const CACHE = ${JSON.stringify(pwa.cacheName ?? "wrnexus-pwa-v1")}; -const OFFLINE_URL = ${JSON.stringify(offlineUrl)}; -const PRECACHE_URLS = ${JSON.stringify(cacheUrls)}; -self.addEventListener("install", event => { - event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(PRECACHE_URLS)).catch(() => {})); - self.skipWaiting(); -}); -self.addEventListener("activate", event => event.waitUntil( - caches.keys().then(keys => Promise.all(keys.filter(key => key.startsWith("wrnexus-pwa-") && key !== CACHE).map(key => caches.delete(key)))) - .then(() => self.clients.claim()) -)); -self.addEventListener("fetch", event => { - if (event.request.method !== "GET" || event.request.mode !== "navigate") return; - event.respondWith(fetch(event.request).then(response => { - if (response.ok) { - const copy = response.clone(); - caches.open(CACHE).then(cache => cache.put(event.request, copy)); - } - return response; - }).catch(() => caches.match(event.request).then(hit => hit || caches.match(OFFLINE_URL)))); -});`; + return generateServiceWorker(pwa); } const DEFAULT_PWA_ICON = ` @@ -825,6 +851,20 @@ export interface Handlers { /** Build the fetch + websocket handlers from a set of dependencies. */ export function createHandlers(deps: RuntimeDeps): Handlers { const { mode, hmr, router, loadModule, getMiddleware, assets } = deps; + const cache = + deps.cache ?? + new CacheCoordinator({ + onEvent: (event) => { + if (mode === "development") + console.debug( + `[wrnexus:cache] ${event.layer} ${event.operation}${event.key ? ` ${event.key}` : ""}`, + ); + }, + }); + const initializeRequestCache = (ctx: Context): void => { + ctx.locals.cache = cache; + ctx.locals.requestCache ??= cache.request(); + }; const builtInMiddleware = frameworkMiddleware(deps); const resolveMiddleware = async (): Promise => [ ...builtInMiddleware, @@ -867,11 +907,15 @@ export function createHandlers(deps: RuntimeDeps): Handlers { const extraHead = headParts.join("\n ") || undefined; const pwaEnabled = deps.pwa !== false && deps.pwa?.enabled !== false; const pwaConfig: PwaConfig = deps.pwa && typeof deps.pwa === "object" ? deps.pwa : {}; - const pwaServiceWorkerEnabled = pwaEnabled && pwaConfig.serviceWorker !== false; + const pwaServiceWorkerEnabled = + mode === "production" && pwaEnabled && pwaConfig.serviceWorker !== false; + const pwaDevCleanupEnabled = mode === "development"; const webVitalsEnabled = deps.observability?.enabled !== false && deps.observability?.webVitals === true; const webVitalsEndpoint = deps.observability?.webVitalsEndpoint ?? "/__wrnexus/metrics/vitals"; const webVitalsHandler = createWebVitalsHandler({ registry: defaultMetrics }); + const livenessHandler = createLivenessHandler(); + const readinessHandler = createReadinessHandler(deps.health ?? new HealthRegistry()); const configuredPermissions = deps.security?.permissionsPolicy; const runtimeSecurity: SecurityConfig | undefined = @@ -898,7 +942,10 @@ export function createHandlers(deps: RuntimeDeps): Handlers { // Health check — unauthenticated, skips the middleware pipeline. if (url.pathname === "/healthz" || url.pathname === "/__wrnexus/health") { - return secure(Response.json({ status: "ok" })); + return secure(await livenessHandler(req)); + } + if (url.pathname === "/readyz" || url.pathname === "/__wrnexus/ready") { + return secure(await readinessHandler(req)); } if (webVitalsEnabled && url.pathname === webVitalsEndpoint) { return secure(await webVitalsHandler(req)); @@ -995,6 +1042,16 @@ export function createHandlers(deps: RuntimeDeps): Handlers { }), ); } + if (url.pathname === "/__wrnexus/pwa-dev-cleanup.js" && pwaDevCleanupEnabled) { + return secure( + new Response(PWA_DEV_CLEANUP_CLIENT, { + headers: { + "content-type": "text/javascript; charset=utf-8", + "cache-control": "no-store", + }, + }), + ); + } if (url.pathname === "/__wrnexus/mobile.js" && deps.mobile?.enabled !== false) { return secure( new Response(MOBILE_CLIENT, { @@ -1081,13 +1138,26 @@ export function createHandlers(deps: RuntimeDeps): Handlers { try { const ctx = createContext(req, url); + initializeRequestCache(ctx); ctx.ip = server.requestIP?.(req)?.address ?? undefined; ctx.locals.cspNonce = nonce; // available to pages for their own inline scripts + if (!["GET", "HEAD", "OPTIONS"].includes(req.method.toUpperCase())) { + const contentType = req.headers.get("content-type") ?? ""; + if (contentType.includes("form")) { + try { + const form = await req.clone().formData(); + const token = form.get("_csrf"); + if (typeof token === "string") ctx.locals._csrf = token; + } catch { + // The endpoint will return its normal malformed-input response. + } + } + } // Resolve the request language so both pages and API can translate. if (deps.i18n) { ctx.lang = resolveLang( deps.i18n, - ctx.cookies.get(LANG_COOKIE), + ctx.cookies.get(deps.i18n.cookie.name), req.headers.get("accept-language"), ); ctx.t = makeT(deps.i18n, ctx.lang); @@ -1125,10 +1195,18 @@ export function createHandlers(deps: RuntimeDeps): Handlers { async function dispatch(ctx: Context): Promise { const { pathname } = ctx.url; + if (pathname === "/__wrnexus/cache") { + if (mode !== "development") return new Response("Not Found", { status: 404 }); + return Response.json(cache.inspect(), { headers: { "cache-control": "no-store" } }); + } + // Framework-owned assets (island chunks, reactive runtime, HMR stream). if (pathname === "/__wrnexus/csr") { return handleCsrBinding(ctx); } + if (pathname === "/__wrnexus/client-load") { + return handleClientLoad(ctx); + } if (pathname.startsWith("/__wrnexus/")) { const res = await assets.serve(pathname); @@ -1186,6 +1264,40 @@ export function createHandlers(deps: RuntimeDeps): Handlers { }); } + async function handleClientLoad(ctx: Context): Promise { + const routePath = ctx.url.searchParams.get("route") ?? ""; + const name = ctx.url.searchParams.get("name") ?? ""; + if ( + !routePath.startsWith("/") || + routePath.startsWith("/__wrnexus/") || + !isSafeRequestPath(routePath) || + !/^[A-Za-z_$][\w$]{0,63}$/.test(name) + ) { + return new Response("Not Found", { status: 404 }); + } + const page = router.matchPage(routePath); + if (!page) return new Response("Not Found", { status: 404 }); + const pageModule = await loadModule(page.route.file); + const load = pageModule.__wrnexusClientLoad; + if (typeof load !== "function") return new Response("Not Found", { status: 404 }); + try { + ctx.params = page.params; + const values = await load(ctx); + if (!values || typeof values !== "object" || !(name in values)) { + return new Response("Not Found", { status: 404 }); + } + return Response.json( + { data: (values as Record)[name] }, + { headers: { "cache-control": "private, no-store" } }, + ); + } catch { + return Response.json( + { error: { code: "CLIENT_LOAD_FAILED", message: "Client data loading failed." } }, + { status: 500, headers: { "cache-control": "private, no-store" } }, + ); + } + } + async function callApiFromContext(ctx: Context, path: string, method = "GET"): Promise { if (!isSafeApiPath(path)) { throw new Error("Unsafe framework API path"); @@ -1283,6 +1395,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers { async function renderComponents( body: string, translate: TFunction = (key) => key, + language?: string, depth = 0, ): Promise { if (depth > 15 || router.components.length === 0 || !body.includes("data-component=")) { @@ -1328,8 +1441,37 @@ export function createHandlers(deps: RuntimeDeps): Handlers { // Props may carry `{t:key}` i18n markers — resolve them for the active // language before handing them to the component. const props = resolveTProps(parseComponentProps(attrStr!), translate); - const rendered = fillSlots(String(render(props)), inner); - result += await renderComponents(rendered, translate, depth + 1); + if (normalizedName === "languageswitcher" && deps.i18n) { + props.locales ??= JSON.stringify( + deps.i18n.langs.map((locale) => ({ + value: locale, + label: deps.i18n?.labels[locale] ?? locale.toUpperCase(), + shortLabel: locale.split("-")[0]!.toUpperCase(), + })), + ); + props.current ??= + language && deps.i18n.langs.includes(language) ? language : deps.i18n.default; + } + const policy = (mod.__wrnexusCache ?? {}) as Record; + const strategy = policy.strategy?.toLowerCase(); + const renderComponent = () => fillSlots(String(render(props)), inner); + const rendered = + strategy && !["none", "no-store", "request"].includes(strategy) + ? await cache.getOrLoad( + "component", + `${normalizedName}:${language}:${JSON.stringify(props)}:${inner}`, + renderComponent, + { + ttlMs: cacheDuration(policy.ttl, 60_000), + staleWhileRevalidateMs: + strategy === "stale-while-revalidate" + ? cacheDuration(policy.stale ?? policy.ttl, 60_000) + : 0, + tags: cacheList(policy.tags), + }, + ) + : renderComponent(); + result += await renderComponents(rendered, translate, language, depth + 1); } catch (err) { console.error(`[wrnexus] component '${name}' failed to render`, err); deps.devToolbar?.collector.add( @@ -1347,6 +1489,9 @@ export function createHandlers(deps: RuntimeDeps): Handlers { } async function handlePage(ctx: Context): Promise { + // Page rendering is also reached by HMR synchronization and internal + // dispatches, so never rely exclusively on the public fetch initializer. + initializeRequestCache(ctx); const isMobileRequest = ctx.req.headers.get("x-wrnexus-mobile") === "1" || new RegExp(deps.mobile?.userAgent ?? "WrNexusMobile", "i").test( @@ -1378,10 +1523,14 @@ export function createHandlers(deps: RuntimeDeps): Handlers { } // Issue the CSRF token cookie so forms on this page can echo it back. - csrfToken(ctx); + const pageCsrf = csrfToken(ctx); const storeContainer = requestStoreContainer(ctx.req, matched.route.raw); const mod = await loadModule(matched.route.file); + if (ctx.req.method.toUpperCase() === "POST") { + const actionResponse = await handlePageAction(ctx, mod); + if (actionResponse) return actionResponse; + } const component = mod.default; if (typeof component !== "function") { throw new Error(`Page ${matched.route.file} has no default export`); @@ -1389,13 +1538,88 @@ export function createHandlers(deps: RuntimeDeps): Handlers { ctx.params = matched.params; const meta = (mod.meta ?? {}) as PageMeta; + const pageNavigation = (mod.__wrnexusNavigation ?? {}) as { preserve?: string }; + const pageCache = (mod.__wrnexusCache ?? {}) as Record; + const fullPageEnabled = ["page", "full-page"].includes(pageCache.scope?.toLowerCase() ?? ""); + const fullPageKey = `page:${matched.route.raw}:${ctx.url.search}:${cacheIdentity(ctx, [ + "language", + `cookie:${THEME_COOKIE}`, + `cookie:${ACCENT_COOKIE}`, + ...cacheList(pageCache.vary), + ])}`; + if (fullPageEnabled && ["GET", "HEAD"].includes(ctx.req.method.toUpperCase())) { + const pageHit = cache.page.lookup(fullPageKey); + if (pageHit.state === "fresh") { + const cached = pageHit.entry.value as { html: string; etag: string; nonce: string }; + const restoredHtml = cached.nonce + ? cached.html.replaceAll( + `nonce="${cached.nonce}"`, + `nonce="${String(ctx.locals.cspNonce ?? "")}"`, + ) + : cached.html; + await disposeRequestStores(ctx.req); + return new Response(ctx.req.method.toUpperCase() === "HEAD" ? null : restoredHtml, { + headers: { + "content-type": "text/html; charset=utf-8", + etag: cached.etag, + "cache-control": "public, max-age=0, must-revalidate", + "x-wrnexus-page-cache": "HIT", + }, + }); + } + } + let dataCacheState: "HIT" | "STALE" | "MISS" | "BYPASS" = "BYPASS"; + const preserve = (pageNavigation.preserve ?? "") + .match(/(?:scroll|forms|tabs|expanded|filters|pagination|component|workflow)/g) + ?.filter((value, index, values) => values.indexOf(value) === index) + .join(","); const pageCtx = ctx as Context & { __wrnexusCallApi?: (path: string, method?: string) => Promise; __wrnexusUseStore?: (definition: StoreDefinition) => Promise; }; pageCtx.__wrnexusCallApi = (path, method = "GET") => callApiFromContext(ctx, path, method); pageCtx.__wrnexusUseStore = (definition) => storeContainer.use(definition); - let body = await renderComponents(String(await component(pageCtx)), ctx.t); + const load = mod.__wrnexusLoad as ((ctx: Context) => Promise) | undefined; + if (typeof load === "function") { + const strategy = pageCache.strategy?.toLowerCase(); + const cacheEnabled = Boolean(strategy && !["none", "no-store", "request"].includes(strategy)); + let data: unknown; + if (cacheEnabled && ["GET", "HEAD"].includes(ctx.req.method.toUpperCase())) { + const key = `route:${matched.route.raw}:${ctx.url.search}:${cacheIdentity(ctx, cacheList(pageCache.vary))}`; + const lookup = cache.data.lookup(key); + dataCacheState = + lookup.state === "fresh" ? "HIT" : lookup.state === "stale" ? "STALE" : "MISS"; + data = await cache.getOrLoad("data", key, () => load(pageCtx), { + ttlMs: cacheDuration(pageCache.ttl, 60_000), + staleWhileRevalidateMs: + strategy === "stale-while-revalidate" + ? cacheDuration(pageCache.stale ?? pageCache.ttl, 60_000) + : 0, + tags: cacheList(pageCache.tags), + }); + } else { + data = await ( + ctx.locals.requestCache as { + getOrLoad(key: string, loader: () => Promise): Promise; + } + ).getOrLoad(`route:${matched.route.raw}`, () => load(pageCtx)); + } + (pageCtx as Context & { data?: unknown }).data = data; + if (data && typeof data === "object") Object.assign(pageCtx, data); + } + let body = await renderComponents(String(await component(pageCtx)), ctx.t, ctx.lang); + const partial = (mod as { __wrnexusRender?: string }).__wrnexusRender === "partial-static"; + const precomputedShell = (mod as { __wrnexusStaticShell?: unknown }).__wrnexusStaticShell; + const pagePartial = partial ? partialPrerender(body) : undefined; + if (partial) { + body = typeof precomputedShell === "string" ? precomputedShell : (pagePartial?.shell ?? body); + } + if (body.includes("data-wrn-action=")) { + body = body.replace( + /(]*\bdata-wrn-action=(?:"[^"]+"|'[^']+')[^>]*>)/gi, + `$1` : "", pwaEnabled ? `` : "", pwaEnabled ? `` : "", pwaEnabled ? `` : "", @@ -1535,6 +1773,9 @@ export function createHandlers(deps: RuntimeDeps): Handlers { extraBody: [ renderStoreHydration(storeContainer, (ctx.locals.cspNonce as string) ?? undefined), + deps.i18n + ? `${renderI18nData(deps.i18n, language)}` + : "", hmr ? hmrClientTag((ctx.locals.cspNonce as string) ?? "") : "", shouldEnableDevToolbar(mode, deps) ? DEV_TOOLBAR_SCRIPT : "", ] @@ -1544,10 +1785,29 @@ export function createHandlers(deps: RuntimeDeps): Handlers { documentTemplate, styleNonce: (ctx.locals.cspNonce as string | undefined) ?? undefined, }); + if (deps.renderHtml) html = await deps.renderHtml(html); // Conditional GET: hash the page CONTENT (`body`), not the assembled shell — // the shell carries a per-request CSP nonce in dev, which would otherwise make // the ETag change every request. Same content → same ETag → 304 on revalidate. const tag = etag(`${htmlAttrs ?? ""}\n${JSON.stringify(scripts)}\n${body}`); + if ( + fullPageEnabled && + ["GET", "HEAD"].includes(ctx.req.method.toUpperCase()) && + !/name=["']_csrf["']/.test(html) + ) { + cache.page.set( + fullPageKey, + { html, etag: tag, nonce: String(ctx.locals.cspNonce ?? "") }, + { + ttlMs: cacheDuration(pageCache.ttl, 60_000), + staleWhileRevalidateMs: + pageCache.strategy?.toLowerCase() === "stale-while-revalidate" + ? cacheDuration(pageCache.stale ?? pageCache.ttl, 60_000) + : 0, + tags: cacheList(pageCache.tags), + }, + ); + } await disposeRequestStores(ctx.req); const method = ctx.req.method.toUpperCase(); if ((method === "GET" || method === "HEAD") && notModified(ctx.req, tag)) { @@ -1556,18 +1816,132 @@ export function createHandlers(deps: RuntimeDeps): Handlers { headers: { etag: tag, "cache-control": "private, no-cache" }, }); } - return new Response(html, { - headers: { - "content-type": "text/html; charset=utf-8", - etag: tag, - "cache-control": "private, no-cache", - ...(shouldEnableDevToolbar(mode, deps) - ? { - "x-wrnexus-dev-toolbar": "enabled", - "x-wrnexus-route": matched.route.raw, - } - : {}), + return new Response( + partial && method !== "HEAD" + ? streamPartialDocument( + { shell: html, regions: partialRegions }, + (ctx.locals.cspNonce as string | undefined) ?? undefined, + ) + : html, + { + headers: { + "content-type": "text/html; charset=utf-8", + etag: tag, + "cache-control": "private, no-cache", + "x-wrnexus-data-cache": dataCacheState, + ...(partial ? { "x-wrnexus-render": "partial-static" } : {}), + ...(partial && typeof precomputedShell === "string" + ? { "x-wrnexus-static-shell": "build" } + : {}), + ...(fullPageEnabled ? { "x-wrnexus-page-cache": "MISS" } : {}), + ...(shouldEnableDevToolbar(mode, deps) + ? { + "x-wrnexus-dev-toolbar": "enabled", + "x-wrnexus-route": matched.route.raw, + } + : {}), + }, }, + ); + } + + async function actionInput(request: Request): Promise<{ name?: string; input: unknown }> { + const contentType = request.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + return { + name: request.headers.get("x-wrnexus-action") ?? undefined, + input: await request.json(), + }; + } + const form = await request.formData(); + const input: Record = {}; + for (const [key, value] of form) { + if (key === "_wrnexus_action" || key === "_csrf") continue; + if (!(key in input)) input[key] = value; + else + input[key] = Array.isArray(input[key]) + ? [...(input[key] as unknown[]), value] + : [input[key], value]; + } + const submittedName = form.get("_wrnexus_action"); + return { + name: + request.headers.get("x-wrnexus-action") ?? + (typeof submittedName === "string" ? submittedName : undefined), + input, + }; + } + + async function handlePageAction(ctx: Context, mod: RouteModule): Promise { + const actions = mod.__wrnexusActions as Record | undefined; + if (!actions) return null; + let submitted: Awaited>; + try { + submitted = await actionInput(ctx.req); + } catch { + return Response.json({ error: "Malformed action input" }, { status: 400 }); + } + if (!submitted.name) return null; + if (!/^[A-Za-z_$][\w$]*$/.test(submitted.name) || !actions[submitted.name]) { + return Response.json({ error: "Unknown server action" }, { status: 404 }); + } + const security = (mod.__wrnexusSecurity ?? {}) as Record; + if (/^(?:required|true)$/i.test(security.auth ?? "") && !ctx.user) { + return Response.json({ error: "Authentication required" }, { status: 401 }); + } + if (security.permission) { + const permissions = ctx.locals.permissions; + const allowed = + typeof permissions === "function" + ? await permissions(security.permission, ctx) + : Array.isArray(permissions) && permissions.includes(security.permission); + if (!allowed) return Response.json({ error: "Permission denied" }, { status: 403 }); + } + if (security.csrf !== "false" && !verifyCsrf(ctx)) { + return Response.json({ error: "Invalid CSRF token" }, { status: 403 }); + } + const action = actions[submitted.name]!; + let input = submitted.input; + if (action.schema) { + const parsed = action.schema.parse(input); + if (!parsed.ok) { + const acceptsJson = (ctx.req.headers.get("accept") ?? "").includes("application/json"); + if (acceptsJson) + return Response.json( + { error: "Validation failed", errors: parsed.errors }, + { status: 422 }, + ); + const errors = Object.entries(parsed.errors) + .map( + ([field, message]) => + `
  • ${escapeHtml(field)}: ${escapeHtml(message)}
  • `, + ) + .join(""); + return new Response( + `Validation failed

    Validation failed

      ${errors}
    Go back`, + { + status: 422, + headers: { "content-type": "text/html; charset=utf-8" }, + }, + ); + } + input = parsed.value; + } + const data = await action.run(input, ctx); + const invalidated = [ + ...new Set( + Array.isArray(ctx.locals.__wrnexusInvalidatedTags) + ? (ctx.locals.__wrnexusInvalidatedTags as string[]) + : [], + ), + ]; + if (invalidated.length) cache.invalidateTags(invalidated); + if ((ctx.req.headers.get("accept") ?? "").includes("application/json")) { + return Response.json({ ok: true, data, invalidated }); + } + return new Response(null, { + status: 303, + headers: { location: ctx.url.pathname + ctx.url.search }, }); } @@ -1605,11 +1979,12 @@ export function createHandlers(deps: RuntimeDeps): Handlers { headers.set("x-wrnexus-hmr", "1"); const req = new Request(url, { headers }); const ctx = createContext(req, url); + initializeRequestCache(ctx); ctx.locals.cspNonce = randomNonce(); if (deps.i18n) { ctx.lang = resolveLang( deps.i18n, - ctx.cookies.get(LANG_COOKIE), + ctx.cookies.get(deps.i18n.cookie.name), req.headers.get("accept-language"), ); ctx.t = makeT(deps.i18n, ctx.lang); @@ -1908,9 +2283,15 @@ export function collectScripts( navigation: { mode?: "auto" | "client" | "document" } = {}, ): RenderScript[] { const scripts: RenderScript[] = []; - if (/\bdata-scope=/.test(body) || /\bdata-wrnexus-csr=/.test(body)) { + if ( + /\bdata-scope=/.test(body) || + /\bdata-wrnexus-csr=/.test(body) || + /\bdata-wrn-client-template=/.test(body) || + /\bdata-wrn-async=/.test(body) + ) { scripts.push("/__wrnexus/reactive.js"); } + if (/\bdata-wrn-action=/.test(body)) scripts.push("/__wrnexus/actions.js"); // The theme runtime is only needed when the page can switch themes. if ( /\bdata-wire-theme-(toggle|set)\b/.test(body) || @@ -1961,6 +2342,49 @@ function safeLanguageTag(value: string): string { return /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(value) ? value : "en"; } +function cacheDuration(value: string | undefined, fallback: number): number { + if (!value) return fallback; + const match = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/i.exec(value.trim()); + if (!match) return fallback; + const scale = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 }[ + (match[2]?.toLowerCase() ?? "ms") as "ms" | "s" | "m" | "h" | "d" + ]; + return Math.max(0, Number(match[1]) * scale); +} + +function cacheList(value: string | undefined): string[] { + if (!value) return []; + try { + const parsed = JSON.parse(value) as unknown; + if (Array.isArray(parsed)) + return parsed.filter((item): item is string => typeof item === "string"); + } catch { + // Fall through to a convenient comma-separated form. + } + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +function cacheIdentity(ctx: Context, vary: string[]): string { + const user = ctx.user as { id?: unknown } | undefined; + const values = new Map(); + if (ctx.tenant?.id) values.set("tenant", String(ctx.tenant.id)); + if (user?.id !== undefined) values.set("user", String(user.id)); + for (const item of vary) { + if (item === "tenant") values.set(item, String(ctx.tenant?.id ?? "")); + else if (item === "user") values.set(item, String(user?.id ?? "")); + else if (item === "language") values.set(item, ctx.lang); + else if (item.startsWith("cookie:")) values.set(item, ctx.cookies.get(item.slice(7)) ?? ""); + else values.set(`header:${item}`, ctx.req.headers.get(item) ?? ""); + } + return [...values.entries()] + .sort() + .map(([key, value]) => `${key}=${value}`) + .join("|"); +} + function versionAssetUrl(src: string, version?: string): string { if (!version) return src; return `${src}${src.includes("?") ? "&" : "?"}v=${encodeURIComponent(version)}`; diff --git a/packages/dev-server/src/serve-entry.ts b/packages/dev-server/src/serve-entry.ts index 61756ece..3ce2ab93 100644 --- a/packages/dev-server/src/serve-entry.ts +++ b/packages/dev-server/src/serve-entry.ts @@ -8,12 +8,13 @@ * the supervisor delivers live reload of edited server code. */ +import { readFileSync } from "node:fs"; import { dirname } from "node:path"; import type { Mode } from "@wrnexus/core"; import { findStyleEntry, headToString, loadAppConfig, renderFontHead } from "@wrnexus/styles"; import { startServer } from "./index.ts"; -const [appDir, portStr, modeStr, hostname, hmrStr] = process.argv.slice(2); +const [appDir, portStr, modeStr, hostname, hmrStr, certFile, keyFile] = process.argv.slice(2); const mode = (modeStr as Mode) || "development"; @@ -38,6 +39,10 @@ const server = await startServer({ port, hostname, + tls: + certFile && keyFile + ? { cert: readFileSync(certFile, "utf8"), key: readFileSync(keyFile, "utf8") } + : undefined, mode, hmr: hmrStr === undefined ? undefined : hmrStr === "true", diff --git a/packages/dev-server/test/actions-runtime.test.ts b/packages/dev-server/test/actions-runtime.test.ts new file mode 100644 index 00000000..4dbd26b1 --- /dev/null +++ b/packages/dev-server/test/actions-runtime.test.ts @@ -0,0 +1,166 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { buildRouter } from "@wrnexus/router"; +import { v } from "@wrnexus/validation"; +import { createHandlers, type RuntimeDeps } from "../src/runtime.ts"; + +const roots: string[] = []; +afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))); +const server = { upgrade: () => false }; + +test("server actions validate, enforce CSRF, invalidate, and progressively enhance forms", async () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-action-runtime-")); + roots.push(root); + const app = join(root, "app"); + mkdirSync(join(app, "pages"), { recursive: true }); + writeFileSync(join(app, "pages", "users.ts"), "export default () => '';"); + const router = buildRouter(app); + const schema = v.object({ name: v.string().min(2) }); + const security: Record = {}; + let authenticated = false; + let granted = false; + const handlers = createHandlers({ + mode: "production", + hmr: false, + router, + loadModule: async () => ({ + default: () => `
    `, + __wrnexusActions: { + createUser: { + schema, + run: (input: { name: string }, ctx: { locals: Record }) => { + ctx.locals.__wrnexusInvalidatedTags = ["users", "users"]; + return { id: `user-${input.name}` }; + }, + }, + }, + __wrnexusSecurity: security, + }), + getMiddleware: async () => [ + (ctx, next) => { + if (authenticated) ctx.user = { id: "operator" }; + ctx.locals.permissions = granted ? ["users.create"] : []; + return next(); + }, + ], + assets: { serve: async () => null }, + } satisfies RuntimeDeps); + + const page = await handlers.fetch(new Request("https://example.test/users"), server); + const html = await page!.text(); + expect(html).toContain('name="_csrf"'); + expect(html).toContain("/__wrnexus/actions.js"); + const cookie = page!.headers.get("set-cookie")!; + const token = /wire-csrf=([^;]+)/.exec(cookie)?.[1]; + if (!token) throw new Error("expected CSRF cookie"); + + const invalid = await handlers.fetch( + new Request("https://example.test/users", { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json", + cookie, + "x-wrnexus-action": "createUser", + "x-csrf-token": token, + }, + body: JSON.stringify({ name: "x" }), + }), + server, + ); + expect(invalid?.status).toBe(422); + expect(await invalid?.json()).toMatchObject({ errors: { name: expect.any(String) } }); + + const noCsrf = await handlers.fetch( + new Request("https://example.test/users", { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json", + cookie, + "x-wrnexus-action": "createUser", + }, + body: JSON.stringify({ name: "Ada" }), + }), + server, + ); + expect(noCsrf?.status).toBe(403); + + const success = await handlers.fetch( + new Request("https://example.test/users", { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json", + cookie, + "x-wrnexus-action": "createUser", + "x-csrf-token": token, + }, + body: JSON.stringify({ name: "Ada" }), + }), + server, + ); + expect(await success?.json()).toEqual({ + ok: true, + data: { id: "user-Ada" }, + invalidated: ["users"], + }); + + security.auth = "required"; + expect( + ( + await handlers.fetch( + new Request("https://example.test/users", { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json", + cookie, + "x-wrnexus-action": "createUser", + "x-csrf-token": token, + }, + body: JSON.stringify({ name: "Ada" }), + }), + server, + ) + )?.status, + ).toBe(401); + authenticated = true; + security.permission = "users.create"; + expect( + ( + await handlers.fetch( + new Request("https://example.test/users", { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json", + cookie, + "x-wrnexus-action": "createUser", + "x-csrf-token": token, + }, + body: JSON.stringify({ name: "Ada" }), + }), + server, + ) + )?.status, + ).toBe(403); + granted = true; + + const form = new FormData(); + form.set("_wrnexus_action", "createUser"); + form.set("_csrf", token); + form.set("name", "Grace"); + const progressive = await handlers.fetch( + new Request("https://example.test/users", { + method: "POST", + headers: { cookie, origin: "https://example.test" }, + body: form, + }), + server, + ); + expect(progressive?.status).toBe(303); + expect(progressive?.headers.get("location")).toBe("/users"); +}); diff --git a/packages/dev-server/test/cache-runtime.test.ts b/packages/dev-server/test/cache-runtime.test.ts new file mode 100644 index 00000000..1740abec --- /dev/null +++ b/packages/dev-server/test/cache-runtime.test.ts @@ -0,0 +1,110 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { CacheCoordinator } from "@wrnexus/cache"; +import { buildRouter } from "@wrnexus/router"; +import { createHandlers, type RuntimeDeps } from "../src/runtime.ts"; + +const roots: string[] = []; +afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))); + +test("declarative route policies cache loader data and expose inspection", async () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-cache-runtime-")); + roots.push(root); + const app = join(root, "app"); + mkdirSync(join(app, "pages"), { recursive: true }); + writeFileSync(join(app, "pages/dashboard.ts"), "export default () => '';\n"); + let loads = 0; + const cache = new CacheCoordinator(); + const handlers = createHandlers({ + mode: "development", + hmr: false, + router: buildRouter(app), + cache, + loadModule: async () => ({ + __wrnexusCache: { + strategy: "stale-while-revalidate", + ttl: "5m", + tags: '["dashboard"]', + vary: '["language"]', + }, + __wrnexusLoad: async () => ({ count: ++loads }), + default: (ctx: { count: number }) => `

    ${ctx.count}

    `, + }), + getMiddleware: async () => [], + assets: { serve: async () => null }, + } satisfies RuntimeDeps); + const server = { upgrade: () => false }; + const first = await handlers.fetch(new Request("https://example.test/dashboard"), server); + const second = await handlers.fetch(new Request("https://example.test/dashboard"), server); + expect(first?.headers.get("x-wrnexus-data-cache")).toBe("MISS"); + expect(second?.headers.get("x-wrnexus-data-cache")).toBe("HIT"); + expect(loads).toBe(1); + const inspection = await handlers.fetch( + new Request("https://example.test/__wrnexus/cache"), + server, + ); + expect((await inspection?.json())?.layers.data).toHaveLength(1); +}); + +test("safe full-page policies reuse static documents", async () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-page-cache-runtime-")); + roots.push(root); + const app = join(root, "app"); + mkdirSync(join(app, "pages"), { recursive: true }); + writeFileSync(join(app, "pages/marketing.ts"), "export default () => '';\n"); + let renders = 0; + const pageCache = new CacheCoordinator(); + const handlers = createHandlers({ + mode: "production", + hmr: false, + router: buildRouter(app), + cache: pageCache, + loadModule: async () => ({ + __wrnexusCache: { scope: "page", strategy: "fresh", ttl: "5m", tags: '["marketing"]' }, + default: () => `

    Render ${++renders}

    `, + }), + getMiddleware: async () => [], + assets: { serve: async () => null }, + } satisfies RuntimeDeps); + const server = { upgrade: () => false }; + const first = await handlers.fetch(new Request("https://example.test/marketing"), server); + const second = await handlers.fetch(new Request("https://example.test/marketing"), server); + expect(first?.headers.get("x-wrnexus-page-cache")).toBe("MISS"); + expect(pageCache.inspect().layers.page).toHaveLength(1); + expect(second?.headers.get("x-wrnexus-page-cache")).toBe("HIT"); + const firstHtml = await first!.text(); + const secondHtml = await second!.text(); + expect(secondHtml).toContain("Render 1"); + expect(/nonce="([^"]+)"/.exec(firstHtml)?.[1]).not.toBe(/nonce="([^"]+)"/.exec(secondHtml)?.[1]); + expect(renders).toBe(1); +}); + +test("full-page cache refuses CSRF-bearing documents", async () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-page-cache-csrf-")); + roots.push(root); + const app = join(root, "app"); + mkdirSync(join(app, "pages"), { recursive: true }); + writeFileSync(join(app, "pages/account.ts"), "export default () => '';\n"); + let renders = 0; + const cache = new CacheCoordinator(); + const handlers = createHandlers({ + mode: "production", + hmr: false, + router: buildRouter(app), + cache, + loadModule: async () => ({ + __wrnexusCache: { scope: "page", strategy: "fresh", ttl: "5m" }, + default: () => + `
    `, + }), + getMiddleware: async () => [], + assets: { serve: async () => null }, + } satisfies RuntimeDeps); + const server = { upgrade: () => false }; + await handlers.fetch(new Request("https://example.test/account"), server); + await handlers.fetch(new Request("https://example.test/account"), server); + expect(renders).toBe(2); + expect(cache.inspect().layers.page).toEqual([]); +}); diff --git a/packages/dev-server/test/import-modes-v060.test.ts b/packages/dev-server/test/import-modes-v060.test.ts index 63b935aa..99e6264e 100644 --- a/packages/dev-server/test/import-modes-v060.test.ts +++ b/packages/dev-server/test/import-modes-v060.test.ts @@ -53,3 +53,20 @@ test("legacy, compatible, and explicit import modes are enforced from app config console.warn = originalWarn; } }); + +test("compiler-native reactive elements do not require application imports", () => { + const { root, page } = fixture(); + writeFileSync( + page, + `page Home { + state active = "Admin" + view { +
    Admin
    +

    Notice

    + LoadingReadyFailed + } + }`, + ); + setCompileImportOptions(root, { mode: "explicit" }); + expect(() => compileWireArtifacts(page, 5)).not.toThrow(); +}); diff --git a/packages/dev-server/test/load-runtime.test.ts b/packages/dev-server/test/load-runtime.test.ts new file mode 100644 index 00000000..744a3c43 --- /dev/null +++ b/packages/dev-server/test/load-runtime.test.ts @@ -0,0 +1,72 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { buildRouter } from "@wrnexus/router"; +import { createHandlers, type RuntimeDeps } from "../src/runtime.ts"; + +const roots: string[] = []; +afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))); + +test("server loader data is available to page rendering", async () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-load-runtime-")); + roots.push(root); + const app = join(root, "app"); + mkdirSync(join(app, "pages"), { recursive: true }); + writeFileSync(join(app, "pages/users.ts"), "export default () => '';\n"); + const handlers = createHandlers({ + mode: "production", + hmr: false, + router: buildRouter(app), + loadModule: async () => ({ + __wrnexusLoad: async () => ({ users: ["Ada", "Lin"] }), + default: (ctx: { users: string[]; data: { users: string[] } }) => + `

    ${ctx.users.join(",")} / ${ctx.data.users.length}

    `, + }), + getMiddleware: async () => [], + assets: { serve: async () => null }, + } satisfies RuntimeDeps); + const response = await handlers.fetch(new Request("https://example.test/users"), { + upgrade: () => false, + }); + expect(await response!.text()).toContain("Ada,Lin / 2"); +}); + +test("HMR page synchronization initializes request-scoped loader caching", async () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-hmr-load-runtime-")); + roots.push(root); + const app = join(root, "app"); + mkdirSync(join(app, "pages"), { recursive: true }); + writeFileSync(join(app, "pages/async.ts"), "export default () => '';\n"); + const handlers = createHandlers({ + mode: "development", + hmr: true, + router: buildRouter(app), + loadModule: async () => ({ + __wrnexusLoad: async () => ({ message: "Loaded through HMR" }), + default: (ctx: { message: string }) => `

    ${ctx.message}

    `, + }), + getMiddleware: async () => [], + assets: { serve: async () => null }, + } satisfies RuntimeDeps); + + const html = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("HMR response timed out")), 1_000); + handlers.websocket.message( + { + data: { kind: "hmr", baseUrl: "http://localhost", headers: [] }, + send(value) { + clearTimeout(timeout); + resolve(String(value)); + }, + close() {}, + }, + JSON.stringify({ type: "sync", path: "/async" }), + ); + }); + + const message = JSON.parse(html) as { type: string; html?: string; message?: string }; + expect(message.type).toBe("html"); + expect(message.message).toBeUndefined(); + expect(message.html).toContain("Loaded through HMR"); +}); diff --git a/packages/dev-server/test/observability-runtime.test.ts b/packages/dev-server/test/observability-runtime.test.ts new file mode 100644 index 00000000..93603bb1 --- /dev/null +++ b/packages/dev-server/test/observability-runtime.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from "bun:test"; +import { HealthRegistry } from "@wrnexus/core"; +import type { Router } from "@wrnexus/router"; +import { createHandlers, type RuntimeDeps } from "../src/runtime.ts"; + +function runtime(health: HealthRegistry) { + const router: Router = { + pages: [], + api: [], + realtime: [], + middlewareFiles: [], + components: [], + layouts: [], + stores: [], + schemas: [], + matchPage: () => null, + matchApi: () => null, + matchRealtime: () => null, + }; + return createHandlers({ + mode: "production", + hmr: false, + router, + loadModule: async () => ({}), + getMiddleware: async () => [], + assets: { serve: async () => null }, + health, + observability: { enabled: true, sampleRate: 1, exporter: "none" }, + } satisfies RuntimeDeps); +} + +const server = { upgrade: () => false }; + +test("runtime exposes separate liveness and dependency readiness probes", async () => { + const health = new HealthRegistry(); + health.register("database", () => ({ status: "down", message: "offline" })); + const handlers = runtime(health); + + const live = await handlers.fetch(new Request("https://example.test/healthz"), server); + const ready = await handlers.fetch(new Request("https://example.test/readyz"), server); + + expect(live?.status).toBe(200); + expect(await live?.json()).toEqual({ status: "up" }); + expect(ready?.status).toBe(503); + expect(await ready?.json()).toEqual({ status: "down" }); +}); + +test("built production responses carry the framework security-header baseline", async () => { + const handlers = runtime(new HealthRegistry()); + const response = await handlers.fetch(new Request("https://example.test/healthz"), server); + expect(response?.headers.get("strict-transport-security")).toContain("max-age="); + expect(response?.headers.get("content-security-policy")).toContain("default-src 'self'"); + expect(response?.headers.get("x-content-type-options")).toBe("nosniff"); + expect(response?.headers.get("referrer-policy")).toBeTruthy(); +}); diff --git a/packages/dev-server/test/partial-build.test.ts b/packages/dev-server/test/partial-build.test.ts new file mode 100644 index 00000000..885d90d4 --- /dev/null +++ b/packages/dev-server/test/partial-build.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "bun:test"; +import { precomputePartialStaticShell } from "../src/partial-build.ts"; + +test("precomputes nested page components while erasing dynamic region bodies", async () => { + const result = await precomputePartialStaticShell( + { + __wrnexusBuildStaticShell: () => + '
    ', + }, + [ + { + name: "Card", + mod: { render: (props) => `
    ${props?.title}
    ` }, + }, + ], + ); + expect(result.regions).toBe(1); + expect(result.shell).toContain("
    Docs
    "); + expect(result.shell).toContain('data-wrn-dynamic-placeholder="wrn-region-0"'); + expect(result.shell).not.toContain("wrn-dynamic-region"); +}); diff --git a/packages/dev-server/test/performance-runtime.test.ts b/packages/dev-server/test/performance-runtime.test.ts index 22a94419..d92282ee 100644 --- a/packages/dev-server/test/performance-runtime.test.ts +++ b/packages/dev-server/test/performance-runtime.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { PWA_CLIENT, usesMobileRuntime } from "../src/runtime.ts"; +import { PWA_CLIENT, PWA_DEV_CLEANUP_CLIENT, usesMobileRuntime } from "../src/runtime.ts"; test("PWA registration is valid JavaScript and Trusted Types compatible", () => { expect(() => new Bun.Transpiler({ loader: "js" }).transformSync(PWA_CLIENT)).not.toThrow(); @@ -7,6 +7,14 @@ test("PWA registration is valid JavaScript and Trusted Types compatible", () => expect(PWA_CLIENT).toContain("createScriptURL(swUrl)"); }); +test("development PWA cleanup removes stale WRNexus service workers and caches", () => { + expect(() => + new Bun.Transpiler({ loader: "js" }).transformSync(PWA_DEV_CLEANUP_CLIENT), + ).not.toThrow(); + expect(PWA_DEV_CLEANUP_CLIENT).toContain("registration.unregister()"); + expect(PWA_DEV_CLEANUP_CLIENT).toContain("wrnexus-pwa-"); +}); + test("mobile runtime is shipped only for pages using mobile or native directives", () => { expect(usesMobileRuntime('
    Docs
    ')).toBe(false); expect(usesMobileRuntime('')).toBe(true); diff --git a/packages/dev-server/test/pipeline.test.ts b/packages/dev-server/test/pipeline.test.ts index 416b2e55..0be320a7 100644 --- a/packages/dev-server/test/pipeline.test.ts +++ b/packages/dev-server/test/pipeline.test.ts @@ -2,7 +2,67 @@ import { expect, test } from "bun:test"; import { mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { invalidateModule, loadModule, setCompileCacheDir } from "../src/pipeline.ts"; +import { + compileWireArtifacts, + compileWireArtifactsAsync, + getWrnCompileMetrics, + invalidateModule, + loadModule, + resetWrnCompileMetrics, + setCompileCacheDir, + setDevCompilerPipeline, +} from "../src/pipeline.ts"; + +test("development compilation awaits plugin AST and code transforms", async () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-plugin-pipeline-")); + const file = join(root, "page.wrn"); + let astTransformed = false; + setCompileCacheDir(join(root, ".wrnexus")); + setDevCompilerPipeline({ + async transformAst(ast) { + await Promise.resolve(); + astTransformed = true; + return ast; + }, + async transformCode(code) { + await Promise.resolve(); + return `${code}\nexport const pluginTransformed = true;\n`; + }, + virtualModules: new Map(), + }); + try { + writeFileSync(file, "page Home { view {

    Plugin

    } }\n"); + const artifact = await compileWireArtifactsAsync(file); + expect(astTransformed).toBeTrue(); + expect((await import(artifact.main)).pluginTransformed).toBeTrue(); + } finally { + setDevCompilerPipeline(null); + rmSync(root, { recursive: true, force: true }); + } +}); + +test("WRN compilation exposes cache hit, miss, timing, and error metrics", () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-cache-metrics-")); + const file = join(root, "page.wrn"); + setCompileCacheDir(join(root, ".wrnexus")); + resetWrnCompileMetrics(); + try { + writeFileSync(file, "page Home { view {

    Metrics

    } }\n"); + compileWireArtifacts(file); + compileWireArtifacts(file); + writeFileSync(file, "page Broken { view {

    } }\n"); + expect(() => compileWireArtifacts(file)).toThrow(); + expect(getWrnCompileMetrics()).toMatchObject({ + hits: 1, + misses: 2, + compilations: 1, + errors: 1, + }); + expect(getWrnCompileMetrics().totalDurationMs).toBeGreaterThanOrEqual(0); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); test("invalidateModule loads changed server modules without restarting the process", async () => { const root = mkdtempSync(join(tmpdir(), "wrnexus-hmr-")); diff --git a/packages/dev-server/test/plugin-render-runtime.test.ts b/packages/dev-server/test/plugin-render-runtime.test.ts new file mode 100644 index 00000000..1b8dd5c4 --- /dev/null +++ b/packages/dev-server/test/plugin-render-runtime.test.ts @@ -0,0 +1,30 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { buildRouter } from "@wrnexus/router"; +import { createHandlers, type RuntimeDeps } from "../src/runtime.ts"; + +const roots: string[] = []; +afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))); + +test("plugin render lifecycle transforms final documents", async () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-plugin-render-")); + roots.push(root); + const app = join(root, "app"); + mkdirSync(join(app, "pages"), { recursive: true }); + writeFileSync(join(app, "pages/index.ts"), "export default () => '';\n"); + const handlers = createHandlers({ + mode: "development", + hmr: false, + router: buildRouter(app), + loadModule: async () => ({ default: () => "

    Home

    " }), + renderHtml: (html) => html.replace("", ""), + getMiddleware: async () => [], + assets: { serve: async () => null }, + } satisfies RuntimeDeps); + const response = await handlers.fetch(new Request("https://example.test/"), { + upgrade: () => false, + }); + expect(await response?.text()).toContain(""); +}); diff --git a/packages/dev-server/test/production-hmr.test.ts b/packages/dev-server/test/production-hmr.test.ts new file mode 100644 index 00000000..2173ba6c --- /dev/null +++ b/packages/dev-server/test/production-hmr.test.ts @@ -0,0 +1,74 @@ +import { expect, test } from "bun:test"; +import { createProductionHandlers, type ProdManifest } from "../src/prod.ts"; + +const manifest: ProdManifest = { + pages: [ + { + raw: "/", + mod: { default: () => "
    Production artifact
    ", meta: { title: "Prod" } }, + }, + { + raw: "/partial", + staticShell: + '
    Build shell
    ', + mod: { + default: () => + '
    Request shellUser 42
    ', + meta: { title: "Partial" }, + __wrnexusRender: "partial-static", + }, + }, + { + raw: "/async", + mod: { + default: () => "
    Async page
    ", + meta: { title: "Async" }, + __wrnexusClientLoad: async () => ({ users: [{ id: 1, name: "Ada" }] }), + }, + }, + ], + api: [], + realtime: [], + middleware: [], + components: [], + layouts: [], +}; + +const server = { upgrade: () => false } as never; + +test("supervised production runtime injects reconnecting DOM-morph support", async () => { + const handlers = createProductionHandlers(manifest, { developmentRuntime: true }); + const response = await handlers.fetch(new Request("http://localhost/"), server); + expect(response).toBeInstanceOf(Response); + expect(await (response as Response).text()).toContain("/__wrnexus/hmr"); +}); + +test("production streams request regions into the build-time static shell", async () => { + const handlers = createProductionHandlers(manifest, {}); + const response = (await handlers.fetch( + new Request("http://localhost/partial"), + server, + )) as Response; + const html = await response.text(); + expect(response.headers.get("x-wrnexus-static-shell")).toBe("build"); + expect(html).toContain("Build shell"); + expect(html).not.toContain("Request shell"); + expect(html).toContain("User 42"); +}); + +test("normal production output remains free of development HMR", async () => { + const handlers = createProductionHandlers(manifest, {}); + const response = await handlers.fetch(new Request("http://localhost/"), server); + expect(await (response as Response).text()).not.toContain("/__wrnexus/hmr"); +}); + +test("production client-load endpoint returns only the requested named result", async () => { + const handlers = createProductionHandlers(manifest, {}); + const response = (await handlers.fetch( + new Request("http://localhost/__wrnexus/client-load?route=%2Fasync&name=users"), + server, + )) as Response; + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("private, no-store"); + expect(await response.json()).toEqual({ data: [{ id: 1, name: "Ada" }] }); +}); diff --git a/packages/dev-toolbar/README.md b/packages/dev-toolbar/README.md index 7b07b04c..76e4164c 100644 --- a/packages/dev-toolbar/README.md +++ b/packages/dev-toolbar/README.md @@ -7,6 +7,9 @@ Development-only page quality toolbar for WRNexusJS. - Runtime, resource and unhandled promise error capture - Accessibility, SEO, image, media, color, HTML, form, link, responsive and security checks - Performance and network observations +- First-class application tabs for runtime, stores, cache, accessibility, SEO, performance, + security, images, links, and JavaScript +- Plugin-contributed applications with badges, descriptions, issue feeds, and structured data - Element highlighting and issue filtering - Server-side issue collector - Development-only asset strings for direct serving by `@wrnexus/dev-server` @@ -21,3 +24,7 @@ Serve `DEV_TOOLBAR_RUNTIME` at `/__wrnexus/dev-toolbar.js` and `DEV_TOOLBAR_CSS` ``` The browser runtime exposes `window.__wrnexusDevToolbar`. + +Plugin panels returned through `devToolbarPanels()` are automatically added to the application +strip. Their issue category is filterable, and structured `data` is rendered as escaped diagnostic +content so a plugin never needs to inject toolbar HTML. diff --git a/packages/dev-toolbar/package.json b/packages/dev-toolbar/package.json index 4857a3c5..1e1be3fa 100644 --- a/packages/dev-toolbar/package.json +++ b/packages/dev-toolbar/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-toolbar", - "version": "0.7.0", + "version": "0.8.0", "private": true, "type": "module", "sideEffects": false, @@ -19,7 +19,7 @@ "check": "bun run typecheck && bun run test" }, "devDependencies": { - "@types/bun": "latest", + "@types/bun": "^1.3.14", "typescript": "^5.9.2" } } diff --git a/packages/dev-toolbar/src/client/runtime.ts b/packages/dev-toolbar/src/client/runtime.ts index 1661c061..6990c5ad 100644 --- a/packages/dev-toolbar/src/client/runtime.ts +++ b/packages/dev-toolbar/src/client/runtime.ts @@ -11,7 +11,7 @@ export const DEV_TOOLBAR_RUNTIME = String.raw`(() => { fetch("/__wrnexus/dev-toolbar.css").then(r => r.ok ? r.text() : "").then(css => style.textContent = css).catch(() => {}); root.appendChild(style); const shell = document.createElement("div"); shell.className="wrn-shell"; shell.dataset.position=state.config.position; - shell.innerHTML='
    WRNexus DevToolbar
    '; + shell.innerHTML='
    WRNexus DevToolbar
    '; root.appendChild(shell); const panel=root.querySelector(".wrn-panel"), list=root.querySelector(".wrn-list"), meta=root.querySelector(".wrn-panel-meta"), search=root.querySelector(".wrn-search"); const esc = value => String(value ?? "").replace(/[&<>\"']/g, char => ({"&":"&","<":"<",">":">",'"':""","'":"'"})[char]); @@ -44,12 +44,22 @@ export const DEV_TOOLBAR_RUNTIME = String.raw`(() => { if(document.documentElement.scrollWidth>innerWidth+2)found.push(issue("responsive/document-overflow","responsive","error","Page has horizontal overflow","Document width exceeds the viewport.",null,"Inspect fixed widths, long text and overflowing media.")); q("body *").filter(visible).slice(0,2500).forEach(el=>{const r=el.getBoundingClientRect();if((r.right>innerWidth+8||r.left<-8)&&found.filter(x=>x.ruleId==="responsive/element-overflow").length<20)found.push(issue("responsive/element-overflow","responsive","warning","Element extends outside the viewport","Element bounds exceed the current viewport.",el,"Use fluid sizing, wrapping, max-width or an intentional scroll container."));}); const resources=performance.getEntriesByType("resource");const total=resources.reduce((s,e)=>s+(e.transferSize||0),0);const jsBytes=resources.filter(e=>/(?:\.m?js)(?:\?|$)/i.test(e.name)).reduce((sum,e)=>sum+(e.transferSize||0),0);if(resources.length>150)found.push(issue("performance/resource-count","performance","warning","Page loads many resources","Found "+resources.length+" resource requests.",null,"Remove duplicates and defer non-critical resources."));if(total>5000000)found.push(issue("performance/transfer-size","performance",total>10000000?"error":"warning","Page transfer size is large","Observed transfer size is about "+(total/1000000).toFixed(2)+" MB.",null,"Compress and optimize page resources."));if(jsBytes>150000)found.push(issue("performance/javascript-budget","javascript",jsBytes>300000?"error":"warning","JavaScript budget exceeded","JavaScript transfer is about "+(jsBytes/1000).toFixed(1)+" KB.",null,"Split routes and defer optional hydration."));const hydrationRoots=q("[data-wrn-client-module],[data-wrn-hydrate]");if(hydrationRoots.length>50)found.push(issue("performance/hydration-count","runtime","warning","Many components hydrate",hydrationRoots.length+" hydration boundaries were found.",null,"Use visible, idle or interaction hydration."));if(state.runtimeMetrics.longTasks.length)found.push(issue("performance/long-tasks","javascript","warning","Long main-thread tasks detected",state.runtimeMetrics.longTasks.length+" task(s) exceeded 50 ms.",null,"Split expensive work and reduce hydration.","high",{longestMs:Math.max(...state.runtimeMetrics.longTasks)})); + let checkedSelectors=0,unusedSelectors=0;for(const sheet of [...document.styleSheets]){let rules;try{rules=[...(sheet.cssRules||[])]}catch{continue}for(const rule of rules){if(checkedSelectors>=2000)break;const selector=rule.selectorText;if(!selector||selector.includes(":"))continue;checkedSelectors++;try{if(!document.querySelector(selector))unusedSelectors++}catch{}}}if(unusedSelectors)found.push(issue("css/unused-selectors","css","suggestion","Potentially unused CSS",unusedSelectors+" of "+checkedSelectors+" inspected selectors do not match this page.",null,"Review across routes before removing selectors.","medium",{checkedSelectors,unusedSelectors}));const memory=performance.memory;if(memory&&memory.jsHeapSizeLimit&&memory.usedJSHeapSize/memory.jsHeapSizeLimit>.8)found.push(issue("performance/memory-pressure","performance","warning","High JavaScript heap usage",Math.round(memory.usedJSHeapSize/1048576)+" MiB of "+Math.round(memory.jsHeapSizeLimit/1048576)+" MiB is in use.",null,"Inspect retained objects and repeated hydration.")); q("[data-wrn-client-module]").forEach(el=>found.push(issue("runtime/client-module","runtime","info","Client function module",el.getAttribute("data-wrn-client-module")||"Unknown module",el,"Loaded according to the component hydration strategy.","high",{hydration:el.getAttribute("data-wrn-hydrate"),runtime:el.getAttribute("data-wrn-runtime")}))); const storeContainer=window.__wrnexusStoreContainer; if(storeContainer&&typeof storeContainer.inspect==="function"){ for(const store of storeContainer.inspect())found.push(issue("stores/instance","stores","info",store.kind+" store: "+store.name,JSON.stringify({state:store.state,computed:store.computed}),null,"Use store actions for mutations. Sensitive server state is never hydrated.","high",store)); } - const unique=new Map();[...state.issues.filter(x=>["runtime","stores","network","server","compiler","routing"].includes(x.category)),...found].forEach(x=>unique.set(x.fingerprint,x));state.issues=[...unique.values()]; + try{ + const inspection=await fetch("/__wrnexus/cache",{headers:{accept:"application/json"}}).then(response=>response.ok?response.json():null); + if(inspection&&inspection.layers){ + for(const name of ["data","component","page"]){ + const entries=inspection.layers[name]||[]; + found.push(issue("cache/layer","cache","info",name+" cache",entries.length+" entries · "+entries.filter(entry=>entry.state==="fresh").length+" fresh · "+entries.filter(entry=>entry.state==="stale").length+" stale",null,"Use tags for related invalidation and vary by tenant, user, or language when needed.","high",{layer:name,entries})); + } + } + }catch{} + const unique=new Map();[...state.issues.filter(x=>["runtime","stores","cache","network","server","compiler","routing"].includes(x.category)),...found].forEach(x=>unique.set(x.fingerprint,x));state.issues=[...unique.values()]; state.report={url:location.href,pathname:location.pathname,title:document.title,status:200,generatedAt:Date.now(),issues:state.issues,metrics:{domNodes:count,cssResources:q('link[rel="stylesheet"]').length,scriptResources:q("script[src]").length,imageResources:q("img").length,totalTransferBytes:total,javascriptBytes:jsBytes,hydratedComponents:state.runtimeMetrics.hydrationCount,hydrationMs:state.runtimeMetrics.hydrationMs,longTasks:state.runtimeMetrics.longTasks.length,pageLoadMs:performance.getEntriesByType("navigation")[0]?.duration}}; render(); return state.report; }; let highlightEl=null; const clearHighlight=()=>{highlightEl?.remove();highlightEl=null}; const highlight=sel=>{clearHighlight();let target;try{target=document.querySelector(sel)}catch{}if(!target)return;const r=target.getBoundingClientRect();highlightEl=document.createElement("div");highlightEl.className="wrn-highlight";Object.assign(highlightEl.style,{left:r.left+"px",top:r.top+"px",width:r.width+"px",height:r.height+"px"});root.appendChild(highlightEl);target.scrollIntoView({block:"center",behavior:"smooth"});setTimeout(clearHighlight,3000)}; @@ -64,7 +74,7 @@ export const DEV_TOOLBAR_RUNTIME = String.raw`(() => { addEventListener("wrnexus:store-mutation",event=>{const mutation=event.detail||{};const x=issue("stores/mutation","stores","info","Store action: "+(mutation.store||"unknown")+"."+(mutation.action||"direct"),"Changed fields: "+((mutation.changed||[]).join(", ")||"none"),null,"Use the Stores panel to inspect safe client state.","high",mutation);state.issues=[x,...state.issues.filter(i=>i.fingerprint!==x.fingerprint)];render();}); addEventListener("wrnexus:hydrated",event=>{state.runtimeMetrics.hydrationCount+=1;state.runtimeMetrics.hydrationMs+=Number(event.detail?.durationMs||0);}); try{new PerformanceObserver(list=>{for(const entry of list.getEntries()){if(entry.duration>50)state.runtimeMetrics.longTasks.push(entry.duration);}state.runtimeMetrics.longTasks=state.runtimeMetrics.longTasks.slice(-100);}).observe({type:"longtask",buffered:true});}catch{} - fetch("/__wrnexus/dev-toolbar/platform").then(r=>r.ok?r.json():null).then(data=>{if(!data)return;state.platform=data.platform;state.panels=data.panels||[];render();}).catch(()=>{}); + fetch("/__wrnexus/dev-toolbar/platform").then(r=>r.ok?r.json():null).then(data=>{if(!data)return;state.platform=data.platform;state.panels=data.panels||[];const apps=root.querySelector(".wrn-apps");const existing=new Set([...apps.querySelectorAll("[data-category]")].map(button=>button.dataset.category));for(const app of state.panels){if(!existing.has(app.id)){const button=document.createElement("button");button.className="wrn-small";button.dataset.category=app.id;button.textContent=app.title+(app.badge!==undefined?" ("+app.badge+")":"");button.title=app.description||app.title;apps.appendChild(button);}if(app.data!==undefined||app.issues?.length){const payload=app.data!==undefined?app.data:app.issues;state.issues.push(issue("plugin/"+app.id,app.id,"info",app.title,JSON.stringify(payload,null,2),null,app.description||"Plugin-provided development information.","high",{panel:app.id,data:payload}));}}render();}).catch(()=>{}); window.__wrnexusDevToolbar={open(){state.open=true;panel.classList.add("open")},close(){state.open=false;panel.classList.remove("open")},toggle(){state.open=!state.open;panel.classList.toggle("open",state.open)},scan,clear(){state.issues=[];render()},report(){return state.report},highlight,configure(config){state.config=Object.assign(state.config,config||{});shell.dataset.position=state.config.position;try{localStorage.setItem(KEY,JSON.stringify(state.config))}catch{}}}; const observer=new MutationObserver(()=>{clearTimeout(observer.timer);observer.timer=setTimeout(()=>{if(!state.open)return;scan()},400)});observer.observe(document.documentElement,{childList:true,subtree:true,attributes:true,attributeFilter:["class","style","src","href","alt","aria-label"]}); setTimeout(scan,100); diff --git a/packages/dev-toolbar/src/client/styles.ts b/packages/dev-toolbar/src/client/styles.ts index 770b2a2f..22c06817 100644 --- a/packages/dev-toolbar/src/client/styles.ts +++ b/packages/dev-toolbar/src/client/styles.ts @@ -1,4 +1,4 @@ export const DEV_TOOLBAR_CSS = String.raw` :host{all:initial;color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--bg:#111318;--panel:#181b22;--line:#2b303b;--text:#f4f6fb;--muted:#9ca6b8;--error:#ff6b6b;--warning:#ffc857;--info:#72a7ff;--suggestion:#a78bfa} -*{box-sizing:border-box}.wrn-shell{position:fixed;z-index:2147483646;bottom:16px;left:50%;transform:translateX(-50%);color:var(--text);font-size:13px;line-height:1.4}.wrn-shell[data-position="bottom-left"]{left:16px;transform:none}.wrn-shell[data-position="bottom-right"]{left:auto;right:16px;transform:none}.wrn-bar{display:flex;align-items:center;gap:6px;padding:7px;border:1px solid var(--line);border-radius:14px;background:rgba(17,19,24,.96);box-shadow:0 18px 60px rgba(0,0,0,.42);backdrop-filter:blur(18px)}button{appearance:none;border:0;font:inherit}.wrn-button,.wrn-count{display:inline-flex;align-items:center;justify-content:center;min-height:32px;border-radius:9px;padding:0 10px;background:#222631;color:var(--text);cursor:pointer}.wrn-button:hover,.wrn-count:hover{background:#2b303c}.wrn-count.active{background:#394150;box-shadow:inset 0 0 0 1px #596579}.wrn-button:focus-visible,.wrn-count:focus-visible{outline:2px solid #72a7ff;outline-offset:2px}.wrn-brand{font-weight:800;letter-spacing:-.02em;padding:0 8px}.wrn-count{gap:5px;font-variant-numeric:tabular-nums}.wrn-dot{width:7px;height:7px;border-radius:999px}.wrn-dot.error{background:var(--error)}.wrn-dot.warning{background:var(--warning)}.wrn-dot.suggestion{background:var(--suggestion)}.wrn-panel{position:absolute;bottom:50px;left:50%;transform:translateX(-50%);width:min(780px,calc(100vw - 24px));height:min(620px,calc(100vh - 100px));display:none;overflow:hidden;border:1px solid var(--line);border-radius:16px;background:var(--bg);box-shadow:0 24px 80px rgba(0,0,0,.5)}.wrn-panel.open{display:grid;grid-template-rows:auto auto 1fr}.wrn-panel-head{display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-bottom:1px solid var(--line)}.wrn-panel-title{font-size:15px;font-weight:800}.wrn-panel-meta{color:var(--muted);font-size:12px}.wrn-toolbar-row{display:flex;gap:8px;padding:10px 12px;border-bottom:1px solid var(--line)}.wrn-search{width:100%;height:34px;border:1px solid var(--line);border-radius:9px;background:#20242d;color:var(--text);padding:0 10px;outline:none}.wrn-search:focus{border-color:#72a7ff}.wrn-list{overflow:auto;padding:10px}.wrn-empty{display:grid;place-items:center;height:100%;color:var(--muted);text-align:center;padding:40px}.wrn-issue{display:grid;grid-template-columns:8px 1fr auto;gap:10px;padding:12px;margin-bottom:8px;border:1px solid var(--line);border-radius:12px;background:var(--panel)}.wrn-severity{border-radius:999px;background:var(--info)}.wrn-severity.error{background:var(--error)}.wrn-severity.warning{background:var(--warning)}.wrn-severity.suggestion{background:var(--suggestion)}.wrn-issue-title{font-weight:750}.wrn-issue-message{margin-top:4px;color:#c8cfdb}.wrn-issue-meta{margin-top:7px;color:var(--muted);font-size:11px}.wrn-actions{display:flex;gap:5px}.wrn-small{height:28px;padding:0 8px;border-radius:7px;background:#252a35;color:var(--text);cursor:pointer}.wrn-small:hover{background:#303644}.wrn-small.active{background:#394150;box-shadow:inset 0 0 0 1px #596579}.wrn-highlight{position:fixed;z-index:2147483647;pointer-events:none;border:2px solid #72a7ff;background:rgba(114,167,255,.12);box-shadow:0 0 0 99999px rgba(0,0,0,.08)}@media(max-width:640px){.wrn-shell{bottom:8px}.wrn-brand{display:none}.wrn-bar{gap:3px;padding:5px}.wrn-count{padding:0 7px}.wrn-panel{bottom:44px;height:calc(100vh - 62px)}.wrn-issue{grid-template-columns:6px 1fr}.wrn-actions{grid-column:2}.wrn-panel-meta{display:none}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important;animation:none!important}} +*{box-sizing:border-box}.wrn-shell{position:fixed;z-index:2147483646;bottom:16px;left:50%;transform:translateX(-50%);color:var(--text);font-size:13px;line-height:1.4}.wrn-shell[data-position="bottom-left"]{left:16px;transform:none}.wrn-shell[data-position="bottom-right"]{left:auto;right:16px;transform:none}.wrn-bar{display:flex;align-items:center;gap:6px;padding:7px;border:1px solid var(--line);border-radius:14px;background:rgba(17,19,24,.96);box-shadow:0 18px 60px rgba(0,0,0,.42);backdrop-filter:blur(18px)}button{appearance:none;border:0;font:inherit}.wrn-button,.wrn-count{display:inline-flex;align-items:center;justify-content:center;min-height:32px;border-radius:9px;padding:0 10px;background:#222631;color:var(--text);cursor:pointer}.wrn-button:hover,.wrn-count:hover{background:#2b303c}.wrn-count.active{background:#394150;box-shadow:inset 0 0 0 1px #596579}.wrn-button:focus-visible,.wrn-count:focus-visible{outline:2px solid #72a7ff;outline-offset:2px}.wrn-brand{font-weight:800;letter-spacing:-.02em;padding:0 8px}.wrn-count{gap:5px;font-variant-numeric:tabular-nums}.wrn-dot{width:7px;height:7px;border-radius:999px}.wrn-dot.error{background:var(--error)}.wrn-dot.warning{background:var(--warning)}.wrn-dot.suggestion{background:var(--suggestion)}.wrn-panel{position:absolute;bottom:50px;left:50%;transform:translateX(-50%);width:min(900px,calc(100vw - 24px));height:min(620px,calc(100vh - 100px));display:none;overflow:hidden;border:1px solid var(--line);border-radius:16px;background:var(--bg);box-shadow:0 24px 80px rgba(0,0,0,.5)}.wrn-panel.open{display:grid;grid-template-rows:auto auto 1fr}.wrn-panel-head{display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-bottom:1px solid var(--line)}.wrn-panel-title{font-size:15px;font-weight:800}.wrn-panel-meta{color:var(--muted);font-size:12px}.wrn-toolbar-row{display:flex;gap:8px;padding:10px 12px;border-bottom:1px solid var(--line);overflow-x:auto}.wrn-apps{display:flex;gap:5px;flex:0 0 auto}.wrn-search{min-width:180px;width:100%;height:34px;border:1px solid var(--line);border-radius:9px;background:#20242d;color:var(--text);padding:0 10px;outline:none}.wrn-search:focus{border-color:#72a7ff}.wrn-list{overflow:auto;padding:10px}.wrn-empty{display:grid;place-items:center;height:100%;color:var(--muted);text-align:center;padding:40px}.wrn-issue{display:grid;grid-template-columns:8px 1fr auto;gap:10px;padding:12px;margin-bottom:8px;border:1px solid var(--line);border-radius:12px;background:var(--panel)}.wrn-severity{border-radius:999px;background:var(--info)}.wrn-severity.error{background:var(--error)}.wrn-severity.warning{background:var(--warning)}.wrn-severity.suggestion{background:var(--suggestion)}.wrn-issue-title{font-weight:750}.wrn-issue-message{margin-top:4px;color:#c8cfdb;white-space:pre-wrap}.wrn-issue-meta{margin-top:7px;color:var(--muted);font-size:11px}.wrn-actions{display:flex;gap:5px}.wrn-small{height:28px;padding:0 8px;border-radius:7px;background:#252a35;color:var(--text);cursor:pointer;white-space:nowrap}.wrn-small:hover{background:#303644}.wrn-small.active{background:#394150;box-shadow:inset 0 0 0 1px #596579}.wrn-plugin-panel{display:block}.wrn-highlight{position:fixed;z-index:2147483647;pointer-events:none;border:2px solid #72a7ff;background:rgba(114,167,255,.12);box-shadow:0 0 0 99999px rgba(0,0,0,.08)}@media(max-width:640px){.wrn-shell{bottom:8px}.wrn-brand{display:none}.wrn-bar{gap:3px;padding:5px}.wrn-count{padding:0 7px}.wrn-panel{bottom:44px;height:calc(100vh - 62px)}.wrn-issue{grid-template-columns:6px 1fr}.wrn-actions{grid-column:2}.wrn-panel-meta{display:none}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important;animation:none!important}} `; diff --git a/packages/dev-toolbar/src/server/builtin-panels.ts b/packages/dev-toolbar/src/server/builtin-panels.ts new file mode 100644 index 00000000..bb1ddb8e --- /dev/null +++ b/packages/dev-toolbar/src/server/builtin-panels.ts @@ -0,0 +1,112 @@ +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { basename, extname, join, relative } from "node:path"; +import type { DevToolbarPanel, DevToolbarPlatformSnapshot } from "../types.ts"; + +function walk(root: string, test: (file: string) => boolean): string[] { + if (!existsSync(root)) return []; + const result: string[] = []; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const file = join(root, entry.name); + if (entry.isDirectory()) result.push(...walk(file, test)); + else if (test(file)) result.push(file); + } + return result; +} + +function flatten(value: unknown, prefix = ""): string[] { + if (!value || typeof value !== "object" || Array.isArray(value)) return prefix ? [prefix] : []; + return Object.entries(value).flatMap(([key, child]) => + flatten(child, prefix ? `${prefix}.${key}` : key), + ); +} + +export interface BuiltinPanelOptions { + root: string; + platform?: DevToolbarPlatformSnapshot; + database?: { queries: unknown[]; issues: unknown[] }; + version?: { current: string; latest?: string }; +} + +/** Produce bounded, credential-free first-party inspector data. */ +export function builtinDevToolbarPanels(options: BuiltinPanelOptions): DevToolbarPanel[] { + const app = join(options.root, "app"); + const queueFiles = walk(join(app, "queues"), (file) => /\.[cm]?[jt]s$/.test(file)); + const localeFiles = walk(join(app, "locales"), (file) => extname(file) === ".json"); + const locales = localeFiles.map((file) => { + try { + return { + name: basename(file, ".json"), + keys: flatten(JSON.parse(readFileSync(file, "utf8"))), + }; + } catch { + return { name: basename(file, ".json"), keys: [] as string[], invalid: true }; + } + }); + const allKeys = new Set(locales.flatMap((locale) => locale.keys)); + const translations = locales.map((locale) => ({ + ...locale, + missing: [...allKeys].filter((key) => !locale.keys.includes(key)).slice(0, 100), + })); + const styleFiles = walk(app, (file) => /\.(?:css|wrn)$/.test(file)); + const cssBytes = styleFiles.reduce((sum, file) => sum + statSync(file).size, 0); + const memory = typeof process.memoryUsage === "function" ? process.memoryUsage() : undefined; + const database = options.database ?? { queries: [], issues: [] }; + const realtimeMonitor = (globalThis as typeof globalThis & { __wrnexusRealtimeMonitor?: unknown }) + .__wrnexusRealtimeMonitor; + const latest = options.version?.latest; + return [ + { + id: "database", + title: "SQL", + category: "database", + badge: database.issues.length, + data: { recent: database.queries.slice(-100), issues: database.issues.slice(-100) }, + }, + { + id: "queues", + title: "Queues", + category: "queues", + badge: queueFiles.length, + data: { + definitions: queueFiles.map((file) => relative(options.root, file).replace(/\\/g, "/")), + }, + }, + { + id: "realtime", + title: "Realtime", + category: "realtime", + badge: options.platform?.routes?.realtime ?? 0, + data: { + routes: options.platform?.routes?.realtime ?? 0, + monitor: realtimeMonitor ?? { rooms: 0, messages: 0, acknowledgements: 0 }, + }, + }, + { + id: "translations", + title: "Translations", + category: "translations", + badge: translations.reduce((sum, locale) => sum + locale.missing.length, 0), + data: { locales: translations }, + }, + { + id: "css", + title: "CSS", + category: "css", + badge: styleFiles.length, + data: { files: styleFiles.length, bytes: cssBytes, browserUnusedSelectorScan: true }, + }, + { + id: "performance", + title: "Memory & Vitals", + category: "performance", + data: { memory, webVitalsEndpoint: "/__wrnexus/metrics/vitals" }, + }, + { + id: "upgrades", + title: "Upgrades", + category: "upgrades", + badge: latest && latest !== options.version?.current ? 1 : 0, + data: { current: options.version?.current, latest, updateCommand: "wrnexus update --latest" }, + }, + ]; +} diff --git a/packages/dev-toolbar/src/server/index.ts b/packages/dev-toolbar/src/server/index.ts index ec13a673..9a87138e 100644 --- a/packages/dev-toolbar/src/server/index.ts +++ b/packages/dev-toolbar/src/server/index.ts @@ -4,3 +4,4 @@ export * from "./serialize.ts"; export * from "./issues.ts"; export * from "./editor.ts"; export * from "./routes.ts"; +export * from "./builtin-panels.ts"; diff --git a/packages/dev-toolbar/src/server/routes.ts b/packages/dev-toolbar/src/server/routes.ts index 8c3438f4..073f260a 100644 --- a/packages/dev-toolbar/src/server/routes.ts +++ b/packages/dev-toolbar/src/server/routes.ts @@ -11,7 +11,7 @@ export interface DevToolbarRouteOptions { editor?: string; allowOpenEditor?: boolean; platform?: DevToolbarPlatformSnapshot; - panels?: DevToolbarPanel[]; + panels?: DevToolbarPanel[] | (() => DevToolbarPanel[] | Promise); } const json = (value: unknown, status = 200) => @@ -39,8 +39,10 @@ export async function handleDevToolbarRoute( return json({ issues: options.collector.getIssues(url.searchParams.get("pathname") ?? undefined), }); - if (url.pathname === "/__wrnexus/dev-toolbar/platform" && request.method === "GET") - return json({ platform: options.platform ?? null, panels: options.panels ?? [] }); + if (url.pathname === "/__wrnexus/dev-toolbar/platform" && request.method === "GET") { + const panels = typeof options.panels === "function" ? await options.panels() : options.panels; + return json({ platform: options.platform ?? null, panels: panels ?? [] }); + } if (url.pathname === "/__wrnexus/dev-toolbar/open-editor" && request.method === "POST") { if (options.allowOpenEditor === false) return json({ error: "Open in editor is disabled." }, 403); diff --git a/packages/dev-toolbar/src/types.ts b/packages/dev-toolbar/src/types.ts index d1314617..af4e9508 100644 --- a/packages/dev-toolbar/src/types.ts +++ b/packages/dev-toolbar/src/types.ts @@ -3,6 +3,15 @@ export type DevToolbarSeverity = "error" | "warning" | "info" | "suggestion"; export type DevToolbarCategory = | "runtime" | "stores" + | "cache" + | "components" + | "hydration" + | "database" + | "queues" + | "realtime" + | "translations" + | "configuration" + | "upgrades" | "compiler" | "server" | "routing" @@ -150,6 +159,8 @@ export interface DevToolbarPanel { order?: number; issues?: unknown[]; data?: unknown; + /** Optional issue category to show when this application is selected. Defaults to id. */ + category?: DevToolbarCategory; } export interface DevToolbarPlatformSnapshot { diff --git a/packages/dev-toolbar/test/builtin-panels.test.ts b/packages/dev-toolbar/test/builtin-panels.test.ts new file mode 100644 index 00000000..dee95d48 --- /dev/null +++ b/packages/dev-toolbar/test/builtin-panels.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { builtinDevToolbarPanels } from "../src/server/builtin-panels.ts"; + +test("built-in providers expose bounded operational data without environment secrets", () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-toolbar-providers-")); + try { + mkdirSync(join(root, "app", "queues"), { recursive: true }); + mkdirSync(join(root, "app", "locales"), { recursive: true }); + writeFileSync(join(root, "app", "queues", "email.ts"), "export default {};\n"); + writeFileSync( + join(root, "app", "locales", "en.json"), + JSON.stringify({ hello: "Hello", bye: "Bye" }), + ); + writeFileSync(join(root, "app", "locales", "fr.json"), JSON.stringify({ hello: "Bonjour" })); + const panels = builtinDevToolbarPanels({ + root, + platform: { routes: { pages: 1, api: 2, realtime: 3 } }, + database: { queries: [{ durationMs: 120 }], issues: [{ code: "SLOW" }] }, + version: { current: "1.0.0", latest: "1.1.0" }, + }); + expect(panels.map((panel) => panel.id)).toEqual([ + "database", + "queues", + "realtime", + "translations", + "css", + "performance", + "upgrades", + ]); + expect(panels.find((panel) => panel.id === "queues")?.badge).toBe(1); + expect(panels.find((panel) => panel.id === "translations")?.badge).toBe(1); + expect(JSON.stringify(panels)).not.toContain("process.env"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/dev-toolbar/test/client/assets.test.ts b/packages/dev-toolbar/test/client/assets.test.ts index 76fd8ce7..77c0616a 100644 --- a/packages/dev-toolbar/test/client/assets.test.ts +++ b/packages/dev-toolbar/test/client/assets.test.ts @@ -4,5 +4,11 @@ import { DEV_TOOLBAR_CSS, DEV_TOOLBAR_RUNTIME } from "../../src/client/index.ts" test("exports usable development assets", () => { expect(DEV_TOOLBAR_RUNTIME).toContain("__wrnexusDevToolbar"); expect(DEV_TOOLBAR_RUNTIME).toContain("wrnexus:navigated"); + expect(DEV_TOOLBAR_RUNTIME).toContain('data-category="cache"'); + expect(DEV_TOOLBAR_RUNTIME).toContain("/__wrnexus/cache"); + expect(DEV_TOOLBAR_RUNTIME).toContain('data-category="accessibility"'); + expect(DEV_TOOLBAR_RUNTIME).toContain('issue("plugin/"+app.id'); + expect(DEV_TOOLBAR_RUNTIME).toContain("apps.appendChild(button)"); expect(DEV_TOOLBAR_CSS).toContain(".wrn-panel"); + expect(DEV_TOOLBAR_CSS).toContain(".wrn-plugin-panel"); }); diff --git a/packages/encryption/README.md b/packages/encryption/README.md index c4bd707f..e58e9196 100644 --- a/packages/encryption/README.md +++ b/packages/encryption/README.md @@ -1,80 +1,67 @@ # @wrnexus/encryption -> Dependency-free crypto helpers for WrNexus: authenticated symmetric encryption (AES-256-GCM), hashing, and HMAC signing. +Authenticated encryption, hashing, HMAC, key rotation, and optional encrypted HTTP exchanges for WRNexusJS. -Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework. +## Core helpers -## Overview +- `generateKey()` — random 256-bit AES key encoded as base64. +- `deriveKey(password, salt)` — PBKDF2-derived AES key. +- `encrypt(plaintext, key)` / `decrypt(payload, key)` — AES-256-GCM. +- `sha256(data)` — SHA-256 digest. +- `hmacSign(data, secret)` / `hmacVerify(...)` — HMAC-SHA256. +- `createKeyring(keys)` — active/previous key management. +- `seal()` / `open()` — versioned ciphertext with key ID. -This package provides small, focused cryptographic primitives for server-side use: encrypting secrets/tokens/database fields at rest with AES-256-GCM, deriving keys from passwords via PBKDF2, computing SHA-256 digests, and signing/verifying payloads with HMAC-SHA256. It is built entirely on the standard **Web Crypto API** (`crypto.subtle`) plus `btoa`/`atob` and `TextEncoder`/`TextDecoder` — no third-party dependencies. Reach for it whenever you need to protect sensitive values or verify webhook signatures. All functions are `async` (Web Crypto is promise-based). - -## Installation - -```bash -bun add @wrnexus/encryption -``` - -> 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 keys are exchanged as **base64 strings** and all digests/signatures as **hex strings**. - -| Export | Signature | Description | -| ------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | -| `generateKey` | `() => Promise` | Generate a random 256-bit AES key, base64-encoded. Store it as a secret. | -| `deriveKey` | `(password: string, salt: string) => Promise` | Derive a base64 AES-256 key from a password + salt using PBKDF2 (100,000 iterations, SHA-256). | -| `encrypt` | `(plaintext: string, key: string) => Promise` | AES-256-GCM encrypt a string. Returns base64 of `iv(12 bytes) ‖ ciphertext+tag`. A fresh random IV is used each call. | -| `decrypt` | `(payload: string, key: string) => Promise` | Decrypt a value produced by `encrypt`. Throws if the key is wrong or the data was tampered with. | -| `sha256` | `(data: string) => Promise` | SHA-256 hex digest of a string (e.g. content hashing, dedup keys). | -| `hmacSign` | `(data: string, secret: string) => Promise` | HMAC-SHA256 hex signature of `data` with `secret` (e.g. signing webhooks). | -| `hmacVerify` | `(data: string, secret: string, signature: string) => Promise` | Constant-time verify of an HMAC-SHA256 hex signature. | - -Notes: - -- `generateKey` produces a 32-byte (256-bit) key via `crypto.getRandomValues`. -- `encrypt`/`decrypt` require a base64-encoded 256-bit key; anything else throws `"Encryption key must be a base64 256-bit key"`. -- `decrypt` throws `"Invalid ciphertext"` if the payload is shorter than the 12-byte IV, and the underlying Web Crypto call throws on any authentication (tag) mismatch. -- `hmacVerify` compares in constant time (length check plus XOR accumulation) to avoid timing leaks. - -## Usage - -Symmetric encryption of a secret at rest: +## Encrypted HTTP envelope ```ts -import { generateKey, encrypt, decrypt } from "@wrnexus/encryption"; +import { + createEncryptedRequest, + createKeyring, + createMemoryReplayStore, + decryptEncryptedResponse, + encryptedExchange, +} from "@wrnexus/encryption"; -const key = await generateKey(); // store this safely (env/secret manager) +const keyring = createKeyring([{ id: "2026-08", secret: process.env.API_BODY_KEY!, active: true }]); -const box = await encrypt("card #1234", key); // opaque base64 string, safe to persist -const plain = await decrypt(box, key); // "card #1234" +const replayStore = createMemoryReplayStore(); + +// Server middleware. +app.use( + encryptedExchange({ + keyring, + replayStore, + maxAgeMs: 60_000, + maxBodyBytes: 1_048_576, + }), +); + +// Controlled service/native client. +const request = await createEncryptedRequest( + "https://api.example.com/private/report", + { reportId: "report-1" }, + { method: "POST", keyring }, +); +const response = await fetch(request); +const result = await decryptEncryptedResponse(response, request, { keyring }); ``` -Deriving a key from a user password instead of a random key: +The envelope binds authenticated ciphertext to: -```ts -import { deriveKey, encrypt } from "@wrnexus/encryption"; +- HTTP method +- URL path and query +- request ID +- timestamp and expiry window +- encryption key ID +- optional replay-store consumption -const key = await deriveKey("correct horse battery staple", "per-user-salt"); -const box = await encrypt("secret note", key); -``` +`encryptedBody()` decrypts request bodies only. `encryptedExchange()` also encrypts successful downstream responses while allowing application exceptions to propagate normally. `encryptedFetch()` provides a convenient controlled-client call. -Hashing and webhook signature verification: +## Security boundary -```ts -import { sha256, hmacSign, hmacVerify } from "@wrnexus/encryption"; +Encrypted HTTP bodies **do not replace TLS/HTTPS**. Always use HTTPS. -const digest = await sha256("some content"); // 64-char hex string +This layer is appropriate for service-to-service traffic, native/mobile applications, controlled agents, and selected fields protected with server-managed keys. It cannot conceal data from an end user when browser JavaScript receives the decryption key. Never ship a long-lived server encryption key to a browser. -const signature = await hmacSign(rawBody, webhookSecret); -const ok = await hmacVerify(rawBody, webhookSecret, incomingSignatureHeader); -if (!ok) throw new Error("Invalid webhook signature"); -``` - -## Requirements / Notes - -- **Bun-only.** Relies on the Web Crypto API (`crypto.subtle`, `crypto.getRandomValues`) and the global `btoa`/`atob`, `TextEncoder`/`TextDecoder` — all available in Bun's runtime. -- **No dependencies.** The package has an empty dependency set; nothing is bundled beyond standard runtime APIs. -- Algorithms: AES-256-GCM (encryption), PBKDF2 with 100k SHA-256 iterations (key derivation), SHA-256 (digest), HMAC-SHA256 (signing). -- Keep generated/derived keys and HMAC secrets out of source control; treat them as first-class secrets. +Use a shared replay store such as Redis in multi-instance deployments. The memory replay store is process-local. diff --git a/packages/encryption/package.json b/packages/encryption/package.json index 4c643e75..a5f5172b 100644 --- a/packages/encryption/package.json +++ b/packages/encryption/package.json @@ -1,10 +1,28 @@ { "name": "@wrnexus/encryption", - "version": "0.7.0", + "version": "0.8.0", "private": true, "type": "module", - "main": "src/index.ts", + "main": "./src/index.ts", "exports": { ".": "./src/index.ts" + }, + "description": "Authenticated encryption, key rotation, hashing, and optional application-layer encrypted HTTP envelopes.", + "types": "./src/index.ts", + "files": [ + "src", + "README.md" + ], + "scripts": { + "test": "bun test", + "typecheck": "tsc --noEmit", + "check": "bun run typecheck && bun run test" + }, + "devDependencies": { + "@types/bun": "^1.3.14", + "typescript": "^5.9.2" + }, + "dependencies": { + "@wrnexus/core": "workspace:*" } } diff --git a/packages/encryption/src/http.ts b/packages/encryption/src/http.ts new file mode 100644 index 00000000..578a956c --- /dev/null +++ b/packages/encryption/src/http.ts @@ -0,0 +1,367 @@ +import type { Context, Middleware } from "@wrnexus/core"; +import { open, seal, sealedKeyId, type EncryptionKeyring } from "./keyring.ts"; + +export const ENCRYPTED_HTTP_CONTENT_TYPE = "application/wrn+json"; +export const ENCRYPTED_HTTP_VERSION = "wrn-http-1"; + +const REQUEST_ID = /^[A-Za-z0-9._:-]{8,128}$/; +const KEY_ID = /^[A-Za-z0-9._-]{1,64}$/; + +export interface EncryptedHttpEnvelope { + version: typeof ENCRYPTED_HTTP_VERSION; + keyId: string; + requestId: string; + timestamp: number; + ciphertext: string; +} + +interface EncryptedHttpPayload { + method: string; + path: string; + requestId: string; + timestamp: number; + body: T; +} + +export interface ReplayStore { + consume(id: string, expiresAt: number): boolean | Promise; +} + +export interface EncryptedHttpOptions { + keyring: EncryptionKeyring; + maxAgeMs?: number; + maxBodyBytes?: number; + replayStore?: ReplayStore; + now?: () => number; + /** Require the clear request-id header used to bind encrypted responses. Default true. */ + requireRequestIdHeader?: boolean; +} + +export interface DecryptedHttpBody { + body: T; + requestId: string; + timestamp: number; + keyId: string; +} + +function createRequestId(): string { + if (typeof crypto.randomUUID === "function") return crypto.randomUUID(); + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return [...bytes].map((value) => value.toString(16).padStart(2, "0")).join(""); +} + +function normalizePath(input: string | URL): string { + const url = input instanceof URL ? input : new URL(input, "https://wrnexus.local"); + return `${url.pathname}${url.search}`; +} + +function methodOf(method: string | undefined): string { + return (method ?? "POST").toUpperCase(); +} + +function parseEnvelope(value: unknown): EncryptedHttpEnvelope { + if (!value || typeof value !== "object") throw new Error("WRN-ENCRYPTION-HTTP-ENVELOPE"); + const envelope = value as Partial; + if ( + envelope.version !== ENCRYPTED_HTTP_VERSION || + typeof envelope.keyId !== "string" || + !KEY_ID.test(envelope.keyId) || + typeof envelope.requestId !== "string" || + !REQUEST_ID.test(envelope.requestId) || + typeof envelope.timestamp !== "number" || + !Number.isFinite(envelope.timestamp) || + envelope.timestamp <= 0 || + typeof envelope.ciphertext !== "string" || + envelope.ciphertext.length < 16 || + sealedKeyId(envelope.ciphertext) !== envelope.keyId + ) { + throw new Error("WRN-ENCRYPTION-HTTP-ENVELOPE"); + } + return envelope as EncryptedHttpEnvelope; +} + +function requestHeaderId(request: Request, required: boolean): string | undefined { + const id = request.headers.get("x-wrn-request-id")?.trim(); + if (!id) { + if (required) throw new Error("WRN-ENCRYPTION-HTTP-REQUEST-ID"); + return undefined; + } + if (!REQUEST_ID.test(id)) throw new Error("WRN-ENCRYPTION-HTTP-REQUEST-ID"); + return id; +} + +export function createMemoryReplayStore(now: () => number = Date.now): ReplayStore { + const seen = new Map(); + return { + consume(id, expiresAt) { + const current = now(); + for (const [key, expiry] of seen) if (expiry <= current) seen.delete(key); + if (seen.has(id)) return false; + seen.set(id, expiresAt); + return true; + }, + }; +} + +export async function encryptHttpBody( + body: T, + input: { + keyring: EncryptionKeyring; + method?: string; + url: string | URL; + requestId?: string; + timestamp?: number; + }, +): Promise { + const id = input.requestId ?? createRequestId(); + if (!REQUEST_ID.test(id)) throw new TypeError("WRN-ENCRYPTION-HTTP-REQUEST-ID"); + const timestamp = input.timestamp ?? Date.now(); + if (!Number.isFinite(timestamp) || timestamp <= 0) { + throw new TypeError("WRN-ENCRYPTION-HTTP-TIMESTAMP"); + } + const payload: EncryptedHttpPayload = { + method: methodOf(input.method), + path: normalizePath(input.url), + requestId: id, + timestamp, + body, + }; + const ciphertext = await seal(JSON.stringify(payload), input.keyring); + return { + version: ENCRYPTED_HTTP_VERSION, + keyId: input.keyring.active().id, + requestId: id, + timestamp, + ciphertext, + }; +} + +export async function decryptHttpBody( + value: unknown, + input: { + keyring: EncryptionKeyring; + method?: string; + url: string | URL; + maxAgeMs?: number; + replayStore?: ReplayStore; + now?: () => number; + expectedRequestId?: string; + }, +): Promise> { + const envelope = parseEnvelope(value); + if (input.expectedRequestId && envelope.requestId !== input.expectedRequestId) { + throw new Error("WRN-ENCRYPTION-HTTP-REQUEST-ID"); + } + const plaintext = await open(envelope.ciphertext, input.keyring); + const payload = JSON.parse(plaintext) as Partial>; + const now = input.now?.() ?? Date.now(); + const maxAgeMs = Math.max(1_000, input.maxAgeMs ?? 5 * 60_000); + if ( + payload.requestId !== envelope.requestId || + payload.timestamp !== envelope.timestamp || + payload.method !== methodOf(input.method) || + payload.path !== normalizePath(input.url) || + !("body" in payload) + ) { + throw new Error("WRN-ENCRYPTION-HTTP-CONTEXT"); + } + if (Math.abs(now - envelope.timestamp) > maxAgeMs) { + throw new Error("WRN-ENCRYPTION-HTTP-EXPIRED"); + } + if (input.replayStore) { + const accepted = await input.replayStore.consume(envelope.requestId, now + maxAgeMs); + if (!accepted) throw new Error("WRN-ENCRYPTION-HTTP-REPLAY"); + } + return { + body: payload.body as T, + requestId: envelope.requestId, + timestamp: envelope.timestamp, + keyId: envelope.keyId, + }; +} + +export async function createEncryptedRequest( + url: string | URL, + body: T, + input: Omit & { keyring: EncryptionKeyring; requestId?: string }, +): Promise { + const { keyring, requestId, ...requestInit } = input; + const method = methodOf(requestInit.method); + const envelope = await encryptHttpBody(body, { + keyring, + method, + url, + requestId, + }); + const headers = new Headers(requestInit.headers); + headers.set("content-type", ENCRYPTED_HTTP_CONTENT_TYPE); + headers.set("accept", ENCRYPTED_HTTP_CONTENT_TYPE); + headers.set("x-wrn-request-id", envelope.requestId); + return new Request(url, { + ...requestInit, + method, + headers, + body: JSON.stringify(envelope), + }); +} + +export async function decryptRequest( + request: Request, + options: EncryptedHttpOptions, +): Promise> { + const contentLength = Number(request.headers.get("content-length") ?? "0"); + const maxBodyBytes = Math.max(1, options.maxBodyBytes ?? 1_048_576); + if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) { + throw new Error("WRN-ENCRYPTION-HTTP-BODY-LIMIT"); + } + const source = await request.text(); + if (new TextEncoder().encode(source).byteLength > maxBodyBytes) { + throw new Error("WRN-ENCRYPTION-HTTP-BODY-LIMIT"); + } + const envelope = parseEnvelope(JSON.parse(source)); + const headerRequestId = requestHeaderId(request, options.requireRequestIdHeader !== false); + if (headerRequestId && headerRequestId !== envelope.requestId) { + throw new Error("WRN-ENCRYPTION-HTTP-REQUEST-ID"); + } + return decryptHttpBody(envelope, { + keyring: options.keyring, + method: request.method, + url: request.url, + maxAgeMs: options.maxAgeMs, + replayStore: options.replayStore, + now: options.now, + expectedRequestId: headerRequestId, + }); +} + +export async function encryptResponse( + body: T, + request: Request, + options: EncryptedHttpOptions & { status?: number; headers?: HeadersInit }, +): Promise { + const originalRequestId = requestHeaderId(request, options.requireRequestIdHeader !== false); + const envelope = await encryptHttpBody(body, { + keyring: options.keyring, + method: request.method, + url: request.url, + requestId: originalRequestId, + }); + const headers = new Headers(options.headers); + headers.set("content-type", `${ENCRYPTED_HTTP_CONTENT_TYPE}; charset=utf-8`); + headers.set("cache-control", "no-store"); + headers.set("x-wrn-request-id", envelope.requestId); + return new Response(JSON.stringify(envelope), { status: options.status ?? 200, headers }); +} + +export async function decryptEncryptedResponse( + response: Response, + request: Request, + options: EncryptedHttpOptions, +): Promise> { + if ( + !response.headers.get("content-type")?.toLowerCase().startsWith(ENCRYPTED_HTTP_CONTENT_TYPE) + ) { + throw new Error("WRN-ENCRYPTION-HTTP-RESPONSE-CONTENT-TYPE"); + } + const requestId = requestHeaderId(request, options.requireRequestIdHeader !== false); + const responseRequestId = response.headers.get("x-wrn-request-id")?.trim(); + if (requestId && responseRequestId !== requestId) { + throw new Error("WRN-ENCRYPTION-HTTP-REQUEST-ID"); + } + return decryptHttpBody(await response.json(), { + keyring: options.keyring, + method: request.method, + url: request.url, + maxAgeMs: options.maxAgeMs, + replayStore: options.replayStore, + now: options.now, + expectedRequestId: requestId, + }); +} + +export async function encryptedFetch( + url: string | URL, + body: TRequest, + input: Omit & EncryptedHttpOptions, +): Promise { + const request = await createEncryptedRequest(url, body, input); + const response = await fetch(request); + if (!response.ok) throw new Error(`WRN-ENCRYPTION-HTTP-RESPONSE: ${response.status}`); + const decrypted = await decryptEncryptedResponse(response, request, input); + return decrypted.body; +} + +export function encryptedBody(options: EncryptedHttpOptions): Middleware { + return async (ctx: Context, next) => { + if ( + !ctx.req.headers.get("content-type")?.toLowerCase().startsWith(ENCRYPTED_HTTP_CONTENT_TYPE) + ) { + return Response.json( + { ok: false, error: "Encrypted request body required" }, + { status: 415 }, + ); + } + try { + const result = await decryptRequest(ctx.req, options); + ctx.locals.encryptedBody = result.body; + ctx.locals.encryptedRequest = result; + return next(); + } catch (error) { + return Response.json( + { ok: false, error: error instanceof Error ? error.message : "Invalid encrypted body" }, + { status: 400, headers: { "cache-control": "no-store" } }, + ); + } + }; +} + +export function encryptedExchange( + options: EncryptedHttpOptions & { encryptResponses?: boolean }, +): Middleware { + return async (ctx: Context, next) => { + if ( + !ctx.req.headers.get("content-type")?.toLowerCase().startsWith(ENCRYPTED_HTTP_CONTENT_TYPE) + ) { + return Response.json( + { ok: false, error: "Encrypted request body required" }, + { status: 415, headers: { "cache-control": "no-store" } }, + ); + } + + let result: DecryptedHttpBody; + try { + result = await decryptRequest(ctx.req, options); + } catch (error) { + return Response.json( + { ok: false, error: error instanceof Error ? error.message : "Invalid encrypted exchange" }, + { status: 400, headers: { "cache-control": "no-store" } }, + ); + } + + ctx.locals.encryptedBody = result.body; + ctx.locals.encryptedRequest = result; + const response = await next(); + if (options.encryptResponses === false || response.status === 204 || response.status === 304) { + return response; + } + if ( + response.headers.get("content-type")?.toLowerCase().startsWith(ENCRYPTED_HTTP_CONTENT_TYPE) + ) { + return response; + } + const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; + const body = contentType.includes("json") + ? await response.clone().json() + : await response.text(); + const headers = new Headers(response.headers); + headers.delete("content-length"); + headers.delete("content-encoding"); + headers.delete("etag"); + return encryptResponse(body, ctx.req, { + ...options, + status: response.status, + headers, + }); + }; +} diff --git a/packages/encryption/src/index.ts b/packages/encryption/src/index.ts index 0d293fdf..467b57f4 100644 --- a/packages/encryption/src/index.ts +++ b/packages/encryption/src/index.ts @@ -140,3 +140,23 @@ export async function deriveKey(password: string, salt: string): Promise } export { createKeyring, seal, open, sealedKeyId, needsRotation } from "./keyring.ts"; export type { EncryptionKey, EncryptionKeyring } from "./keyring.ts"; +export { + ENCRYPTED_HTTP_CONTENT_TYPE, + ENCRYPTED_HTTP_VERSION, + createMemoryReplayStore, + encryptHttpBody, + decryptHttpBody, + createEncryptedRequest, + decryptRequest, + encryptResponse, + decryptEncryptedResponse, + encryptedFetch, + encryptedBody, + encryptedExchange, +} from "./http.ts"; +export type { + EncryptedHttpEnvelope, + EncryptedHttpOptions, + DecryptedHttpBody, + ReplayStore, +} from "./http.ts"; diff --git a/packages/encryption/test/http.test.ts b/packages/encryption/test/http.test.ts new file mode 100644 index 00000000..9b19dc4c --- /dev/null +++ b/packages/encryption/test/http.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, test } from "bun:test"; +import { + createEncryptedRequest, + createKeyring, + createMemoryReplayStore, + decryptEncryptedResponse, + decryptHttpBody, + decryptRequest, + encryptHttpBody, + encryptResponse, + encryptedExchange, +} from "../src/index.ts"; + +const keyring = createKeyring([ + { + id: "primary", + secret: "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=", + active: true, + }, +]); + +describe("encrypted HTTP envelopes", () => { + test("binds ciphertext to method, URL, request id, age, and replay state", async () => { + const now = 10_000; + const replay = createMemoryReplayStore(() => now); + const envelope = await encryptHttpBody( + { message: "secret" }, + { + keyring, + method: "POST", + url: "https://api.example.test/private?view=full", + requestId: "request-1", + timestamp: now, + }, + ); + + const result = await decryptHttpBody<{ message: string }>(envelope, { + keyring, + method: "POST", + url: "https://api.example.test/private?view=full", + replayStore: replay, + now: () => now, + expectedRequestId: "request-1", + }); + expect(result.body).toEqual({ message: "secret" }); + + await expect( + decryptHttpBody(envelope, { + keyring, + method: "POST", + url: "https://api.example.test/private?view=full", + replayStore: replay, + now: () => now, + }), + ).rejects.toThrow("REPLAY"); + + await expect( + decryptHttpBody(envelope, { + keyring, + method: "GET", + url: "https://api.example.test/private?view=full", + now: () => now, + }), + ).rejects.toThrow("CONTEXT"); + }); + + test("checks the clear request-id header against the encrypted envelope", async () => { + const request = await createEncryptedRequest( + "https://api.example.test/private", + { value: 1 }, + { keyring, requestId: "request-2", method: "POST" }, + ); + request.headers.set("x-wrn-request-id", "tampered-id"); + await expect(decryptRequest(request, { keyring })).rejects.toThrow("REQUEST-ID"); + }); + + test("encrypts a response using and verifying the original request context", async () => { + const request = await createEncryptedRequest( + "https://api.example.test/private", + { value: 1 }, + { keyring, requestId: "request-3", method: "POST" }, + ); + const response = await encryptResponse({ ok: true }, request, { keyring }); + const result = await decryptEncryptedResponse<{ ok: boolean }>(response, request, { keyring }); + expect(result.body.ok).toBe(true); + expect(response.headers.get("x-wrn-request-id")).toBe("request-3"); + expect(response.headers.get("cache-control")).toBe("no-store"); + + const other = await createEncryptedRequest( + "https://api.example.test/private", + { value: 2 }, + { keyring, requestId: "request-other", method: "POST" }, + ); + await expect(decryptEncryptedResponse(response, other, { keyring })).rejects.toThrow( + "REQUEST-ID", + ); + }); + + test("provides transparent encrypted request and response middleware", async () => { + const request = await createEncryptedRequest( + "https://api.example.test/private", + { value: 7 }, + { keyring, requestId: "request-4", method: "POST" }, + ); + const context = { + req: request, + locals: {}, + } as Parameters>[0]; + const response = await encryptedExchange({ keyring })(context, () => + Response.json({ received: context.locals.encryptedBody }), + ); + const result = await decryptEncryptedResponse<{ + received: { value: number }; + }>(response, request, { keyring }); + expect(result.body.received.value).toBe(7); + }); + + test("does not convert application exceptions into invalid-body responses", async () => { + const request = await createEncryptedRequest( + "https://api.example.test/private", + { value: 7 }, + { keyring, requestId: "request-5", method: "POST" }, + ); + const context = { + req: request, + locals: {}, + } as Parameters>[0]; + await expect( + encryptedExchange({ keyring })(context, () => { + throw new Error("application failed"); + }), + ).rejects.toThrow("application failed"); + }); +}); diff --git a/packages/graphql/README.md b/packages/graphql/README.md new file mode 100644 index 00000000..ad177b23 --- /dev/null +++ b/packages/graphql/README.md @@ -0,0 +1,5 @@ +# @wrnexus/graphql + +Optional GraphQL endpoint plugin. Supply the executor from GraphQL.js, GraphQL Yoga, Mercurius, +or another maintained engine; WRNexus owns bounded HTTP input, depth/alias limits, introspection +policy, generic production errors and plugin route integration. diff --git a/packages/graphql/package.json b/packages/graphql/package.json new file mode 100644 index 00000000..6831c725 --- /dev/null +++ b/packages/graphql/package.json @@ -0,0 +1,13 @@ +{ + "name": "@wrnexus/graphql", + "version": "0.8.0", + "type": "module", + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./plugin": "./src/plugin.ts" + }, + "dependencies": { + "@wrnexus/plugin": "workspace:*" + } +} diff --git a/packages/graphql/src/index.ts b/packages/graphql/src/index.ts new file mode 100644 index 00000000..4782709c --- /dev/null +++ b/packages/graphql/src/index.ts @@ -0,0 +1,88 @@ +export interface GraphqlRequest { + query: string; + variables?: Record; + operationName?: string; +} +export interface GraphqlExecutionResult { + data?: unknown; + errors?: Array<{ message: string; [key: string]: unknown }>; +} +export interface GraphqlOptions { + execute( + request: GraphqlRequest, + context: { request: Request; signal: AbortSignal }, + ): GraphqlExecutionResult | Promise; + maxQueryBytes?: number; + maxDepth?: number; + maxAliases?: number; + allowIntrospection?: boolean; +} + +function queryMetrics(query: string) { + let depth = 0, + maxDepth = 0, + aliases = 0; + let string = false, + escaped = false; + for (let index = 0; index < query.length; index++) { + const char = query[index]!; + if (string) { + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === '"') string = false; + continue; + } + if (char === '"') string = true; + else if (char === "{") maxDepth = Math.max(maxDepth, ++depth); + else if (char === "}") depth--; + else if (char === ":" && /[A-Za-z0-9_]\s*$/.test(query.slice(Math.max(0, index - 40), index))) + aliases++; + } + return { depth: maxDepth, aliases }; +} + +export function createGraphqlHandler(options: GraphqlOptions) { + return async (request: Request): Promise => { + if (request.method !== "POST") + return new Response("Method Not Allowed", { status: 405, headers: { allow: "POST" } }); + const maxBytes = options.maxQueryBytes ?? 100_000; + const length = Number(request.headers.get("content-length") ?? 0); + if (length > maxBytes) + return Response.json( + { errors: [{ message: "GraphQL query is too large" }] }, + { status: 413 }, + ); + const body = (await request.json().catch(() => null)) as GraphqlRequest | null; + if ( + !body || + typeof body.query !== "string" || + new TextEncoder().encode(body.query).length > maxBytes + ) + return Response.json( + { errors: [{ message: "A bounded GraphQL query string is required" }] }, + { status: 400 }, + ); + if (options.allowIntrospection === false && /\b__(?:schema|type)\b/.test(body.query)) + return Response.json( + { errors: [{ message: "GraphQL introspection is disabled" }] }, + { status: 403 }, + ); + const metrics = queryMetrics(body.query); + if (metrics.depth > (options.maxDepth ?? 12) || metrics.aliases > (options.maxAliases ?? 50)) + return Response.json( + { errors: [{ message: "GraphQL query complexity limit exceeded" }] }, + { status: 400 }, + ); + try { + const result = await options.execute(body, { request, signal: request.signal }); + return Response.json(result, { + status: result.errors?.length && result.data === undefined ? 400 : 200, + headers: { "cache-control": "no-store" }, + }); + } catch { + return Response.json({ errors: [{ message: "GraphQL execution failed" }] }, { status: 500 }); + } + }; +} + +export { graphqlPlugin } from "./plugin.ts"; diff --git a/packages/graphql/src/plugin.ts b/packages/graphql/src/plugin.ts new file mode 100644 index 00000000..dada17f2 --- /dev/null +++ b/packages/graphql/src/plugin.ts @@ -0,0 +1,24 @@ +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { WrnexusPlugin } from "@wrnexus/plugin"; +import { createGraphqlHandler, type GraphqlOptions } from "./index.ts"; + +export function graphqlPlugin(options: GraphqlOptions & { path?: string }): WrnexusPlugin { + return { + name: "@wrnexus/graphql", + version: "0.8.0", + setup() { + (globalThis as any).__wrnexusGraphqlHandler = createGraphqlHandler(options); + }, + routeEntries: [ + { + kind: "api", + path: options.path ?? "/api/graphql", + entry: join(dirname(fileURLToPath(import.meta.url)), "route.ts"), + }, + ], + documentation: ["packages/graphql/README.md"], + typeDefinitions: ["packages/graphql/src/index.ts"], + }; +} +export default graphqlPlugin; diff --git a/packages/graphql/src/route.ts b/packages/graphql/src/route.ts new file mode 100644 index 00000000..0defaf84 --- /dev/null +++ b/packages/graphql/src/route.ts @@ -0,0 +1,6 @@ +export async function POST(ctx: { req: Request }): Promise { + const handler = (globalThis as any).__wrnexusGraphqlHandler; + if (typeof handler !== "function") + return new Response("GraphQL is not configured", { status: 503 }); + return handler(ctx.req); +} diff --git a/packages/graphql/test/graphql.test.ts b/packages/graphql/test/graphql.test.ts new file mode 100644 index 00000000..16551f86 --- /dev/null +++ b/packages/graphql/test/graphql.test.ts @@ -0,0 +1,61 @@ +import { expect, test } from "bun:test"; +import { createGraphqlHandler } from "../src/index.ts"; + +test("GraphQL handler enforces method, introspection and complexity limits", async () => { + const handler = createGraphqlHandler({ + allowIntrospection: false, + maxDepth: 2, + execute: async () => ({ data: { ok: true } }), + }); + expect((await handler(new Request("https://test/graphql"))).status).toBe(405); + expect( + ( + await handler( + new Request("https://test/graphql", { + method: "POST", + body: JSON.stringify({ query: "{ __schema { types { name } } }" }), + }), + ) + ).status, + ).toBe(403); + expect( + ( + await handler( + new Request("https://test/graphql", { + method: "POST", + body: JSON.stringify({ query: "{ a { b { c } } }" }), + }), + ) + ).status, + ).toBe(400); +}); + +test("GraphQL handler delegates valid requests and hides thrown errors", async () => { + const handler = createGraphqlHandler({ + execute: async (request) => ({ data: { query: request.query } }), + }); + const response = await handler( + new Request("https://test/graphql", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ query: "{ health }" }), + }), + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ data: { query: "{ health }" } }); + const failing = createGraphqlHandler({ + execute: async () => { + throw new Error("secret"); + }, + }); + expect( + await ( + await failing( + new Request("https://test/graphql", { + method: "POST", + body: JSON.stringify({ query: "{ health }" }), + }), + ) + ).text(), + ).not.toContain("secret"); +}); diff --git a/packages/helpers/package.json b/packages/helpers/package.json index 14996448..33cb4e71 100644 --- a/packages/helpers/package.json +++ b/packages/helpers/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/helpers", - "version": "0.7.0", + "version": "0.8.0", "private": true, "type": "module", "description": "Safe convenience helpers for WrNexus request contexts and common application flows.", diff --git a/packages/i18n/README.md b/packages/i18n/README.md index afb8e863..e173a22e 100644 --- a/packages/i18n/README.md +++ b/packages/i18n/README.md @@ -1,167 +1,93 @@ # @wrnexus/i18n -> Per-request translations plus locale-aware number, date, and currency formatting for WrNexus apps. +Recursive locale loading, fallback resolution, SSR/browser translations, locale formatting, and language UI blocks for WRNexusJS. -Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework. +## Locale files -## Overview +Both layouts can be used together: -`@wrnexus/i18n` loads locale files from `app/locales/.json`, resolves the -active language for each request (cookie → `Accept-Language` → default), and -builds a `t(key, params)` translator used both in server code and in `.wrn` -views. It also ships Intl-based formatting helpers and a tiny client runtime that -wires up a language switcher. Translation lookup, language resolution, and HTML -marker rewriting run server-side; only the small `I18N_RUNTIME` snippet runs in -the browser. - -## Installation - -```bash -bun add @wrnexus/i18n +```text +app/locales/en.json +app/locales/en/common.json +app/locales/en/auth.json +app/locales/mr/common.json ``` -> Private package — the machine must be authenticated to the `wrnexus` npm org -> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported). - -## API - -### Loading & resolving - -| Export | Signature | Description | -| ------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -| `loadLocales` | `(dir: string) => Record` | Reads every `.json` in `dir` into a `{ lang: messages }` map. Missing dir → `{}`; a bad file is warned and skipped. | -| `resolveI18n` | `(messages: Record, config?: I18nConfig) => ResolvedI18n` | Merges loaded messages + config into a resolved bundle (default lang, supported langs, messages). | -| `resolveLang` | `(i18n: ResolvedI18n, cookieValue: string \| undefined, acceptLanguage: string \| null) => string` | Picks the active language: matching cookie → best `Accept-Language` tag (falls back to base tag, e.g. `en-US` → `en`) → `i18n.default`. | -| `makeT` | `(i18n: ResolvedI18n, lang: string) => TFunction` | Builds a translator resolving current language → default → the key itself, with `{param}` interpolation. | - -### Types & constants - -| Export | Kind | Notes | -| -------------- | ----------- | ------------------------------------------------------------------------------ | -| `Messages` | `type` | `Record` — a locale's messages (supports nested/dotted keys). | -| `I18nConfig` | `interface` | `{ default?: string; locales?: string[] }`. | -| `ResolvedI18n` | `interface` | `{ default: string; langs: string[]; messages: Record }`. | -| `LANG_COOKIE` | `const` | `"wire-lang"` — the cookie the language is read from / written to. | -| `I18N_JS_HREF` | `const` | `"/__wrnexus/i18n.js"` — URL the client runtime is served at. | - -### HTML & client runtime - -| Export | Signature | Description | -| ---------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `translateHtml` | `(html: string, t: TFunction) => string` | Rewrites markers in rendered HTML: `t:="key"` → `=""` (attribute-escaped) and `` → element text becomes the translation (HTML-escaped). No-op unless a marker is present. | -| `renderI18nData` | `(i18n: ResolvedI18n, lang: string) => string` | JS snippet setting `window.__wireI18n = { lang, langs, default }` for the client switcher. | -| `I18N_RUNTIME` | `const string` | Browser IIFE that binds `[data-wire-lang-set="es"]` clicks and `select[data-wire-lang]` changes to set the `wire-lang` cookie and reload. Exposes `window.__wireLang.set(lang)`. | - -### Formatting helpers (re-exported from `./format.ts`) - -| Export | Signature | Example | -| -------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------- | -| `formatNumber` | `(value: number, lang: string, options?: Intl.NumberFormatOptions) => string` | `1234.5 → "1,234.5"` | -| `formatCurrency` | `(value: number, currency: string, lang: string) => string` | `9.99, "USD" → "$9.99"` | -| `formatDate` | `(value: Date \| number \| string, lang: string, options?: Intl.DateTimeFormatOptions) => string` | defaults to `{ dateStyle: "medium" }` | -| `formatRelativeTime` | `(value: number, unit: Intl.RelativeTimeFormatUnit, lang: string) => string` | `-3, "day" → "3 days ago"` (`numeric: "auto"`) | -| `plural` | `(count: number, forms: Partial>, lang: string) => string` | picks CLDR form; `#` is replaced by `count` | - -## Usage - -### Server: load, resolve, translate +Namespaced files become keys such as `common.save` and `auth.signIn`. ```ts -import { - loadLocales, - resolveI18n, - resolveLang, - makeT, - translateHtml, - LANG_COOKIE, -} from "@wrnexus/i18n"; +import { loadLocales, makeT, resolveI18n, resolveLang } from "@wrnexus/i18n"; -// app/locales/en.json, app/locales/es.json -const messages = loadLocales("app/locales"); -const i18n = resolveI18n(messages, { default: "en", locales: ["en", "es"] }); +const i18n = resolveI18n(loadLocales("app/locales", { strict: true }), { + default: "en", + locales: ["en", "mr", "hi"], + fallbacks: { "mr-IN": ["mr", "en"] }, + cookie: { name: "wire-lang", sameSite: "Lax", secure: true }, +}); -// Per request: -const lang = resolveLang(i18n, req.cookies?.[LANG_COOKIE], req.headers.get("accept-language")); +const lang = resolveLang(i18n, cookieValue, request.headers.get("accept-language")); const t = makeT(i18n, lang); - -t("nav.home"); // dotted key → "Home" -t("greeting", { name: "Ada" }); // "Hello, {name}" → "Hello, Ada" - -// After rendering a .wrn view, resolve translation markers in the HTML: -const finalHtml = translateHtml(renderedHtml, t); +t("common.hello", { name: "Ajay" }); ``` -`app/locales/en.json`: +## Resolution behavior -```json -{ - "nav": { "home": "Home" }, - "greeting": "Hello, {name}" -} -``` +- normalized BCP-47-style locale names +- cookie preference +- weighted `Accept-Language` +- wildcard language ranges +- regional base fallback +- explicit fallback chains +- configured default language +- automatic RTL for Arabic, Hebrew, Persian, Urdu, and related languages -### Views: translation markers +Locale JSON is size-limited and rejects prototype-pollution keys. Recursive namespace collisions are resolved safely. + +## Views and runtime ```html -

    Home

    +

    Dashboard

    ``` -`translateHtml` replaces the element text for `data-t` and the attribute value for -any `t:` (e.g. `t:placeholder`, `t:aria-label`). +Text and translated attributes are resolved during SSR. Active/fallback messages are serialized safely for the language runtime, which rebinds `data-t` markers after client navigation. -### Client: language switcher +Enable `i18nPlugin()` to use: -```ts -import { renderI18nData, I18N_RUNTIME, I18N_JS_HREF } from "@wrnexus/i18n"; +- `` +- `` -// In the document : -const head = ` - - -`; +`LanguageSwitcher` renders a native `select[data-wire-lang]`. The packaged runtime validates the +selection against the configured locales, writes the configured language cookie, updates the +document `lang`/`dir` attributes, emits `wrnexus:language-change`, and reloads so the next SSR +request uses the same cookie. No application-owned browser script is required. -// Serve I18N_RUNTIME at I18N_JS_HREF; then in markup: -// -// -``` +## Formatting -### Formatting +- `formatNumber` +- `formatCurrency` +- `formatDate` +- `formatRelativeTime` +- `plural` +- `createLocaleFormatter` +- `translationCoverage` + Localization tooling can extract statically discoverable `t("key")`, + `i18n.t("key")`, and `data-i18n="key"` usage, compare every locale with a + reference, and create layout-stressing pseudo-locales: ```ts import { - formatNumber, - formatCurrency, - formatDate, - formatRelativeTime, - plural, + auditLocaleKeys, + createPseudoLocale, + extractTranslationKeysFromFiles, } from "@wrnexus/i18n"; -formatNumber(1234.5, lang); // "1,234.5" -formatCurrency(9.99, "USD", lang); // "$9.99" -formatDate(Date.now(), lang); // "Jul 4, 2026" -formatRelativeTime(-3, "day", lang); // "3 days ago" -plural(2, { one: "# item", other: "# items" }, lang); // "2 items" +const used = extractTranslationKeysFromFiles(sourceFiles); +const coverage = auditLocaleKeys(messages, "en"); +const enXA = createPseudoLocale(messages.en); +const arXB = createPseudoLocale(messages.en, { rtl: true }); ``` -## Configuration - -`resolveI18n` accepts an `I18nConfig`: - -- `default` — fallback language; used when nothing else matches. Ignored if it has - no loaded messages, in which case the first supported language is used. -- `locales` — explicit supported-language list; defaults to the loaded locale names. - -Language resolution order at request time (`resolveLang`): a supported `wire-lang` -cookie value → the first matching `Accept-Language` tag (or its base subtag) → the -resolved default. - -## Requirements / Notes - -- **Bun-only.** Locale loading uses `node:fs` (`existsSync`, `readdirSync`, - `readFileSync`) and `node:path`; formatting relies on the platform `Intl` APIs. -- Works with [`@wrnexus/core`](../core) — `TFunction` (the `t(key, params)` type) - comes from core, and the resolved translator is exposed as `ctx.t` / `ctx.lang` - in request handling. -- Nested message objects are supported: keys are looked up whole first, then split - on `.` to walk the object tree. +Pseudo-localization preserves interpolation placeholders and markup tags. RTL +pseudo output uses Unicode direction controls, while runtime direction detection +continues to derive `rtl` from Arabic and other RTL language subtags. diff --git a/packages/i18n/components/LanguageSwitcher.wrn b/packages/i18n/components/LanguageSwitcher.wrn new file mode 100644 index 00000000..2afd2cf2 --- /dev/null +++ b/packages/i18n/components/LanguageSwitcher.wrn @@ -0,0 +1,105 @@ +component LanguageSwitcher { + outputs { + change(payload: { locale: string }) + } + props { + locales: unknown[] = [] + current: string = "en" + label: string = "Language" + placeholder: string = "Choose language" + helperText: string = "" + variant: string = "select" + compact: boolean = false + responsive: boolean = true + fullWidth: boolean = false + showLabel: boolean = true + showHelper: boolean = true + icon: string = "icon-[lucide--languages]" + color: string = "primary" + size: string = "md" + class: string = "" + } + functions { + client function changed(sourceEvent) { + output.change({ locale: sourceEvent.currentTarget.value }) + } + client function selected(locale, sourceEvent) { + output.change({ locale: locale }) + } + } + view { +
    + {#if variant == "segmented"} +
    + {#if showLabel}{label}{/if} +
    + {#each locales as locale} + + {/each} +
    + {#if showHelper && helperText}{helperText}{/if} +
    + {/if} + {#if variant != "segmented"} + + {/if} +
    + } + + style { + .wire-language-switcher { min-width: 13rem; color: var(--wire-color-text); } + .wire-language-switcher--full, .wire-language-switcher--full .wire-language-switcher__field { width: 100%; } + .wire-language-switcher__field, .wire-language-switcher__fieldset { display: flex; margin: 0; padding: 0; border: 0; flex-direction: column; gap: .4rem; } + .wire-language-switcher__label { padding: 0; font-size: .8125rem; line-height: 1.2; font-weight: 650; color: var(--wire-color-text); } + .wire-language-switcher__control { position: relative; display: flex; align-items: center; } + .wire-language-switcher__select { width: 100%; min-height: 2.75rem; appearance: none; border: 1px solid var(--wire-color-border); border-radius: var(--wire-radius-lg); background: var(--wire-color-surface); padding: .65rem 2.5rem .65rem 2.35rem; color: var(--wire-color-text); font: inherit; font-size: .875rem; font-weight: 550; box-shadow: 0 1px 2px color-mix(in srgb, var(--wire-color-text) 7%, transparent); outline: none; transition: border-color .16s ease, box-shadow .16s ease, background .16s ease; } + .wire-language-switcher__select:hover { border-color: color-mix(in srgb, var(--wire-color-primary) 45%, var(--wire-color-border)); } + .wire-language-switcher__select:focus-visible { border-color: var(--wire-color-primary); box-shadow: 0 0 0 3px color-mix(in srgb, var(--wire-color-primary) 18%, transparent); } + .wire-language-switcher__icon, .wire-language-switcher__chevron { position: absolute; z-index: 1; width: 1rem; height: 1rem; pointer-events: none; color: var(--wire-color-muted); } + .wire-language-switcher__icon { left: .8rem; } + .wire-language-switcher__chevron { right: .8rem; } + .wire-language-switcher__helper { font-size: .72rem; line-height: 1.35; color: var(--wire-color-muted); } + .wire-language-switcher--compact { min-width: 0; } + .wire-language-switcher--compact .wire-language-switcher__label, .wire-language-switcher--compact .wire-language-switcher__helper { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; } + .wire-language-switcher--compact .wire-language-switcher__select { min-height: 2.25rem; border-radius: 999px; padding-top: .4rem; padding-bottom: .4rem; } + .wire-language-switcher__segments { display: inline-flex; flex-wrap: wrap; gap: .25rem; border: 1px solid var(--wire-color-border); border-radius: var(--wire-radius-xl); background: var(--wire-color-surface-2); padding: .25rem; } + .wire-language-switcher__segment { min-height: 2.25rem; border: 0; border-radius: var(--wire-radius-lg); background: transparent; padding: .45rem .8rem; color: var(--wire-color-muted); font: inherit; font-size: .8125rem; font-weight: 650; cursor: pointer; transition: color .16s ease, background .16s ease, box-shadow .16s ease; } + .wire-language-switcher__segment:hover { color: var(--wire-color-text); } + .wire-language-switcher__segment[aria-pressed="true"] { background: var(--wire-color-surface); color: var(--wire-color-primary); box-shadow: 0 1px 3px color-mix(in srgb, var(--wire-color-text) 12%, transparent); } + .wire-language-switcher__segment:focus-visible { outline: 2px solid var(--wire-color-primary); outline-offset: 2px; } + .wire-language-switcher__short-label { display: none; } + @media (max-width: 640px) { .wire-language-switcher--responsive { width: 100%; min-width: 0; } .wire-language-switcher--responsive .wire-language-switcher__segments { display: grid; width: 100%; grid-template-columns: repeat(auto-fit, minmax(3.5rem, 1fr)); } .wire-language-switcher--responsive .wire-language-switcher__long-label { display: none; } .wire-language-switcher--responsive .wire-language-switcher__short-label { display: inline; } } + @media (prefers-reduced-motion: reduce) { .wire-language-switcher__select, .wire-language-switcher__segment { transition: none; } } + } +} diff --git a/packages/i18n/components/LocaleStatus.wrn b/packages/i18n/components/LocaleStatus.wrn new file mode 100644 index 00000000..8c6d081e --- /dev/null +++ b/packages/i18n/components/LocaleStatus.wrn @@ -0,0 +1,21 @@ +component LocaleStatus { + props { + locale: string = "en" + direction: string = "ltr" + translated: number = 0 + total: number = 0 + label: string = "Current language" + color: string = "primary" + size: string = "sm" + class: string = "" + } + view { + +
    + + + {#if total > 0}{translated}/{total} translations{/if} +
    +
    + } +} diff --git a/packages/i18n/package.json b/packages/i18n/package.json index 3c79f31a..be174118 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,13 +1,36 @@ { "name": "@wrnexus/i18n", - "version": "0.7.0", + "version": "0.8.0", "private": true, "type": "module", - "main": "src/index.ts", + "main": "./src/index.ts", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./plugin": "./src/plugin.ts", + "./components/*": "./components/*" }, "dependencies": { - "@wrnexus/core": "workspace:*" + "@wrnexus/core": "workspace:*", + "@wrnexus/plugin": "workspace:*", + "@wrnexus/ui": "workspace:*" + }, + "description": "Locale loading, fallback resolution, SSR/browser translations, formatters, and WRNexusJS language components.", + "types": "./src/index.ts", + "files": [ + "src", + "components", + "README.md" + ], + "devDependencies": { + "@types/bun": "^1.3.14", + "typescript": "^5.9.2", + "@wrnexus/syntax": "workspace:*" + }, + "wrnexus": { + "plugin": { + "plugin": "./src/plugin.ts", + "export": "default", + "factory": true + } } } diff --git a/packages/i18n/src/advanced.ts b/packages/i18n/src/advanced.ts index f9f2745f..29ce63b7 100644 --- a/packages/i18n/src/advanced.ts +++ b/packages/i18n/src/advanced.ts @@ -73,7 +73,11 @@ export interface LocaleFormatter { list(values: string[], options?: Intl.ListFormatOptions): string; } -export function createLocaleFormatter(locale: string, timeZone?: string): LocaleFormatter { +export function createLocaleFormatter( + locale: string, + timeZone?: string, + calendar?: string, +): LocaleFormatter { const numberCache = new Map(); const dateCache = new Map(); const key = (value: unknown) => JSON.stringify(value ?? {}); @@ -93,7 +97,11 @@ export function createLocaleFormatter(locale: string, timeZone?: string): Locale ); }, date(value, options = { dateStyle: "medium" }) { - const resolved = { ...options, ...(timeZone ? { timeZone } : {}) }; + const resolved = { + ...options, + ...(timeZone ? { timeZone } : {}), + ...(calendar ? { calendar } : {}), + }; const cacheKey = key(resolved); let format = dateCache.get(cacheKey); if (!format) { @@ -108,18 +116,60 @@ export function createLocaleFormatter(locale: string, timeZone?: string): Locale }; } -/** Lightweight plural templates: `{count, plural, one {# item} other {# items}}`. */ +function complexExpression(template: string, start: number) { + const header = /^\{(\w+),\s*(plural|select),\s*/.exec(template.slice(start)); + if (!header) return null; + let cursor = start + header[0].length; + const choices: Record = {}; + while (cursor < template.length) { + while (/\s/.test(template[cursor] ?? "")) cursor++; + if (template[cursor] === "}") + return { name: header[1]!, kind: header[2]!, choices, end: cursor + 1 }; + const key = /^(=?[\w-]+)/.exec(template.slice(cursor)); + if (!key) return null; + cursor += key[0].length; + while (/\s/.test(template[cursor] ?? "")) cursor++; + if (template[cursor] !== "{") return null; + const bodyStart = ++cursor; + let depth = 1; + while (cursor < template.length && depth) { + if (template[cursor] === "{") depth++; + else if (template[cursor] === "}") depth--; + cursor++; + } + if (depth) return null; + choices[key[0]] = template.slice(bodyStart, cursor - 1); + } + return null; +} + +/** ICU-style plural/select templates with exact values and recursive interpolation. */ export function formatMessage( template: string, params: Record, locale: string, ): string { - const plural = /\{(\w+),\s*plural,\s*one\s*\{([^{}]*)\}\s*other\s*\{([^{}]*)\}\s*\}/g; - let result = template.replace(plural, (_match, name: string, one: string, other: string) => { - const value = Number(params[name] ?? 0); - const selected = new Intl.PluralRules(locale).select(value) === "one" ? one : other; - return selected.replace(/#/g, String(value)); - }); + let result = ""; + for (let cursor = 0; cursor < template.length;) { + const expression = template[cursor] === "{" ? complexExpression(template, cursor) : null; + if (!expression) { + result += template[cursor++]!; + continue; + } + const raw = params[expression.name]; + const selector = expression.kind === "plural" ? `=${Number(raw ?? 0)}` : String(raw ?? "other"); + const category = + expression.kind === "plural" + ? new Intl.PluralRules(locale).select(Number(raw ?? 0)) + : selector; + const selected = + expression.choices[selector] ?? + expression.choices[category] ?? + expression.choices.other ?? + ""; + result += formatMessage(selected.replace(/#/g, String(raw ?? 0)), params, locale); + cursor = expression.end; + } result = result.replace(/\{(\w+)\}/g, (_match, name: string) => name in params ? String(params[name]) : `{${name}}`, ); diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts index 13c8cf6f..bdd5dba1 100644 --- a/packages/i18n/src/index.ts +++ b/packages/i18n/src/index.ts @@ -1,105 +1,373 @@ /** - * @wrnexus/i18n — translations for pages and API responses. - * - * Locales live in `app/locales/.json`. Per request the active language is - * resolved from the `wire-lang` cookie, then Accept-Language, then the default. - * `ctx.t(key, params)` translates on the server; in `.wrn` views `{t:key}` and - * `t:attr="key"` markers are resolved by `translateHtml` before the HTML is sent. + * @wrnexus/i18n — deterministic locale loading, fallback resolution, SSR + * translation markers, browser translation helpers, and UI language controls. */ -import { existsSync, readdirSync, readFileSync } from "node:fs"; -import { join } from "node:path"; +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { basename, extname, join, relative, sep } from "node:path"; import type { TFunction } from "@wrnexus/core"; export { formatNumber, formatCurrency, formatDate, formatRelativeTime, plural } from "./format.ts"; +export { + extractTranslationKeys, + extractTranslationKeysFromFiles, + flattenMessageKeys, + auditLocaleKeys, + pseudoLocalize, + createPseudoLocale, +} from "./tooling.ts"; +export type { ExtractedTranslationKey } from "./tooling.ts"; export type Messages = Record; -/** Load `/.json` files into a `{ lang: messages }` map. */ -export function loadLocales(dir: string): Record { - const out: Record = {}; - if (!existsSync(dir)) return out; - for (const file of readdirSync(dir)) { - if (!file.endsWith(".json")) continue; - const lang = file.replace(/\.json$/, ""); - try { - out[lang] = JSON.parse(readFileSync(join(dir, file), "utf8")) as Messages; - } catch (err) { - console.warn(`[wrnexus] failed to load locale '${lang}'`, err); - } - } - return out; +export interface LocaleLoadOptions { + /** Throw on invalid JSON instead of warning and continuing. */ + strict?: boolean; + /** Maximum JSON file size. Default 1 MiB. */ + maxFileBytes?: number; +} + +export interface I18nCookieConfig { + name?: string; + maxAge?: number; + path?: string; + sameSite?: "Strict" | "Lax" | "None"; + secure?: boolean; } export interface I18nConfig { - /** Default language, used as the fallback and when nothing else matches. */ default?: string; - /** Explicit set of supported languages (defaults to the loaded locale names). */ locales?: string[]; + /** Human-readable locale names used by package language controls. */ + labels?: Record; + /** Per-locale fallback override. Example: `{ "fr-CA": ["fr", "en"] }`. */ + fallbacks?: Record; + /** Locale direction overrides. Arabic/Hebrew/Persian/Urdu are RTL automatically. */ + direction?: Record; + cookie?: I18nCookieConfig; + strict?: boolean; } export interface ResolvedI18n { default: string; langs: string[]; messages: Record; + fallbacks: Record; + direction: Record; + labels: Record; + cookie: Required; } export const LANG_COOKIE = "wire-lang"; export const I18N_JS_HREF = "/__wrnexus/i18n.js"; -/** Merge loaded locale messages + config into a resolved i18n bundle. */ -export function resolveI18n(messages: Record, config?: I18nConfig): ResolvedI18n { - const langs = config?.locales ?? Object.keys(messages); - const fallback = langs[0] ?? "en"; - const def = config?.default && messages[config.default] ? config.default : fallback; - return { default: def, langs, messages }; +const UNSAFE_KEYS = new Set(["__proto__", "prototype", "constructor"]); +const RTL_LANGS = new Set(["ar", "dv", "fa", "he", "ku", "ps", "sd", "ug", "ur", "yi"]); + +export function normalizeLocale(locale: string): string { + const value = locale.trim().replace(/_/g, "-"); + if (!value || !/^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/.test(value)) return ""; + const parts = value.split("-"); + return parts + .map((part, index) => { + if (index === 0) return part.toLowerCase(); + if (part.length === 2) return part.toUpperCase(); + if (part.length === 4) return part[0]!.toUpperCase() + part.slice(1).toLowerCase(); + return part; + }) + .join("-"); } -/** Look up a possibly-dotted key in a messages object. */ -function lookup(messages: Messages | undefined, key: string): string | undefined { - if (!messages) return undefined; - if (key in messages && typeof messages[key] === "string") return messages[key] as string; +function safeObject(value: unknown, path: string): Messages { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError(`Locale file '${path}' must contain a JSON object.`); + } + const output: Messages = Object.create(null) as Messages; + for (const [key, child] of Object.entries(value)) { + if (UNSAFE_KEYS.has(key)) throw new TypeError(`Unsafe translation key '${key}' in ${path}.`); + output[key] = + child && typeof child === "object" && !Array.isArray(child) + ? safeObject(child, `${path}.${key}`) + : child; + } + return output; +} + +function mergeMessages(target: Messages, source: Messages): Messages { + for (const [key, value] of Object.entries(source)) { + if (UNSAFE_KEYS.has(key)) continue; + const current = target[key]; + target[key] = + value && typeof value === "object" && !Array.isArray(value) + ? mergeMessages( + current && typeof current === "object" && !Array.isArray(current) + ? (current as Messages) + : (Object.create(null) as Messages), + value as Messages, + ) + : value; + } + return target; +} + +function localeFiles(dir: string): string[] { + if (!existsSync(dir)) return []; + const files: string[] = []; + const visit = (current: string) => { + for (const entry of readdirSync(current, { withFileTypes: true })) { + const path = join(current, entry.name); + if (entry.isDirectory()) visit(path); + else if (entry.isFile() && entry.name.endsWith(".json")) files.push(path); + } + }; + visit(dir); + return files.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)); +} + +/** + * Load both supported layouts: + * - `locales/en.json` + * - `locales/en/common.json`, `locales/en/auth.json` + * + * Namespaced files become `messages.en.common` and `messages.en.auth`. + */ +export function loadLocales( + dir: string, + options: LocaleLoadOptions = {}, +): Record { + const output: Record = Object.create(null) as Record; + const maxFileBytes = Math.max(1_024, options.maxFileBytes ?? 1_048_576); + for (const file of localeFiles(dir)) { + const rel = relative(dir, file).split(sep); + const rootFile = rel.length === 1; + const rawLocale = rootFile ? basename(file, extname(file)) : rel[0]!; + const locale = normalizeLocale(rawLocale); + if (!locale) { + const error = new TypeError(`Invalid locale name '${rawLocale}' in ${file}.`); + if (options.strict) throw error; + console.warn(`[wrnexus] ${error.message}`); + continue; + } + try { + const info = statSync(file); + if (info.size > maxFileBytes) throw new Error(`Locale file exceeds ${maxFileBytes} bytes.`); + const parsed = safeObject(JSON.parse(readFileSync(file, "utf8")), file); + const messages = (output[locale] ??= Object.create(null) as Messages); + if (rootFile) mergeMessages(messages, parsed); + else { + const namespace = rel + .slice(1) + .join("/") + .replace(/\.json$/, "") + .replace(/\//g, "."); + const parts = namespace.split(".").filter(Boolean); + let node = messages; + for (const part of parts.slice(0, -1)) { + const current = node[part]; + if (!current || typeof current !== "object" || Array.isArray(current)) { + node[part] = Object.create(null) as Messages; + } + node = node[part] as Messages; + } + const leaf = parts.at(-1); + if (leaf) { + const current = node[leaf]; + const target = + current && typeof current === "object" && !Array.isArray(current) + ? (current as Messages) + : (Object.create(null) as Messages); + node[leaf] = mergeMessages(target, parsed); + } + } + } catch (error) { + if (options.strict) throw error; + console.warn(`[wrnexus] failed to load locale '${locale}' from ${file}`, error); + } + } + return output; +} + +export function localeDirection( + locale: string, + overrides: Record = {}, +): "ltr" | "rtl" { + const normalized = normalizeLocale(locale); + return ( + overrides[normalized] ?? + overrides[normalized.split("-")[0]!] ?? + (RTL_LANGS.has(normalized.split("-")[0]!) ? "rtl" : "ltr") + ); +} + +export function resolveI18n( + messages: Record, + config: I18nConfig = {}, +): ResolvedI18n { + const normalizedMessages: Record = Object.create(null) as Record< + string, + Messages + >; + for (const [locale, value] of Object.entries(messages)) { + const normalized = normalizeLocale(locale); + if (normalized) normalizedMessages[normalized] = value; + } + const configured = (config.locales ?? Object.keys(normalizedMessages)) + .map(normalizeLocale) + .filter((locale, index, values) => locale && values.indexOf(locale) === index); + const langs = configured.filter((locale) => normalizedMessages[locale]); + const fallback = langs[0] ?? (normalizeLocale(config.default ?? "en") || "en"); + const requestedDefault = normalizeLocale(config.default ?? ""); + const defaultLocale = + requestedDefault && normalizedMessages[requestedDefault] ? requestedDefault : fallback; + if (config.strict && !normalizedMessages[defaultLocale]) { + throw new Error(`WRN-I18N-DEFAULT-MISSING: ${defaultLocale}`); + } + const fallbacks: Record = Object.create(null) as Record; + const direction: Record = Object.create(null) as Record< + string, + "ltr" | "rtl" + >; + const labels: Record = Object.create(null) as Record; + for (const locale of langs) { + const custom = config.fallbacks?.[locale] ?? config.fallbacks?.[locale.toLowerCase()] ?? []; + const base = locale.split("-")[0]!; + fallbacks[locale] = [ + ...new Set([ + locale, + ...(base !== locale ? [base] : []), + ...custom.map(normalizeLocale), + defaultLocale, + ]), + ].filter((entry) => entry && normalizedMessages[entry]); + direction[locale] = localeDirection(locale, config.direction); + labels[locale] = + config.labels?.[locale] ?? config.labels?.[locale.toLowerCase()] ?? locale.toUpperCase(); + } + return { + default: defaultLocale, + langs, + messages: normalizedMessages, + fallbacks, + direction, + labels, + cookie: { + name: config.cookie?.name ?? LANG_COOKIE, + maxAge: config.cookie?.maxAge ?? 31_536_000, + path: config.cookie?.path ?? "/", + sameSite: config.cookie?.sameSite ?? "Lax", + secure: config.cookie?.sameSite === "None" ? true : (config.cookie?.secure ?? false), + }, + }; +} + +export function lookupMessage(messages: Messages | undefined, key: string): string | undefined { + if (!messages || !key) return undefined; let node: unknown = messages; for (const part of key.split(".")) { - if (node && typeof node === "object" && part in (node as Record)) { - node = (node as Record)[part]; - } else { - return undefined; - } + if (UNSAFE_KEYS.has(part)) return undefined; + if (node && typeof node === "object" && !Array.isArray(node) && part in (node as Messages)) { + node = (node as Messages)[part]; + } else return undefined; } return typeof node === "string" ? node : undefined; } -/** Interpolate `{param}` placeholders in a message. */ -function interpolate(message: string, params?: Record): string { +export function interpolate(message: string, params?: Record): string { if (!params) return message; - return message.replace(/\{(\w+)\}/g, (_m, name: string) => - name in params ? String(params[name]) : `{${name}}`, + return message.replace(/\{([A-Za-z0-9_.-]+)\}/g, (_match, name: string) => + Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : `{${name}}`, + ); +} + +export function translationChain(i18n: ResolvedI18n, locale: string): string[] { + const normalized = normalizeLocale(locale); + return ( + i18n.fallbacks[normalized] ?? + [...new Set([normalized, normalized.split("-")[0], i18n.default])].filter( + (entry) => entry && i18n.messages[entry], + ) ); } -/** Build a `t()` for a language: current → default → the key itself. */ export function makeT(i18n: ResolvedI18n, lang: string): TFunction { + const chain = translationChain(i18n, lang); return (key, params) => { - const message = - lookup(i18n.messages[lang], key) ?? lookup(i18n.messages[i18n.default], key) ?? key; - return interpolate(message, params); + for (const locale of chain) { + const message = lookupMessage(i18n.messages[locale], key); + if (message !== undefined) return interpolate(message, params); + } + return key; }; } -/** Resolve the active language from a cookie, Accept-Language, then default. */ +/** Deeply apply tenant-specific translations without mutating the shared locale bundle. */ +export function withTenantMessages( + i18n: ResolvedI18n, + overrides: Record, +): ResolvedI18n { + const messages: Record = Object.create(null) as Record; + for (const [locale, value] of Object.entries(i18n.messages)) + messages[locale] = mergeMessages( + safeObject(structuredClone(value), `tenant:${locale}`), + overrides[locale] + ? safeObject(structuredClone(overrides[locale]), `tenant:${locale}:override`) + : {}, + ); + return { ...i18n, messages }; +} + +/** Load only common and route-specific messages for one locale. */ +export function loadRouteMessages(directory: string, locale: string, route: string): Messages { + const normalized = normalizeLocale(locale); + if (!normalized) throw new Error("WRN-I18N-ROUTE-LOCALE: invalid locale."); + const cleanRoute = route.replace(/^\/+|\/+$/g, "").replace(/\[[^\]]+\]/g, "_") || "index"; + if (!/^[A-Za-z0-9_/-]+$/.test(cleanRoute) || cleanRoute.includes("..")) + throw new Error("WRN-I18N-ROUTE-PATH: invalid route namespace."); + const result = Object.create(null) as Messages; + const candidates = [ + join(directory, `${normalized}.json`), + join(directory, normalized, "common.json"), + join(directory, normalized, "routes", `${cleanRoute}.json`), + ]; + for (const file of candidates) { + if (!existsSync(file)) continue; + mergeMessages(result, safeObject(JSON.parse(readFileSync(file, "utf8")), file)); + } + return result; +} + +export function parseAcceptLanguage(value: string | null): string[] { + return (value ?? "") + .split(",") + .map((part, index) => { + const [tag, ...params] = part.trim().split(";"); + const quality = params.map((entry) => /^q=([0-9.]+)$/i.exec(entry.trim())?.[1]).find(Boolean); + const normalizedTag = tag?.trim() === "*" ? "*" : normalizeLocale(tag ?? ""); + return { locale: normalizedTag, quality: quality ? Number(quality) : 1, index }; + }) + .filter( + (entry) => + entry.locale && Number.isFinite(entry.quality) && entry.quality > 0 && entry.quality <= 1, + ) + .sort((left, right) => right.quality - left.quality || left.index - right.index) + .map((entry) => entry.locale); +} + export function resolveLang( i18n: ResolvedI18n, cookieValue: string | undefined, acceptLanguage: string | null, ): string { - if (cookieValue && i18n.langs.includes(cookieValue)) return cookieValue; - for (const part of (acceptLanguage ?? "").split(",")) { - const tag = part.split(";")[0]!.trim().toLowerCase(); - if (!tag) continue; - if (i18n.langs.includes(tag)) return tag; - const base = tag.split("-")[0]!; - if (i18n.langs.includes(base)) return base; + const cookie = normalizeLocale(cookieValue ?? ""); + if (cookie && i18n.langs.includes(cookie)) return cookie; + for (const locale of parseAcceptLanguage(acceptLanguage)) { + if (locale === "*") return i18n.default; + if (i18n.langs.includes(locale)) return locale; + const base = locale.split("-")[0]!; + const match = i18n.langs.find( + (supported) => supported === base || supported.startsWith(`${base}-`), + ); + if (match) return match; } return i18n.default; } @@ -111,72 +379,158 @@ function attrEscape(value: string): string { .replace(//g, ">"); } - function htmlEscape(value: string): string { return value.replace(/&/g, "&").replace(//g, ">"); } -/** - * Resolve translation markers in rendered HTML: - * t:="key" → ="" (e.g. t:placeholder, t:aria-label) - * → element text becomes the translation - * Only runs when the HTML actually contains a marker. - */ export function translateHtml(html: string, t: TFunction): string { if (!html.includes("data-t=") && !html.includes("t:")) return html; - - let out = html.replace( - /\bt:([A-Za-z][A-Za-z0-9:_-]*)="([^"]*)"/g, - (_m, attr: string, key: string) => `${attr}="${attrEscape(t(key))}"`, + let output = html.replace( + /\bt:([A-Za-z][A-Za-z0-9:_-]*)=(?:"([^"]*)"|'([^']*)')/g, + (_match, attr: string, doubleKey: string | undefined, singleKey: string | undefined) => { + const key = doubleKey ?? singleKey ?? ""; + const translated = t(key); + return translated === key ? `${attr}=""` : `${attr}="${attrEscape(translated)}"`; + }, ); - - out = out.replace( - /<([A-Za-z][A-Za-z0-9-]*)\b([^>]*\bdata-t="([^"]*)"[^>]*)>([\s\S]*?)<\/\1>/g, - (_m, tag: string, attrs: string, key: string) => - `<${tag}${attrs}>${htmlEscape(t(key))}`, + output = output.replace( + /<([A-Za-z][A-Za-z0-9-]*)\b([^>]*?)\sdata-t=(?:"([^"]*)"|'([^']*)')([^>]*)>([\s\S]*?)<\/\1>/g, + ( + _match, + tag: string, + before: string, + doubleKey: string | undefined, + singleKey: string | undefined, + after: string, + content: string, + ) => { + const key = doubleKey ?? singleKey ?? ""; + const translated = t(key); + return `<${tag}${before} data-t="${attrEscape(key)}"${after}>${ + translated === key ? content : htmlEscape(translated) + }`; + }, ); - - return out; + return output; +} + +function safeJson(value: unknown): string { + return JSON.stringify(value) + .replace(//g, "\\u003e") + .replace(/&/g, "\\u0026") + .replace(/\u2028/g, "\\u2028") + .replace(/\u2029/g, "\\u2029"); } -/** `window.__wireI18n = { lang, langs }` for the client language switcher. */ export function renderI18nData(i18n: ResolvedI18n, lang: string): string { - return `window.__wireI18n=${JSON.stringify({ lang, langs: i18n.langs, default: i18n.default })};`; + const active = i18n.langs.includes(lang) ? lang : i18n.default; + return `window.__wireI18n=${safeJson({ + lang: active, + langs: i18n.langs, + default: i18n.default, + messages: i18n.messages[active] ?? {}, + fallbackMessages: active === i18n.default ? {} : (i18n.messages[i18n.default] ?? {}), + direction: i18n.direction[active] ?? "ltr", + directions: i18n.direction, + labels: i18n.labels, + cookie: i18n.cookie, + })};`; } -/** - * Client runtime: binds `[data-wire-lang-set="es"]` elements to set the - * `wire-lang` cookie and reload, so the server re-renders in the new language. - */ export const I18N_RUNTIME = String.raw` (function () { - var COOKIE = "${LANG_COOKIE}"; + function lookup(messages, key) { + var node = messages; + var parts = String(key || "").split("."); + for (var i = 0; i < parts.length; i++) { + if (parts[i] === "__proto__" || parts[i] === "prototype" || parts[i] === "constructor") return undefined; + if (!node || typeof node !== "object" || !Object.prototype.hasOwnProperty.call(node, parts[i])) return undefined; + node = node[parts[i]]; + } + return typeof node === "string" ? node : undefined; + } + function interpolate(message, params) { + return String(message).replace(/\{([A-Za-z0-9_.-]+)\}/g, function (_, name) { + return params && Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : "{" + name + "}"; + }); + } + function state() { return window.__wireI18n || {}; } + function t(key, params) { + var current = state(); + return interpolate(lookup(current.messages, key) || lookup(current.fallbackMessages, key) || key, params); + } function set(lang) { - document.cookie = COOKIE + "=" + encodeURIComponent(lang) + ";path=/;max-age=31536000;samesite=lax"; + var current = state(); + if (current.langs && current.langs.indexOf(lang) === -1) return false; + var cookie = current.cookie || {}; + var secure = cookie.secure || location.protocol === "https:"; + document.cookie = encodeURIComponent(cookie.name || "${LANG_COOKIE}") + "=" + encodeURIComponent(lang) + + ";path=" + (cookie.path || "/") + ";max-age=" + (cookie.maxAge || 31536000) + + ";samesite=" + (cookie.sameSite || "Lax") + (secure ? ";secure" : ""); + document.documentElement.lang = lang; + document.documentElement.dir = (current.directions && current.directions[lang]) || "ltr"; + window.dispatchEvent(new CustomEvent("wrnexus:language-change", { detail: { locale: lang } })); location.reload(); + return true; } function bind(root) { - var currentLang = (window.__wireI18n && window.__wireI18n.lang) || document.documentElement.lang; - (root || document).querySelectorAll("[data-wrn-preferences]").forEach(function (switcher) { + var current = state(); + var currentLang = current.lang || document.documentElement.lang; + document.documentElement.lang = currentLang || current.default || "en"; + if (current.direction) document.documentElement.dir = current.direction; + var scope = root || document; + var elements = []; + if (scope.nodeType === 1) elements.push(scope); + scope.querySelectorAll("*").forEach(function (node) { elements.push(node); }); + elements.forEach(function (node) { + Array.from(node.attributes || []).forEach(function (attribute) { + if (attribute.name.indexOf("t:") !== 0) return; + var target = attribute.name.slice(2); + if (!target) return; + var translated = t(attribute.value); + node.setAttribute(target, translated === attribute.value ? "" : translated); + node.removeAttribute(attribute.name); + }); + }); + scope.querySelectorAll("[data-t]").forEach(function (node) { + var key = node.getAttribute("data-t"); + if (key) node.textContent = t(key); + }); + function updateResponsiveLabels() { + var short = window.matchMedia && window.matchMedia("(max-width: 640px)").matches; + scope.querySelectorAll("option[data-wire-lang-option]").forEach(function (option) { + option.textContent = option.getAttribute(short ? "data-label-short" : "data-label-long") || option.value; + }); + } + updateResponsiveLabels(); + if (!window.__wireI18nResponsiveBound && window.matchMedia) { + window.__wireI18nResponsiveBound = true; + window.matchMedia("(max-width: 640px)").addEventListener("change", function () { bind(document); }); + } + scope.querySelectorAll("[data-wrn-preferences]").forEach(function (switcher) { switcher.setAttribute("data-current-language", currentLang); - var currentLabel = switcher.querySelector(".wire-preferences__current-language"); - if (currentLabel) currentLabel.textContent = String(currentLang || "en").toUpperCase(); + var label = switcher.querySelector(".wire-preferences__current-language"); + if (label) label.textContent = String(currentLang || "en").toUpperCase(); }); - (root || document).querySelectorAll("[data-wire-lang-set]").forEach(function (n) { - n.setAttribute("aria-pressed", String(n.getAttribute("data-wire-lang-set") === currentLang)); - if (n.__wireLangBound) return; n.__wireLangBound = 1; - n.addEventListener("click", function () { set(n.getAttribute("data-wire-lang-set")); }); + scope.querySelectorAll("[data-wire-lang-set]").forEach(function (node) { + node.setAttribute("aria-pressed", String(node.getAttribute("data-wire-lang-set") === currentLang)); + if (node.__wireLangBound) return; node.__wireLangBound = 1; + node.addEventListener("click", function () { set(node.getAttribute("data-wire-lang-set")); }); }); - (root || document).querySelectorAll("select[data-wire-lang]").forEach(function (n) { - if (n.__wireLangBound) return; n.__wireLangBound = 1; - n.addEventListener("change", function () { set(n.value); }); + scope.querySelectorAll("select[data-wire-lang]").forEach(function (node) { + node.value = currentLang; + if (node.__wireLangBound) return; node.__wireLangBound = 1; + node.addEventListener("change", function () { set(node.value); }); }); } - window.__wireLang = { set: set }; + window.__wireLang = { set: set, t: t, bind: bind, get lang() { return state().lang; } }; + window.__wireI18n = Object.assign(state(), { t: t, set: set }); if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", function () { bind(document); }); else bind(document); })(); `.trim(); + export { flattenMessages, localeFallbacks, @@ -185,3 +539,5 @@ export { formatMessage, } from "./advanced.ts"; export type { LocaleFormatter } from "./advanced.ts"; +export { i18nPlugin, i18nComponentsDir } from "./plugin.ts"; +export type { I18nPluginOptions } from "./plugin.ts"; diff --git a/packages/i18n/src/plugin.ts b/packages/i18n/src/plugin.ts new file mode 100644 index 00000000..40208a80 --- /dev/null +++ b/packages/i18n/src/plugin.ts @@ -0,0 +1,21 @@ +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { definePlugin } from "@wrnexus/plugin"; + +export interface I18nPluginOptions { + components?: boolean; + componentDir?: string; +} +const packageRoot = dirname(dirname(fileURLToPath(import.meta.url))); +export function i18nComponentsDir(): string { + return join(packageRoot, "components"); +} +export function i18nPlugin(options: I18nPluginOptions = {}) { + return definePlugin({ + name: "@wrnexus/i18n", + version: "0.8.0", + componentDirs: + options.components === false ? [] : [options.componentDir ?? i18nComponentsDir()], + }); +} +export default i18nPlugin; diff --git a/packages/i18n/src/tooling.ts b/packages/i18n/src/tooling.ts new file mode 100644 index 00000000..f8d046c9 --- /dev/null +++ b/packages/i18n/src/tooling.ts @@ -0,0 +1,104 @@ +import { readFileSync } from "node:fs"; +import type { Messages } from "./index.ts"; + +export interface ExtractedTranslationKey { + key: string; + file?: string; + offset: number; +} + +export function extractTranslationKeys(source: string, file?: string): ExtractedTranslationKey[] { + const found = new Map(); + const patterns = [ + /(?:\b(?:t|\$t)|\bi18n\.t)\s*\(\s*(["'])([^"']+)\1/g, + /\bdata-i18n\s*=\s*(["'])([^"']+)\1/g, + /\{t:([A-Za-z0-9_.:-]+)\}/g, + ]; + for (const pattern of patterns) { + for (const match of source.matchAll(pattern)) { + const key = (match[2] ?? match[1])!.trim(); + if (key && !found.has(key)) found.set(key, { key, file, offset: match.index }); + } + } + return [...found.values()].sort((left, right) => left.key.localeCompare(right.key)); +} + +export function extractTranslationKeysFromFiles( + files: Iterable, +): ExtractedTranslationKey[] { + const found = new Map(); + for (const file of files) { + for (const item of extractTranslationKeys(readFileSync(file, "utf8"), file)) { + found.set(`${item.file}:${item.key}`, item); + } + } + return [...found.values()].sort( + (left, right) => + String(left.file).localeCompare(String(right.file)) || left.key.localeCompare(right.key), + ); +} + +export function flattenMessageKeys(messages: Messages, prefix = ""): string[] { + const keys: string[] = []; + for (const [name, value] of Object.entries(messages)) { + const key = prefix ? `${prefix}.${name}` : name; + if (value && typeof value === "object" && !Array.isArray(value)) { + keys.push(...flattenMessageKeys(value as Messages, key)); + } else keys.push(key); + } + return keys.sort(); +} + +export function auditLocaleKeys( + messages: Record, + referenceLocale: string, +): Record { + const reference = new Set(flattenMessageKeys(messages[referenceLocale] ?? {})); + const result: Record = {}; + for (const [locale, value] of Object.entries(messages)) { + const keys = new Set(flattenMessageKeys(value)); + result[locale] = { + missing: [...reference].filter((key) => !keys.has(key)).sort(), + extra: [...keys].filter((key) => !reference.has(key)).sort(), + }; + } + return result; +} + +const ACCENTS: Record = { + a: "à", + e: "ë", + i: "ï", + o: "ô", + u: "ü", + A: "À", + E: "Ë", + I: "Ï", + O: "Ô", + U: "Ü", +}; + +export function pseudoLocalize(value: string, options: { rtl?: boolean } = {}): string { + const parts = value.split(/(\{[^{}]+\}|<[^>]+>)/g); + const transformed = parts + .map((part) => + /^\{[^{}]+\}$|^<[^>]+>$/.test(part) + ? part + : part.replace(/[aeiouAEIOU]/g, (character) => ACCENTS[character] ?? character), + ) + .join(""); + return options.rtl ? `\u202e[${transformed}]\u202c` : `[${transformed}~~~]`; +} + +export function createPseudoLocale(messages: Messages, options: { rtl?: boolean } = {}): Messages { + const output: Messages = Object.create(null) as Messages; + for (const [key, value] of Object.entries(messages)) { + output[key] = + typeof value === "string" + ? pseudoLocalize(value, options) + : value && typeof value === "object" && !Array.isArray(value) + ? createPseudoLocale(value as Messages, options) + : value; + } + return output; +} diff --git a/packages/i18n/test/complete.test.ts b/packages/i18n/test/complete.test.ts new file mode 100644 index 00000000..df653106 --- /dev/null +++ b/packages/i18n/test/complete.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + createLocaleFormatter, + formatMessage, + loadRouteMessages, + resolveI18n, + withTenantMessages, +} from "../src/index.ts"; + +test("formats ICU plural, exact and gender-aware select messages", () => { + expect( + formatMessage("{count, plural, =0 {None} one {# item} other {# items}}", { count: 2 }, "en"), + ).toBe("2 items"); + expect( + formatMessage( + "{gender, select, female {She} male {He} other {They}} approved", + { gender: "female" }, + "en", + ), + ).toBe("She approved"); +}); +test("supports calendars, tenant overrides and route-scoped loading", () => { + const formatter = createLocaleFormatter("en-US", "UTC", "indian"); + expect( + formatter.date(new Date("2026-08-02T00:00:00Z"), { year: "numeric", calendar: "indian" }), + ).toBeTruthy(); + const base = resolveI18n({ en: { title: "Default" } }, { default: "en" }); + expect(withTenantMessages(base, { en: { title: "Tenant" } }).messages.en?.title).toBe("Tenant"); + expect(base.messages.en?.title).toBe("Default"); + const root = mkdtempSync(join(tmpdir(), "wrnexus-i18n-route-")); + mkdirSync(join(root, "en/routes/users"), { recursive: true }); + writeFileSync(join(root, "en/common.json"), JSON.stringify({ common: "Common" })); + writeFileSync(join(root, "en/routes/users/index.json"), JSON.stringify({ title: "Users" })); + expect(loadRouteMessages(root, "en", "/users/index")).toEqual({ + common: "Common", + title: "Users", + }); +}); diff --git a/packages/i18n/test/i18n.test.ts b/packages/i18n/test/i18n.test.ts index 5ba80ded..e5de20e5 100644 --- a/packages/i18n/test/i18n.test.ts +++ b/packages/i18n/test/i18n.test.ts @@ -38,3 +38,13 @@ test("translateHtml is a no-op without markers", () => { const t = makeT(i18n, "en"); expect(translateHtml("

    plain

    ", t)).toBe("

    plain

    "); }); + +test("translateHtml preserves authored fallback text when a key is unavailable", () => { + const t = (key: string) => key; + expect(translateHtml('

    Readable fallback

    ', t)).toBe( + '

    Readable fallback

    ', + ); + expect(translateHtml('', t)).toBe( + '', + ); +}); diff --git a/packages/i18n/test/package-kit.test.ts b/packages/i18n/test/package-kit.test.ts new file mode 100644 index 00000000..cd2c304f --- /dev/null +++ b/packages/i18n/test/package-kit.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + loadLocales, + localeDirection, + makeT, + renderI18nData, + resolveI18n, + resolveLang, +} from "../src/index.ts"; + +describe("i18n package kit", () => { + test("loads top-level and namespaced locale files recursively", () => { + const directory = mkdtempSync(join(tmpdir(), "wrn-i18n-")); + try { + writeFileSync(join(directory, "en.json"), JSON.stringify({ app: { title: "Portal" } })); + mkdirSync(join(directory, "fr")); + writeFileSync( + join(directory, "fr", "common.json"), + JSON.stringify({ hello: "Bonjour {name}" }), + ); + const i18n = resolveI18n(loadLocales(directory, { strict: true }), { + default: "en", + locales: ["en", "fr"], + }); + expect(makeT(i18n, "fr")("common.hello", { name: "Ajay" })).toBe("Bonjour Ajay"); + expect(resolveLang(i18n, undefined, "en;q=0.6, fr;q=0.9")).toBe("fr"); + expect(resolveLang(i18n, undefined, "*")).toBe("en"); + expect(renderI18nData(i18n, "fr")).toContain("Bonjour"); + expect(renderI18nData(i18n, "fr")).toContain('"directions"'); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + + test("ships a cookie-backed language switcher that targets the native select", () => { + const source = readFileSync( + join(import.meta.dir, "../components/LanguageSwitcher.wrn"), + "utf8", + ); + expect(source).toContain(" { + const directory = mkdtempSync(join(tmpdir(), "wrn-i18n-collision-")); + try { + writeFileSync(join(directory, "en.json"), JSON.stringify({ common: "legacy" })); + mkdirSync(join(directory, "en")); + writeFileSync(join(directory, "en", "common.json"), JSON.stringify({ save: "Save" })); + const i18n = resolveI18n(loadLocales(directory, { strict: true }), { default: "en" }); + expect(makeT(i18n, "en")("common.save")).toBe("Save"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + + test("resolves RTL direction from language subtags", () => { + expect(localeDirection("ar-IN")).toBe("rtl"); + expect(localeDirection("en-IN")).toBe("ltr"); + }); + + test("rejects prototype-polluting locale keys", () => { + const directory = mkdtempSync(join(tmpdir(), "wrn-i18n-unsafe-")); + try { + writeFileSync(join(directory, "en.json"), '{"constructor":{"prototype":{"polluted":true}}}'); + expect(() => loadLocales(directory, { strict: true })).toThrow(); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/i18n/test/tooling.test.ts b/packages/i18n/test/tooling.test.ts new file mode 100644 index 00000000..5d8a5254 --- /dev/null +++ b/packages/i18n/test/tooling.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from "bun:test"; +import { + auditLocaleKeys, + createPseudoLocale, + extractTranslationKeys, + flattenMessageKeys, + localeDirection, + pseudoLocalize, +} from "../src/index.ts"; + +test("extracts static translation keys from code and WRN attributes", () => { + const keys = extractTranslationKeys(` + const title = t("account.title") + const copy = i18n.t('account.copy') + const dynamic = t(key) +

    + `); + expect(keys.map(({ key }) => key)).toEqual(["account.copy", "account.help", "account.title"]); +}); + +test("audits locale completeness against a reference locale", () => { + const messages = { + en: { common: { hello: "Hello", bye: "Bye" } }, + fr: { common: { hello: "Bonjour", extra: "Extra" } }, + }; + expect(flattenMessageKeys(messages.en)).toEqual(["common.bye", "common.hello"]); + expect(auditLocaleKeys(messages, "en").fr).toEqual({ + missing: ["common.bye"], + extra: ["common.extra"], + }); +}); + +test("pseudo-locales preserve placeholders and exercise LTR and RTL layouts", () => { + expect(pseudoLocalize("Hello {name} today")).toContain("{name}"); + const pseudo = createPseudoLocale({ greeting: "Hello {name}", nested: { save: "Save" } }); + expect(pseudo.greeting).toBe("[Hëllô {name}~~~]"); + expect((pseudo.nested as Record).save).toBe("[Sàvë~~~]"); + expect(pseudoLocalize("Hello {name}", { rtl: true }).startsWith("\u202e[")).toBe(true); + expect(localeDirection("ar-XB")).toBe("rtl"); +}); diff --git a/packages/identity/README.md b/packages/identity/README.md new file mode 100644 index 00000000..52368f24 --- /dev/null +++ b/packages/identity/README.md @@ -0,0 +1,10 @@ +# @wrnexus/identity + +Enterprise identity and governance for WRNexusJS: OIDC discovery, signed SAML adapter flows, +LDAP/Active Directory synchronization adapters, SCIM provisioning, scoped API keys, service +accounts, approval workflows, consent history, retention, subject export/deletion and audit. + +The package complements `@wrnexus/auth` (passkeys, MFA, devices, sessions, OAuth and audited +impersonation) and `@wrnexus/authz` (RBAC, ABAC and policy decisions). Protocol-specific SAML and +directory parsing is supplied through adapters so applications can select a maintained vendor SDK +without weakening framework validation, replay protection or governance auditing. diff --git a/packages/identity/package.json b/packages/identity/package.json new file mode 100644 index 00000000..23a4fde3 --- /dev/null +++ b/packages/identity/package.json @@ -0,0 +1,15 @@ +{ + "name": "@wrnexus/identity", + "version": "0.8.0", + "type": "module", + "description": "Enterprise federation, provisioning, machine identity, and privacy governance for WRNexusJS.", + "main": "src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "@wrnexus/auth": "workspace:*", + "@wrnexus/authz": "workspace:*", + "@wrnexus/oauth": "workspace:*" + } +} diff --git a/packages/identity/src/index.ts b/packages/identity/src/index.ts new file mode 100644 index 00000000..46e6ed7b --- /dev/null +++ b/packages/identity/src/index.ts @@ -0,0 +1,454 @@ +export interface OidcMetadata { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + userinfo_endpoint?: string; + jwks_uri: string; + scopes_supported?: string[]; +} + +function secureUrl(value: string, field: string): URL { + const url = new URL(value); + if (url.protocol !== "https:" && url.hostname !== "localhost") + throw new Error(`WRN-IDENTITY-URL: ${field} must use HTTPS.`); + return url; +} + +export async function discoverOidc( + issuer: string, + options: { fetch?: typeof fetch } = {}, +): Promise { + const expected = secureUrl(issuer, "issuer"); + const endpoint = new URL( + ".well-known/openid-configuration", + `${expected.href.replace(/\/$/, "")}/`, + ); + const response = await (options.fetch ?? fetch)(endpoint, { + headers: { accept: "application/json" }, + }); + if (!response.ok) throw new Error(`WRN-OIDC-DISCOVERY: provider returned ${response.status}.`); + const metadata = (await response.json()) as OidcMetadata; + if (metadata.issuer.replace(/\/$/, "") !== expected.href.replace(/\/$/, "")) + throw new Error("WRN-OIDC-ISSUER: discovered issuer does not match configuration."); + for (const field of ["authorization_endpoint", "token_endpoint", "jwks_uri"] as const) + secureUrl(metadata[field], field); + return metadata; +} + +export function oidcAuthorizationUrl( + metadata: OidcMetadata, + input: { + clientId: string; + redirectUri: string; + state: string; + nonce: string; + codeChallenge: string; + scopes?: string[]; + }, +): string { + const url = secureUrl(metadata.authorization_endpoint, "authorization_endpoint"); + const values = { + response_type: "code", + client_id: input.clientId, + redirect_uri: input.redirectUri, + scope: (input.scopes ?? ["openid", "profile", "email"]).join(" "), + state: input.state, + nonce: input.nonce, + code_challenge: input.codeChallenge, + code_challenge_method: "S256", + }; + for (const [key, value] of Object.entries(values)) url.searchParams.set(key, value); + return url.href; +} + +export interface EnterpriseIdentity { + externalId: string; + username: string; + displayName?: string; + email?: string; + groups: string[]; + active: boolean; + attributes?: Record; +} + +export interface SamlAssertion { + id: string; + issuer: string; + audience: string; + recipient: string; + expiresAt: number; + identity: EnterpriseIdentity; +} +export interface SamlAdapter { + createLoginRequest(input: { + requestId: string; + callbackUrl: string; + relayState: string; + }): Promise | string; + verifySignedResponse(response: string): Promise; +} +export interface ReplayStore { + consume(id: string, expiresAt: number): Promise; +} +export function memoryReplayStore(now: () => number = Date.now): ReplayStore { + const ids = new Map(); + return { + async consume(id, expiresAt) { + for (const [key, expiry] of ids) if (expiry <= now()) ids.delete(key); + if (ids.has(id)) return false; + ids.set(id, expiresAt); + return true; + }, + }; +} +export function createSamlFederation(options: { + adapter: SamlAdapter; + issuer: string; + audience: string; + recipient: string; + replayStore?: ReplayStore; + now?: () => number; +}) { + const replay = options.replayStore ?? memoryReplayStore(options.now); + const now = options.now ?? Date.now; + return { + login: options.adapter.createLoginRequest.bind(options.adapter), + async callback(encodedResponse: string): Promise { + const assertion = await options.adapter.verifySignedResponse(encodedResponse); + if ( + assertion.issuer !== options.issuer || + assertion.audience !== options.audience || + assertion.recipient !== options.recipient + ) + throw new Error("WRN-SAML-BOUNDARY: issuer, audience, or recipient mismatch."); + if (assertion.expiresAt <= now()) throw new Error("WRN-SAML-EXPIRED: assertion has expired."); + if (!(await replay.consume(assertion.id, assertion.expiresAt))) + throw new Error("WRN-SAML-REPLAY: assertion was already consumed."); + return assertion.identity; + }, + }; +} + +export interface DirectoryAdapter { + kind: "ldap" | "active-directory"; + search(input: { + baseDn: string; + filter: string; + attributes: string[]; + signal?: AbortSignal; + }): Promise; + authenticate?( + username: string, + password: string, + signal?: AbortSignal, + ): Promise; +} +export async function syncDirectory( + adapter: DirectoryAdapter, + options: { + baseDn: string; + filter?: string; + attributes?: string[]; + signal?: AbortSignal; + upsert: (identity: EnterpriseIdentity) => void | Promise; + disableMissing?: (externalIds: string[]) => void | Promise; + }, +) { + const identities = await adapter.search({ + baseDn: options.baseDn, + filter: options.filter ?? "(objectClass=person)", + attributes: options.attributes ?? ["uid", "mail", "displayName", "memberOf"], + signal: options.signal, + }); + for (const identity of identities) await options.upsert(identity); + await options.disableMissing?.(identities.map((identity) => identity.externalId)); + return { provider: adapter.kind, synchronized: identities.length }; +} + +export interface ScimUser extends EnterpriseIdentity { + id: string; + /** RFC 7643 field accepted at the HTTP boundary. */ + userName?: string; + schemas?: string[]; +} +export interface ScimStore { + list(): Promise; + get(id: string): Promise; + create(user: Omit): Promise; + update(id: string, user: Partial): Promise; + delete(id: string): Promise; +} +export function memoryScimStore(): ScimStore { + const users = new Map(); + return { + async list() { + return [...users.values()]; + }, + async get(id) { + return users.get(id) ?? null; + }, + async create(user) { + const value = { ...user, id: crypto.randomUUID() }; + users.set(value.id, value); + return value; + }, + async update(id, patch) { + const current = users.get(id); + if (!current) return null; + const value = { ...current, ...patch, id }; + users.set(id, value); + return value; + }, + async delete(id) { + return users.delete(id); + }, + }; +} +function constantTimeText(left: string, right: string): boolean { + const a = new TextEncoder().encode(left); + const b = new TextEncoder().encode(right); + let mismatch = a.length ^ b.length; + for (let index = 0; index < Math.max(a.length, b.length); index++) + mismatch |= (a[index] ?? 0) ^ (b[index] ?? 0); + return mismatch === 0; +} +const SCIM_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse"; +export function createScimHandler(options: { + store: ScimStore; + bearerToken: string; + basePath?: string; + maxBodyBytes?: number; +}) { + if (options.bearerToken.length < 24) + throw new Error("WRN-SCIM-TOKEN: bearer token must contain at least 24 characters."); + const base = options.basePath ?? "/scim/v2"; + return async (request: Request): Promise => { + if ( + !constantTimeText(request.headers.get("authorization") ?? "", `Bearer ${options.bearerToken}`) + ) + return Response.json({ detail: "Unauthorized" }, { status: 401 }); + const url = new URL(request.url); + const match = new RegExp( + `^${base.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/Users(?:/([^/]+))?$`, + ).exec(url.pathname); + if (!match) return Response.json({ detail: "Not found" }, { status: 404 }); + const id = match[1]; + if (request.method === "GET" && !id) { + const values = await options.store.list(); + return Response.json({ + schemas: [SCIM_SCHEMA], + totalResults: values.length, + startIndex: 1, + itemsPerPage: values.length, + Resources: values, + }); + } + if (request.method === "GET" && id) { + const value = await options.store.get(id); + return value ? Response.json(value) : Response.json({ detail: "Not found" }, { status: 404 }); + } + if (["POST", "PUT", "PATCH"].includes(request.method)) { + const text = await request.text(); + if (new TextEncoder().encode(text).byteLength > (options.maxBodyBytes ?? 64 * 1024)) + return Response.json({ detail: "Too large" }, { status: 413 }); + let body: ScimUser; + try { + body = JSON.parse(text) as ScimUser; + } catch { + return Response.json({ detail: "Invalid JSON" }, { status: 400 }); + } + if (!body || typeof body.userName !== "string") + return Response.json({ detail: "userName is required" }, { status: 400 }); + const normalized = { + externalId: String(body.externalId ?? body.userName), + username: body.userName, + displayName: body.displayName, + email: body.email, + groups: Array.isArray(body.groups) ? body.groups : [], + active: body.active !== false, + attributes: body.attributes, + }; + const value = + request.method === "POST" + ? await options.store.create(normalized) + : id + ? await options.store.update(id, normalized) + : null; + return value + ? Response.json(value, { status: request.method === "POST" ? 201 : 200 }) + : Response.json({ detail: "Not found" }, { status: 404 }); + } + if (request.method === "DELETE" && id) + return new Response(null, { status: (await options.store.delete(id)) ? 204 : 404 }); + return new Response("Method Not Allowed", { status: 405 }); + }; +} + +export interface MachineCredential { + id: string; + ownerId: string; + kind: "api-key" | "service-account"; + name: string; + scopes: string[]; + secretHash: string; + createdAt: number; + expiresAt?: number; + revokedAt?: number; +} +const hex = (bytes: Uint8Array) => + Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +async function hash(value: string): Promise { + return hex( + new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value))), + ); +} +export function createMachineIdentityManager(now: () => number = Date.now) { + const records = new Map(); + return { + async issue(input: { + ownerId: string; + name: string; + scopes: string[]; + kind?: MachineCredential["kind"]; + expiresAt?: number; + }) { + const id = crypto.randomUUID(); + const secret = `wrn_${id.replaceAll("-", "")}_${hex(crypto.getRandomValues(new Uint8Array(24)))}`; + const record: MachineCredential = { + id, + ownerId: input.ownerId, + kind: input.kind ?? "api-key", + name: input.name, + scopes: [...new Set(input.scopes)].sort(), + secretHash: await hash(secret), + createdAt: now(), + expiresAt: input.expiresAt, + }; + records.set(id, record); + return { secret, credential: { ...record, secretHash: "[REDACTED]" } }; + }, + async authenticate(secret: string, requiredScope?: string) { + const digest = await hash(secret); + for (const record of records.values()) + if ( + constantTimeText(record.secretHash, digest) && + !record.revokedAt && + (!record.expiresAt || record.expiresAt > now()) && + (!requiredScope || record.scopes.includes(requiredScope) || record.scopes.includes("*")) + ) + return { ...record, secretHash: "[REDACTED]" }; + return null; + }, + revoke(id: string) { + const record = records.get(id); + if (!record) return false; + record.revokedAt = now(); + return true; + }, + list(ownerId: string) { + return [...records.values()] + .filter((record) => record.ownerId === ownerId) + .map((record) => ({ ...record, secretHash: "[REDACTED]" })); + }, + }; +} + +export interface GovernanceEvent { + id: string; + type: string; + subjectId: string; + actorId?: string; + createdAt: number; + data?: Record; +} +export function createGovernance( + options: { + now?: () => number; + audit?: (event: GovernanceEvent) => void | Promise; + exportSubject?: (subjectId: string) => unknown | Promise; + deleteSubject?: (subjectId: string) => void | Promise; + } = {}, +) { + const now = options.now ?? Date.now; + const consents = new Map< + string, + Map + >(); + const approvals = new Map< + string, + { + id: string; + subjectId: string; + action: "export" | "delete"; + status: "pending" | "approved" | "rejected"; + requestedAt: number; + decidedAt?: number; + decidedBy?: string; + } + >(); + const emit = async ( + type: string, + subjectId: string, + actorId?: string, + data?: Record, + ) => + options.audit?.({ id: crypto.randomUUID(), type, subjectId, actorId, createdAt: now(), data }); + return { + async consent(subjectId: string, purpose: string, granted: boolean, version: string) { + const values = consents.get(subjectId) ?? new Map(); + const value = { granted, version, at: now() }; + values.set(purpose, value); + consents.set(subjectId, values); + await emit("consent.changed", subjectId, subjectId, { purpose, granted, version }); + return value; + }, + consents(subjectId: string) { + return Object.fromEntries(consents.get(subjectId) ?? []); + }, + async request(subjectId: string, action: "export" | "delete") { + const value = { + id: crypto.randomUUID(), + subjectId, + action, + status: "pending" as const, + requestedAt: now(), + }; + approvals.set(value.id, value); + await emit(`privacy.${action}.requested`, subjectId); + return value; + }, + async decide(id: string, actorId: string, approved: boolean) { + const request = approvals.get(id); + if (!request || request.status !== "pending") + throw new Error("WRN-GOVERNANCE-APPROVAL: request is missing or already decided."); + const decision = { + ...request, + status: approved ? ("approved" as const) : ("rejected" as const), + decidedAt: now(), + decidedBy: actorId, + }; + approvals.set(id, decision); + let result: unknown; + if (approved && request.action === "export") + result = await options.exportSubject?.(request.subjectId); + if (approved && request.action === "delete") await options.deleteSubject?.(request.subjectId); + await emit(`privacy.${request.action}.${decision.status}`, request.subjectId, actorId); + return { decision, result }; + }, + async enforceRetention( + records: Array<{ subjectId: string; createdAt: number }>, + maxAgeMs: number, + remove: (record: { subjectId: string; createdAt: number }) => void | Promise, + ) { + if (maxAgeMs < 0) throw new RangeError("retention duration must not be negative"); + const expired = records.filter((record) => record.createdAt + maxAgeMs <= now()); + for (const record of expired) { + await remove(record); + await emit("retention.deleted", record.subjectId, undefined, { + createdAt: record.createdAt, + }); + } + return expired.length; + }, + }; +} diff --git a/packages/identity/test/identity.test.ts b/packages/identity/test/identity.test.ts new file mode 100644 index 00000000..eea5e6d8 --- /dev/null +++ b/packages/identity/test/identity.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, test } from "bun:test"; +import { + createGovernance, + createMachineIdentityManager, + createSamlFederation, + createScimHandler, + discoverOidc, + memoryScimStore, + oidcAuthorizationUrl, + syncDirectory, +} from "../src/index.ts"; + +describe("enterprise identity", () => { + test("discovers OIDC safely and creates PKCE authorization URLs", async () => { + const metadata = { + issuer: "https://id.test", + authorization_endpoint: "https://id.test/auth", + token_endpoint: "https://id.test/token", + jwks_uri: "https://id.test/jwks", + }; + const discovered = await discoverOidc(metadata.issuer, { + fetch: (async () => Response.json(metadata)) as unknown as typeof fetch, + }); + const url = new URL( + oidcAuthorizationUrl(discovered, { + clientId: "app", + redirectUri: "https://app.test/callback", + state: "state", + nonce: "nonce", + codeChallenge: "challenge", + }), + ); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + }); + + test("validates signed SAML boundaries and prevents replay", async () => { + const assertion = { + id: "assertion-1", + issuer: "https://id.test", + audience: "app", + recipient: "https://app.test/saml", + expiresAt: 200, + identity: { externalId: "u1", username: "u1", groups: [], active: true }, + }; + const federation = createSamlFederation({ + adapter: { createLoginRequest: () => "request", verifySignedResponse: async () => assertion }, + issuer: assertion.issuer, + audience: assertion.audience, + recipient: assertion.recipient, + now: () => 100, + }); + expect((await federation.callback("signed")).externalId).toBe("u1"); + await expect(federation.callback("signed")).rejects.toThrow("WRN-SAML-REPLAY"); + }); + + test("syncs LDAP/AD adapters and provisions SCIM users", async () => { + const values: string[] = []; + expect( + ( + await syncDirectory( + { + kind: "active-directory", + search: async () => [ + { externalId: "u1", username: "user", groups: ["staff"], active: true }, + ], + }, + { + baseDn: "dc=test", + upsert: (identity) => { + values.push(identity.externalId); + }, + }, + ) + ).synchronized, + ).toBe(1); + const handler = createScimHandler({ store: memoryScimStore(), bearerToken: "a".repeat(32) }); + const created = await handler( + new Request("https://app.test/scim/v2/Users", { + method: "POST", + headers: { authorization: `Bearer ${"a".repeat(32)}` }, + body: JSON.stringify({ userName: "person@example.test", active: true }), + }), + ); + expect(created.status).toBe(201); + expect( + ( + await handler( + new Request("https://app.test/scim/v2/Users", { + headers: { authorization: `Bearer ${"a".repeat(32)}` }, + }), + ) + ).status, + ).toBe(200); + expect(values).toEqual(["u1"]); + }); + + test("issues scoped machine identities without exposing hashes", async () => { + const manager = createMachineIdentityManager(() => 100); + const issued = await manager.issue({ + ownerId: "org", + name: "deploy", + kind: "service-account", + scopes: ["deploy:write"], + }); + expect(issued.credential.secretHash).toBe("[REDACTED]"); + expect((await manager.authenticate(issued.secret, "deploy:write"))?.kind).toBe( + "service-account", + ); + expect(await manager.authenticate(issued.secret, "admin")).toBeNull(); + }); + + test("tracks consent, approvals, export/deletion, retention and audit", async () => { + const audit: string[] = []; + const deleted: string[] = []; + const governance = createGovernance({ + now: () => 100, + audit: (event) => { + audit.push(event.type); + }, + exportSubject: (id) => ({ id }), + deleteSubject: (id) => { + deleted.push(id); + }, + }); + await governance.consent("u1", "analytics", true, "v2"); + const exportRequest = await governance.request("u1", "export"); + expect((await governance.decide(exportRequest.id, "admin", true)).result).toEqual({ id: "u1" }); + const deleteRequest = await governance.request("u1", "delete"); + await governance.decide(deleteRequest.id, "admin", true); + expect( + await governance.enforceRetention([{ subjectId: "u2", createdAt: 0 }], 50, (record) => { + deleted.push(record.subjectId); + }), + ).toBe(1); + expect(deleted).toEqual(["u1", "u2"]); + expect(audit).toContain("consent.changed"); + }); +}); diff --git a/packages/image/README.md b/packages/image/README.md index 086ace57..6578cb0e 100644 --- a/packages/image/README.md +++ b/packages/image/README.md @@ -1,16 +1,61 @@ # @wrnexus/image -Responsive image attribute generation with secure remote-host policies and audits for dimensions, LCP loading, source oversizing, transfer size, and modern formats. +Secure responsive-image planning, loader adapters, picture sources, preload hints, placeholders, and performance auditing for WRNexusJS. + +Build-time conversion is available through `optimizeImage`. It normalizes and +bounds width/format variants, prevents variant explosions, writes deterministic +filenames, and returns a manifest with dimensions and byte sizes: ```ts -import { createResponsiveImage } from "@wrnexus/image"; -const attrs = createResponsiveImage({ - src: "/hero.jpg", - alt: "Hero", - width: 1600, - height: 900, - widths: [480, 960, 1600], - sizes: "100vw", - format: "avif", +import { optimizeImage } from "@wrnexus/image"; + +const manifest = await optimizeImage("public/hero.jpg", { + outputDir: "public/generated/images", + widths: [480, 960, 1440], + formats: ["avif", "webp"], + quality: 80, }); ``` + +Install the optional `sharp` peer (`bun add sharp`) for the default AVIF/WebP +processor. Build systems can instead supply an `ImageProcessor` adapter, which +also makes transformation pipelines deterministic in tests. + +## Helper API + +```ts +import { + createResponsiveImage, + createPicture, + createCdnImageLoader, + createPathImageLoader, + createBlurPlaceholder, + imagePreload, + auditImage, +} from "@wrnexus/image"; + +const loader = createCdnImageLoader("https://images.example.com/transform"); +const picture = createPicture({ + src: "/hero.jpg", + alt: "Product dashboard", + width: 1600, + height: 900, + widths: [480, 768, 1200, 1600], + formats: ["avif", "webp"], + sizes: "(max-width: 768px) 100vw, 1200px", + fetchPriority: "high", + loader, +}); +``` + +Remote loaders require HTTPS. Source URLs are validated, dimensions and quality are bounded, placeholder colors are restricted to safe CSS colors, and preload attributes are escaped. + +## Components + +Enable `imagePlugin()` and use: + +- `` +- `` +- `` + +The package-owned blocks compose `@wrnexus/ui` where a complete UI block is appropriate while keeping the low-level image element lightweight. diff --git a/packages/image/components/ImageCard.wrn b/packages/image/components/ImageCard.wrn new file mode 100644 index 00000000..34c328d9 --- /dev/null +++ b/packages/image/components/ImageCard.wrn @@ -0,0 +1,21 @@ +component ImageCard { + props { + title: string = "" + description: string = "" + src: string = "" + alt: string = "" + width: string = "" + height: string = "" + href: string = "" + actionLabel: string = "" + color: string = "primary" + size: string = "md" + class: string = "" + } + view { + + + + + } +} diff --git a/packages/image/components/OptimizedImage.wrn b/packages/image/components/OptimizedImage.wrn new file mode 100644 index 00000000..5847c2fa --- /dev/null +++ b/packages/image/components/OptimizedImage.wrn @@ -0,0 +1,41 @@ +component OptimizedImage { + outputs { + load(payload: { sourceEvent: Event; src: string }) + error(payload: { sourceEvent: Event; src: string }) + } + props { + src: string = "" + srcset: string = "" + sizes: string = "" + alt: string = "" + width: string = "" + height: string = "" + loading: string = "lazy" + decoding: string = "async" + fetchpriority: string = "auto" + placeholder: string = "" + objectFit: string = "cover" + rounded: boolean = false + color: string = "primary" + size: string = "md" + class: string = "" + } + view { + {alt} + } +} diff --git a/packages/image/components/Picture.wrn b/packages/image/components/Picture.wrn new file mode 100644 index 00000000..3d3ee25f --- /dev/null +++ b/packages/image/components/Picture.wrn @@ -0,0 +1,21 @@ +component Picture { + props { + sources: unknown[] = [] + src: string = "" + srcset: string = "" + sizes: string = "" + alt: string = "" + width: string = "" + height: string = "" + loading: string = "lazy" + decoding: string = "async" + fetchpriority: string = "auto" + class: string = "" + } + view { + + {#each sources as source}{/each} + {alt} + + } +} diff --git a/packages/image/package.json b/packages/image/package.json index 24f49c54..a4a0a82e 100644 --- a/packages/image/package.json +++ b/packages/image/package.json @@ -1,13 +1,43 @@ { "name": "@wrnexus/image", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "description": "Responsive image planning, secure remote image policies, and performance auditing for WRNexusJS.", - "main": "src/index.ts", + "main": "./src/index.ts", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./plugin": "./src/plugin.ts", + "./components/*": "./components/*" }, "dependencies": { - "@wrnexus/security": "workspace:*" + "@wrnexus/security": "workspace:*", + "@wrnexus/plugin": "workspace:*", + "@wrnexus/ui": "workspace:*" + }, + "types": "./src/index.ts", + "files": [ + "src", + "components", + "README.md" + ], + "devDependencies": { + "@types/bun": "^1.3.14", + "typescript": "^5.9.2", + "@wrnexus/syntax": "workspace:*" + }, + "peerDependencies": { + "sharp": "^0.35.3" + }, + "peerDependenciesMeta": { + "sharp": { + "optional": true + } + }, + "wrnexus": { + "plugin": { + "plugin": "./src/plugin.ts", + "export": "default", + "factory": true + } } } diff --git a/packages/image/src/index.ts b/packages/image/src/index.ts index c6bb4e9e..5d061321 100644 --- a/packages/image/src/index.ts +++ b/packages/image/src/index.ts @@ -11,6 +11,29 @@ export interface ImageLoaderInput { export type ImageLoader = (input: ImageLoaderInput) => string; +function safeSvgColor(value: string, name: string): string { + const color = value.trim(); + if (color.length > 128) throw new TypeError(`Unsafe ${name} image placeholder color.`); + if ( + /^#[0-9a-f]{3,8}$/i.test(color) || + /^(?:rgb|hsl)a?\([0-9.,%\s/+-]+\)$/i.test(color) || + /^var\(--[A-Za-z0-9_-]+\)$/.test(color) || + /^[A-Za-z]+$/.test(color) + ) { + return color; + } + throw new TypeError(`Unsafe ${name} image placeholder color.`); +} + +function escapeHtmlAttribute(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + export interface ImagePolicy { remoteHosts?: string[]; allowedProtocols?: string[]; @@ -65,11 +88,14 @@ export interface ImageAuditIssue { } export const defaultImageLoader: ImageLoader = ({ src, width, quality, format }) => { - const separator = src.includes("?") ? "&" : "?"; + const hashIndex = src.indexOf("#"); + const source = hashIndex >= 0 ? src.slice(0, hashIndex) : src; + const hash = hashIndex >= 0 ? src.slice(hashIndex) : ""; + const separator = source.includes("?") ? "&" : "?"; const params = new URLSearchParams({ w: String(width) }); if (quality !== undefined) params.set("q", String(quality)); if (format && format !== "original") params.set("format", format); - return `${src}${separator}${params.toString()}`; + return `${source}${separator}${params.toString()}${hash}`; }; function validateSource(src: string, policy: ImagePolicy): void { @@ -100,6 +126,10 @@ export function createResponsiveImage(options: ResponsiveImageOptions): Responsi } const maxWidth = options.maxWidth ?? 8_192; const maxQuality = options.maxQuality ?? 100; + if (!Number.isFinite(maxWidth) || maxWidth <= 0) + throw new RangeError("Image maxWidth must be positive."); + if (!Number.isFinite(maxQuality) || maxQuality <= 0) + throw new RangeError("Image maxQuality must be positive."); const quality = Math.min(maxQuality, Math.max(1, Math.round(options.quality ?? 80))); const widths = [...new Set(options.widths ?? [options.width])] .map((width) => Math.round(width)) @@ -186,3 +216,151 @@ export function auditImage(input: ImageAuditInput): ImageAuditIssue[] { } return issues; } + +export interface PictureSource { + type: string; + srcset: string; + sizes?: string; +} + +export interface PicturePlan { + image: ResponsiveImageAttributes; + sources: PictureSource[]; +} + +export function normalizeImageWidths( + widths: readonly number[], + options: { min?: number; max?: number } = {}, +): number[] { + const requestedMin = options.min ?? 16; + const requestedMax = options.max ?? 8_192; + if (!Number.isFinite(requestedMin) || !Number.isFinite(requestedMax)) { + throw new RangeError("Image width bounds must be finite."); + } + const min = Math.max(1, Math.round(requestedMin)); + const max = Math.max(min, Math.round(requestedMax)); + return [...new Set(widths.map((width) => Math.round(width)))] + .filter((width) => Number.isFinite(width) && width >= min && width <= max) + .sort((left, right) => left - right); +} + +export function createCdnImageLoader( + baseUrl: string, + options: { + sourceParam?: string; + widthParam?: string; + qualityParam?: string; + formatParam?: string; + } = {}, +): ImageLoader { + const base = new URL(baseUrl); + validateUrl(base, { + allowRelative: false, + allowedProtocols: ["https:"], + allowCredentials: false, + }); + return ({ src, width, quality, format }) => { + const url = new URL(base); + url.searchParams.set(options.sourceParam ?? "src", src); + url.searchParams.set(options.widthParam ?? "w", String(width)); + if (quality !== undefined) url.searchParams.set(options.qualityParam ?? "q", String(quality)); + if (format && format !== "original") + url.searchParams.set(options.formatParam ?? "format", format); + return url.toString(); + }; +} + +export function createPathImageLoader(prefix = "/__wrnexus/image"): ImageLoader { + validateUrl(prefix, { + base: "https://wrnexus.invalid", + allowRelative: true, + allowedProtocols: ["https:"], + allowCredentials: false, + }); + return ({ src, width, quality, format }) => { + const path = `${prefix.replace(/\/$/, "")}/${encodeURIComponent(src)}`; + const query = new URLSearchParams({ w: String(width) }); + if (quality !== undefined) query.set("q", String(quality)); + if (format && format !== "original") query.set("format", format); + return `${path}?${query}`; + }; +} + +export function createPicture( + options: ResponsiveImageOptions & { formats?: ImageFormat[] }, +): PicturePlan { + validateSource(options.src, options); + const formats: ImageFormat[] = [...new Set(options.formats ?? ["avif", "webp"])]; + const widths = normalizeImageWidths(options.widths ?? [options.width], { max: options.maxWidth }); + const loader = options.loader ?? defaultImageLoader; + const quality = Math.min( + options.maxQuality ?? 100, + Math.max(1, Math.round(options.quality ?? 80)), + ); + const sources = formats + .filter((format) => format !== "original") + .map((format) => ({ + type: `image/${format === "jpeg" ? "jpeg" : format}`, + srcset: widths + .map((width) => `${loader({ src: options.src, width, quality, format })} ${width}w`) + .join(", "), + ...(options.sizes ? { sizes: options.sizes } : {}), + })); + return { + image: createResponsiveImage({ ...options, widths, format: options.format ?? "original" }), + sources, + }; +} + +export function createBlurPlaceholder( + options: { width?: number; height?: number; color?: string; accent?: string } = {}, +): string { + const requestedWidth = options.width ?? 16; + const requestedHeight = options.height ?? 9; + if (!Number.isFinite(requestedWidth) || !Number.isFinite(requestedHeight)) { + throw new RangeError("Image placeholder dimensions must be finite."); + } + const width = Math.max(1, Math.min(512, Math.round(requestedWidth))); + const height = Math.max(1, Math.min(512, Math.round(requestedHeight))); + const color = safeSvgColor(options.color ?? "#e2e8f0", "primary"); + const accent = safeSvgColor(options.accent ?? "#cbd5e1", "accent"); + const svg = ``; + return `data:image/svg+xml,${encodeURIComponent(svg)}`; +} + +export function imagePreload( + image: ResponsiveImageAttributes, + options: { as?: string; type?: string; crossOrigin?: "anonymous" | "use-credentials" } = {}, +): string { + const attrs = [ + 'rel="preload"', + `as="${options.as ?? "image"}"`, + `href="${escapeHtmlAttribute(image.src)}"`, + ]; + if (image.srcset) attrs.push(`imagesrcset="${escapeHtmlAttribute(image.srcset)}"`); + if (image.sizes) attrs.push(`imagesizes="${escapeHtmlAttribute(image.sizes)}"`); + if (options.type) attrs.push(`type="${escapeHtmlAttribute(options.type)}"`); + if (options.crossOrigin) attrs.push(`crossorigin="${options.crossOrigin}"`); + return ``; +} + +export function imageCacheKey(input: ImageLoaderInput): string { + const source = `${input.src}|${Math.round(input.width)}|${input.quality ?? ""}|${input.format ?? "original"}`; + let hash = 0x811c9dc5; + for (let index = 0; index < source.length; index++) { + hash ^= source.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return `img-${(hash >>> 0).toString(36)}`; +} + +export { imagePlugin, imageComponentsDir } from "./plugin.ts"; +export type { ImagePluginOptions } from "./plugin.ts"; +export { optimizeImage } from "./optimize.ts"; +export type { + ImageProcessor, + ImageProcessorResult, + OptimizeImageOptions, + OptimizedImageManifest, + OptimizedImageVariant, +} from "./optimize.ts"; diff --git a/packages/image/src/optimize.ts b/packages/image/src/optimize.ts new file mode 100644 index 00000000..0e836a82 --- /dev/null +++ b/packages/image/src/optimize.ts @@ -0,0 +1,112 @@ +import { mkdirSync, statSync, writeFileSync } from "node:fs"; +import { basename, extname, join, resolve } from "node:path"; +import { normalizeImageWidths, type ImageFormat } from "./index.ts"; + +export interface ImageProcessorResult { + data: Uint8Array; + width: number; + height: number; +} + +export interface ImageProcessor { + transform( + input: string, + options: { width: number; format: Exclude; quality: number }, + ): Promise; +} + +export interface OptimizeImageOptions { + outputDir: string; + widths: number[]; + formats?: Array>; + quality?: number; + maxVariants?: number; + processor?: ImageProcessor; +} + +export interface OptimizedImageVariant { + path: string; + width: number; + height: number; + format: Exclude; + bytes: number; +} + +export interface OptimizedImageManifest { + source: string; + variants: OptimizedImageVariant[]; +} + +async function sharpProcessor(): Promise { + const packageName = "sharp"; + let sharp: any; + try { + const module = (await import(packageName)) as { default?: any }; + sharp = module.default ?? module; + } catch (error) { + throw new Error( + "WRN-IMAGE-SHARP-MISSING: install the optional `sharp` peer or provide an ImageProcessor", + { cause: error }, + ); + } + return { + async transform(input, options) { + const pipeline = sharp(input) + .rotate() + .resize({ width: options.width, withoutEnlargement: true }); + const { data, info } = await pipeline + .toFormat(options.format, { quality: options.quality }) + .toBuffer({ resolveWithObject: true }); + return { data: new Uint8Array(data), width: info.width, height: info.height }; + }, + }; +} + +export async function optimizeImage( + input: string, + options: OptimizeImageOptions, +): Promise { + const widths = normalizeImageWidths(options.widths); + const formats = [...new Set(options.formats ?? ["avif", "webp"])] as Array< + Exclude + >; + if (!widths.length) throw new RangeError("Image optimization requires at least one valid width"); + if ( + !formats.length || + formats.some((format) => !["avif", "webp", "jpeg", "png"].includes(format)) + ) + throw new RangeError("Image optimization has an unsupported output format"); + const maxVariants = options.maxVariants ?? 32; + if (!Number.isInteger(maxVariants) || maxVariants < 1) + throw new RangeError("Image maxVariants must be positive"); + if (widths.length * formats.length > maxVariants) + throw new RangeError(`Image optimization exceeds ${maxVariants} variants`); + const quality = Math.max(1, Math.min(100, Math.round(options.quality ?? 80))); + const processor = options.processor ?? (await sharpProcessor()); + const outputDir = resolve(options.outputDir); + mkdirSync(outputDir, { recursive: true }); + const stem = basename(input, extname(input)).replace(/[^A-Za-z0-9._-]+/g, "-") || "image"; + const variants: OptimizedImageVariant[] = []; + for (const format of formats) { + for (const width of widths) { + const result = await processor.transform(resolve(input), { width, format, quality }); + if ( + !Number.isInteger(result.width) || + result.width < 1 || + !Number.isInteger(result.height) || + result.height < 1 + ) + throw new Error("Image processor returned invalid dimensions"); + const path = join(outputDir, `${stem}-${result.width}.${format === "jpeg" ? "jpg" : format}`); + writeFileSync(path, result.data); + variants.push({ + path, + width: result.width, + height: result.height, + format, + bytes: statSync(path).size, + }); + } + } + return { source: resolve(input), variants }; +} diff --git a/packages/image/src/plugin.ts b/packages/image/src/plugin.ts new file mode 100644 index 00000000..9daff596 --- /dev/null +++ b/packages/image/src/plugin.ts @@ -0,0 +1,20 @@ +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { definePlugin } from "@wrnexus/plugin"; +export interface ImagePluginOptions { + components?: boolean; + componentDir?: string; +} +const packageRoot = dirname(dirname(fileURLToPath(import.meta.url))); +export function imageComponentsDir(): string { + return join(packageRoot, "components"); +} +export function imagePlugin(options: ImagePluginOptions = {}) { + return definePlugin({ + name: "@wrnexus/image", + version: "0.8.0", + componentDirs: + options.components === false ? [] : [options.componentDir ?? imageComponentsDir()], + }); +} +export default imagePlugin; diff --git a/packages/image/test/optimize.test.ts b/packages/image/test/optimize.test.ts new file mode 100644 index 00000000..0ac3c1b3 --- /dev/null +++ b/packages/image/test/optimize.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { optimizeImage } from "../src/index.ts"; + +test("build optimizer emits deterministic bounded variants and a manifest", async () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-images-")); + const input = join(root, "Hero image.png"); + writeFileSync(input, new Uint8Array([1, 2, 3])); + const manifest = await optimizeImage(input, { + outputDir: join(root, "output"), + widths: [800, 400, 800], + formats: ["webp", "avif"], + processor: { + async transform(_input, options) { + return { + data: new TextEncoder().encode(`${options.width}:${options.format}:${options.quality}`), + width: options.width, + height: options.width / 2, + }; + }, + }, + }); + expect(manifest.variants).toHaveLength(4); + expect(manifest.variants.map(({ width, format }) => `${width}:${format}`)).toEqual([ + "400:webp", + "800:webp", + "400:avif", + "800:avif", + ]); + expect(readFileSync(manifest.variants[0]!.path, "utf8")).toBe("400:webp:80"); +}); + +test("build optimizer rejects variant explosions and unsupported formats", async () => { + const processor = { transform: async () => ({ data: new Uint8Array(), width: 1, height: 1 }) }; + await expect( + optimizeImage("input.png", { + outputDir: ".", + widths: [100, 200], + formats: ["webp", "avif"], + maxVariants: 3, + processor, + }), + ).rejects.toThrow("exceeds"); +}); diff --git a/packages/image/test/package-kit.test.ts b/packages/image/test/package-kit.test.ts new file mode 100644 index 00000000..38fa0f05 --- /dev/null +++ b/packages/image/test/package-kit.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test"; +import { + createBlurPlaceholder, + createCdnImageLoader, + createPathImageLoader, + createPicture, + imagePreload, +} from "../src/index.ts"; + +describe("image package kit", () => { + test("creates AVIF/WebP picture plans and preload hints", () => { + const picture = createPicture({ + src: "/hero.jpg", + alt: "Hero", + width: 1200, + height: 630, + widths: [480, 768, 1200], + formats: ["avif", "webp"], + }); + expect(picture.sources.map((source) => source.type)).toEqual(["image/avif", "image/webp"]); + expect(imagePreload(picture.image)).toContain('rel="preload"'); + }); + + test("requires HTTPS CDN endpoints and safe placeholder colors", () => { + expect(() => createCdnImageLoader("http://images.example.test")).toThrow(); + expect(() => createBlurPlaceholder({ color: 'url("javascript:alert(1)")' })).toThrow(); + expect(createBlurPlaceholder({ color: "#fff", accent: "var(--wire-color-surface)" })).toMatch( + /^data:image\/svg\+xml/, + ); + }); + + test("keeps query parameters before URL fragments and rejects invalid dimensions", () => { + const picture = createPicture({ + src: "/hero.jpg#preview", + alt: "Hero", + width: 640, + height: 360, + }); + expect(picture.image.src).toContain("?w=640"); + expect(picture.image.src.endsWith("#preview")).toBe(true); + expect(() => createBlurPlaceholder({ width: Number.NaN })).toThrow("finite"); + }); + + test("encodes source paths in the local loader", () => { + const loader = createPathImageLoader(); + expect(loader({ src: "/images/hero one.jpg", width: 640 })).toContain( + encodeURIComponent("/images/hero one.jpg"), + ); + }); +}); diff --git a/packages/jwt/README.md b/packages/jwt/README.md index 386278e2..2b5b0903 100644 --- a/packages/jwt/README.md +++ b/packages/jwt/README.md @@ -130,3 +130,62 @@ app.use(jwtAuth({ secret: process.env.JWT_SECRET!, required: false })); - Integrates with [`@wrnexus/core`](../core) for `Context`, `Middleware`, and `ctx.user`; it complements the framework's cookie/session auth with a stateless bearer-token flow for API and mobile clients. + +## Access, refresh, scope, and cookie helpers + +```ts +import { + createAccessToken, + createRefreshToken, + verifyAccessToken, + verifyRefreshToken, + extractBearerToken, + requireScopes, + jwtCookie, +} from "@wrnexus/jwt"; +``` + +The helpers add explicit `type: "access" | "refresh"` claims, scope checks, refresh-token family metadata, no-store token responses, and secure cookie defaults. `__Host-` cookies are rejected unless they use `Path=/` and `Secure`; `SameSite=None` is rejected without `Secure`. + +## 0.8 helper kit + +```ts +import { + createTokenPair, + verifyAccessToken, + verifyRefreshToken, + extractBearerToken, + readJwtCookie, + jwtCookie, + clearJwtCookie, + requireScopes, +} from "@wrnexus/jwt"; + +const pair = await createTokenPair(user.id, { + accessSecret: process.env.JWT_ACCESS_SECRET!, + refreshSecret: process.env.JWT_REFRESH_SECRET!, + scopes: ["profile:read"], + family: sessionFamily, +}); +``` + +The helper kit validates `__Host-` cookie invariants, cookie names and paths, `SameSite=None` security, typed access/refresh token types, scope requirements, and no-store token responses. +In addition to local HS256 secrets/keyrings, the package verifies standards-based +RS256 tokens through bounded remote JWKS caches: + +```ts +import { createRemoteJwks, verifyJwtWithJwks } from "@wrnexus/jwt"; + +const jwks = createRemoteJwks("https://issuer.example/.well-known/jwks.json"); +const claims = await verifyJwtWithJwks(token, jwks, { + issuer: "https://issuer.example", + audience: "my-api", + maxAge: 300, +}); +``` + +JWKS URLs must use HTTPS. Responses have key-count/byte limits, accept only +RS256 signing RSA keys, deduplicate concurrent refreshes, cache imported public +keys, and force an immediate refresh for an unknown `kid` so issuer rotation +does not wait for cache expiry. Never use decoded-but-unverified claims for an +authorization decision. diff --git a/packages/jwt/package.json b/packages/jwt/package.json index ef1cffe8..913f4a7e 100644 --- a/packages/jwt/package.json +++ b/packages/jwt/package.json @@ -1,10 +1,28 @@ { "name": "@wrnexus/jwt", - "version": "0.7.0", + "version": "0.8.0", "private": true, "type": "module", - "main": "src/index.ts", + "main": "./src/index.ts", "exports": { ".": "./src/index.ts" + }, + "description": "HS256 JSON Web Tokens, key rotation, access/refresh helpers, scopes, cookies, and auth middleware.", + "types": "./src/index.ts", + "files": [ + "src", + "README.md" + ], + "scripts": { + "test": "bun test", + "typecheck": "tsc --noEmit", + "check": "bun run typecheck && bun run test" + }, + "devDependencies": { + "@types/bun": "^1.3.14", + "typescript": "^5.9.2" + }, + "dependencies": { + "@wrnexus/core": "workspace:*" } } diff --git a/packages/jwt/src/helpers.ts b/packages/jwt/src/helpers.ts new file mode 100644 index 00000000..49b28d4b --- /dev/null +++ b/packages/jwt/src/helpers.ts @@ -0,0 +1,276 @@ +import type { Context, Middleware } from "@wrnexus/core"; +import { + JwtError, + signJwt, + verifyJwt, + type JwtClaims, + type SignOptions, + type VerifyOptions, +} from "./index.ts"; + +export interface AccessTokenClaims extends JwtClaims { + sub: string; + type: "access"; + scopes?: string[]; +} + +export interface RefreshTokenClaims extends JwtClaims { + sub: string; + type: "refresh"; + family?: string; +} + +export function extractBearerToken( + value: Headers | Request | Context | string | null | undefined, +): string | undefined { + const header = + typeof value === "string" || value == null + ? (value ?? "") + : "req" in value + ? (value.req.headers.get("authorization") ?? "") + : value instanceof Request + ? (value.headers.get("authorization") ?? "") + : (value.get("authorization") ?? ""); + return /^Bearer\s+(.+)$/i.exec(header)?.[1]; +} + +export async function tryVerifyJwt( + token: string | undefined, + secret: string, + options: VerifyOptions = {}, +): Promise { + if (!token) return null; + try { + return await verifyJwt(token, secret, options); + } catch { + return null; + } +} + +export function assertJwtClaims( + claims: T, + requirements: { + subject?: boolean; + type?: string; + required?: string[]; + } = {}, +): T { + if (requirements.subject && !claims.sub) throw new JwtError("Token subject is required"); + if (requirements.type && claims.type !== requirements.type) + throw new JwtError("Invalid token type"); + for (const name of requirements.required ?? []) { + if (!(name in claims)) throw new JwtError(`Missing required claim: ${name}`); + } + return claims; +} + +export function tokenScopes(claims: JwtClaims): string[] { + const value = claims.scopes ?? claims.scope; + if (Array.isArray(value)) + return value.filter((entry): entry is string => typeof entry === "string"); + if (typeof value === "string") return value.split(/\s+/).filter(Boolean); + return []; +} + +export function hasScopes( + claims: JwtClaims, + required: readonly string[], + mode: "all" | "any" = "all", +): boolean { + const scopes = new Set(tokenScopes(claims)); + return mode === "all" + ? required.every((scope) => scopes.has(scope)) + : required.some((scope) => scopes.has(scope)); +} + +export function requireScopes( + required: readonly string[], + mode: "all" | "any" = "all", +): Middleware { + return async (ctx, next) => { + const claims = ctx.user && typeof ctx.user === "object" ? (ctx.user as JwtClaims) : {}; + if (!hasScopes(claims, required, mode)) { + return Response.json({ ok: false, error: "Insufficient scope" }, { status: 403 }); + } + return next(); + }; +} + +export function createAccessToken( + subject: string, + secret: string, + options: Omit & { + expiresIn?: number; + scopes?: string[]; + claims?: JwtClaims; + } = {}, +): Promise { + const { claims, scopes, expiresIn, ...signOptions } = options; + return signJwt( + { ...claims, sub: subject, type: "access", ...(scopes ? { scopes } : {}) }, + secret, + { ...signOptions, expiresIn: expiresIn ?? 15 * 60 }, + ); +} + +export function createRefreshToken( + subject: string, + secret: string, + options: Omit & { + expiresIn?: number; + family?: string; + claims?: JwtClaims; + } = {}, +): Promise { + const { claims, family, expiresIn, ...signOptions } = options; + return signJwt( + { ...claims, sub: subject, type: "refresh", ...(family ? { family } : {}) }, + secret, + { ...signOptions, expiresIn: expiresIn ?? 30 * 24 * 60 * 60 }, + ); +} + +export async function verifyAccessToken( + token: string, + secret: string, + options: VerifyOptions = {}, +): Promise { + return assertJwtClaims(await verifyJwt(token, secret, options), { + subject: true, + type: "access", + }); +} + +export async function verifyRefreshToken( + token: string, + secret: string, + options: VerifyOptions = {}, +): Promise { + return assertJwtClaims(await verifyJwt(token, secret, options), { + subject: true, + type: "refresh", + }); +} + +const COOKIE_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; + +function cookieSource(value: Headers | Request | string | null | undefined): string { + if (typeof value === "string" || value == null) return value ?? ""; + return value instanceof Request + ? (value.headers.get("cookie") ?? "") + : (value.get("cookie") ?? ""); +} + +export function readJwtCookie( + value: Headers | Request | string | null | undefined, + name = "__Host-wrn_token", +): string | undefined { + if (!COOKIE_NAME.test(name)) throw new TypeError("Invalid JWT cookie name."); + for (const part of cookieSource(value).split(";")) { + const index = part.indexOf("="); + if (index < 0) continue; + const key = part.slice(0, index).trim(); + if (key !== name) continue; + try { + return decodeURIComponent(part.slice(index + 1).trim()); + } catch { + return undefined; + } + } + return undefined; +} + +export function jwtCookie( + token: string, + options: { + name?: string; + maxAge?: number; + secure?: boolean; + sameSite?: "Strict" | "Lax" | "None"; + path?: string; + } = {}, +): string { + const name = options.name ?? "__Host-wrn_token"; + const path = options.path ?? "/"; + const sameSite = options.sameSite ?? "Lax"; + const secure = options.secure !== false; + if (!COOKIE_NAME.test(name)) throw new TypeError("Invalid JWT cookie name."); + if (!path.startsWith("/") || /[;\r\n]/.test(path)) + throw new TypeError("Invalid JWT cookie path."); + if (name.startsWith("__Host-") && (path !== "/" || !secure)) { + throw new TypeError("__Host- JWT cookies require Path=/ and Secure."); + } + if (sameSite === "None" && !secure) { + throw new TypeError("SameSite=None JWT cookies require Secure."); + } + const parts = [ + `${name}=${encodeURIComponent(token)}`, + `Path=${path}`, + "HttpOnly", + `SameSite=${sameSite}`, + ]; + if (secure) parts.push("Secure"); + if (options.maxAge !== undefined) { + if (!Number.isFinite(options.maxAge)) throw new RangeError("JWT cookie maxAge must be finite."); + parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAge))}`); + } + return parts.join("; "); +} + +export function clearJwtCookie( + options: Omit[1], "maxAge"> = {}, +): string { + return jwtCookie("", { ...options, maxAge: 0 }); +} + +export interface JwtTokenPair { + accessToken: string; + refreshToken: string; + tokenType: "Bearer"; + expiresIn: number; +} + +export async function createTokenPair( + subject: string, + input: { + accessSecret: string; + refreshSecret?: string; + accessExpiresIn?: number; + refreshExpiresIn?: number; + scopes?: string[]; + family?: string; + accessOptions?: Omit; + refreshOptions?: Omit; + }, +): Promise { + const expiresIn = input.accessExpiresIn ?? 15 * 60; + const [accessToken, refreshToken] = await Promise.all([ + createAccessToken(subject, input.accessSecret, { + ...input.accessOptions, + expiresIn, + scopes: input.scopes, + }), + createRefreshToken(subject, input.refreshSecret ?? input.accessSecret, { + ...input.refreshOptions, + expiresIn: input.refreshExpiresIn ?? 30 * 24 * 60 * 60, + family: input.family, + }), + ]); + return { accessToken, refreshToken, tokenType: "Bearer", expiresIn }; +} + +export function jwtResponse( + accessToken: string, + input: { refreshToken?: string; expiresIn?: number; tokenType?: string; scope?: string[] } = {}, +): Response { + return Response.json( + { + accessToken, + tokenType: input.tokenType ?? "Bearer", + expiresIn: input.expiresIn ?? 900, + ...(input.refreshToken ? { refreshToken: input.refreshToken } : {}), + ...(input.scope ? { scope: input.scope.join(" ") } : {}), + }, + { headers: { "cache-control": "no-store", pragma: "no-cache" } }, + ); +} diff --git a/packages/jwt/src/index.ts b/packages/jwt/src/index.ts index 4f51c55d..7f94ee2b 100644 --- a/packages/jwt/src/index.ts +++ b/packages/jwt/src/index.ts @@ -231,3 +231,23 @@ function unauthorized(): Response { } export { decodeJwt, createJwtKeyring, signWithKeyring, verifyWithKeyring } from "./keyring.ts"; export type { JwtKey, JwtKeyring } from "./keyring.ts"; +export { + extractBearerToken, + tryVerifyJwt, + assertJwtClaims, + tokenScopes, + hasScopes, + requireScopes, + createAccessToken, + createRefreshToken, + verifyAccessToken, + verifyRefreshToken, + readJwtCookie, + jwtCookie, + clearJwtCookie, + createTokenPair, + jwtResponse, +} from "./helpers.ts"; +export type { AccessTokenClaims, RefreshTokenClaims, JwtTokenPair } from "./helpers.ts"; +export { createRemoteJwks, verifyJwtWithJwks } from "./jwks.ts"; +export type { RemoteJwks, RemoteJwksOptions } from "./jwks.ts"; diff --git a/packages/jwt/src/jwks.ts b/packages/jwt/src/jwks.ts new file mode 100644 index 00000000..f783425b --- /dev/null +++ b/packages/jwt/src/jwks.ts @@ -0,0 +1,179 @@ +import { decodeJwt, type JwtClaims } from "./index.ts"; +import { JwtError, type VerifyOptions } from "./index.ts"; + +export interface RemoteJwksOptions { + fetch?: typeof fetch; + cacheTtlMs?: number; + maxKeys?: number; + maxBytes?: number; + now?: () => number; +} + +export interface RemoteJwks { + resolve(kid: string, alg: string): Promise; + refresh(): Promise; + clear(): void; + stats(): { fetches: number; hits: number; keys: number; expiresAt: number }; +} + +type JwksKey = JsonWebKey & { kid?: string; alg?: string; use?: string }; +type StoredKey = { jwk: JwksKey; key?: Promise }; + +function decodeBase64Url(value: string): Uint8Array { + const padding = value.length % 4 === 0 ? "" : "=".repeat(4 - (value.length % 4)); + const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/") + padding); + return Uint8Array.from(binary, (character) => character.charCodeAt(0)); +} + +export function createRemoteJwks(url: string, options: RemoteJwksOptions = {}): RemoteJwks { + const endpoint = new URL(url); + if (endpoint.protocol !== "https:") throw new TypeError("JWKS URL must use HTTPS"); + const fetchImpl = options.fetch ?? fetch; + const cacheTtlMs = options.cacheTtlMs ?? 5 * 60_000; + const maxKeys = options.maxKeys ?? 32; + const maxBytes = options.maxBytes ?? 256 * 1024; + if (!Number.isFinite(cacheTtlMs) || cacheTtlMs < 0) + throw new RangeError("JWKS cacheTtlMs must be non-negative"); + if (!Number.isInteger(maxKeys) || maxKeys < 1) + throw new RangeError("JWKS maxKeys must be positive"); + if (!Number.isInteger(maxBytes) || maxBytes < 1) + throw new RangeError("JWKS maxBytes must be positive"); + const now = options.now ?? Date.now; + const keys = new Map(); + let expiresAt = 0; + let etag: string | undefined; + let refreshing: Promise | undefined; + let fetches = 0; + let hits = 0; + + const refresh = async (): Promise => { + if (refreshing) return refreshing; + refreshing = (async () => { + fetches++; + const response = await fetchImpl(endpoint, { + headers: etag + ? { accept: "application/json", "if-none-match": etag } + : { accept: "application/json" }, + }); + if (response.status === 304) { + expiresAt = now() + cacheTtlMs; + return; + } + if (!response.ok) throw new JwtError(`JWKS fetch failed (${response.status})`); + const declared = Number(response.headers.get("content-length")); + if (Number.isFinite(declared) && declared > maxBytes) + throw new JwtError("JWKS response is too large"); + const text = await response.text(); + if (new TextEncoder().encode(text).byteLength > maxBytes) + throw new JwtError("JWKS response is too large"); + let document: { keys?: JwksKey[] }; + try { + document = JSON.parse(text) as { keys?: JwksKey[] }; + } catch { + throw new JwtError("JWKS response is invalid JSON"); + } + if (!Array.isArray(document.keys) || document.keys.length > maxKeys) + throw new JwtError("JWKS response has an invalid key set"); + const next = new Map(); + for (const jwk of document.keys) { + if ( + typeof jwk.kid !== "string" || + !jwk.kid || + jwk.kty !== "RSA" || + (jwk.use !== undefined && jwk.use !== "sig") || + (jwk.alg !== undefined && jwk.alg !== "RS256") || + typeof jwk.n !== "string" || + typeof jwk.e !== "string" + ) { + continue; + } + if (next.has(jwk.kid)) throw new JwtError(`JWKS contains duplicate kid: ${jwk.kid}`); + next.set(jwk.kid, { jwk }); + } + keys.clear(); + for (const [kid, key] of next) keys.set(kid, key); + etag = response.headers.get("etag") ?? undefined; + expiresAt = now() + cacheTtlMs; + })().finally(() => { + refreshing = undefined; + }); + return refreshing; + }; + + return { + async resolve(kid, alg) { + if (!kid) throw new JwtError("Token has no key id"); + if (alg !== "RS256") throw new JwtError(`Unsupported token algorithm: ${alg}`); + if (now() >= expiresAt) await refresh(); + let stored = keys.get(kid); + if (!stored) { + await refresh(); + stored = keys.get(kid); + } + if (!stored) throw new JwtError(`Unknown key id: ${kid}`); + hits++; + stored.key ??= crypto.subtle.importKey( + "jwk", + stored.jwk, + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["verify"], + ); + return stored.key; + }, + refresh, + clear() { + keys.clear(); + expiresAt = 0; + etag = undefined; + }, + stats: () => ({ fetches, hits, keys: keys.size, expiresAt }), + }; +} + +export async function verifyJwtWithJwks( + token: string, + jwks: RemoteJwks, + options: VerifyOptions = {}, +): Promise { + const parts = token.split("."); + if (parts.length !== 3) throw new JwtError("Malformed token"); + const decoded = decodeJwt(token); + const kid = typeof decoded.header.kid === "string" ? decoded.header.kid : ""; + const alg = typeof decoded.header.alg === "string" ? decoded.header.alg : ""; + const key = await jwks.resolve(kid, alg); + const valid = await crypto.subtle.verify( + "RSASSA-PKCS1-v1_5", + key, + decodeBase64Url(parts[2]!) as BufferSource, + new TextEncoder().encode(`${parts[0]}.${parts[1]}`) as BufferSource, + ); + if (!valid) throw new JwtError("Invalid signature"); + const claims = decoded.claims as T; + const now = options.now ?? Math.floor(Date.now() / 1000); + const tolerance = Math.max(0, options.clockTolerance ?? 0); + if (typeof claims.exp === "number" && now - tolerance >= claims.exp) + throw new JwtError("Token expired"); + if (typeof claims.nbf === "number" && now + tolerance < claims.nbf) + throw new JwtError("Token not yet valid"); + if (options.issuer !== undefined && claims.iss !== options.issuer) + throw new JwtError("Invalid issuer"); + if (options.audience !== undefined) { + const expected = Array.isArray(options.audience) ? options.audience : [options.audience]; + const actual = Array.isArray(claims.aud) + ? claims.aud + : typeof claims.aud === "string" + ? [claims.aud] + : []; + if (!expected.some((audience) => actual.includes(audience))) + throw new JwtError("Invalid audience"); + } + if ( + options.maxAge !== undefined && + typeof claims.iat === "number" && + now - claims.iat > options.maxAge + tolerance + ) { + throw new JwtError("Token is too old"); + } + return claims; +} diff --git a/packages/jwt/test/helpers.test.ts b/packages/jwt/test/helpers.test.ts new file mode 100644 index 00000000..42340d90 --- /dev/null +++ b/packages/jwt/test/helpers.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test"; +import { + clearJwtCookie, + createAccessToken, + createRefreshToken, + createTokenPair, + extractBearerToken, + hasScopes, + jwtCookie, + readJwtCookie, + tokenScopes, + verifyAccessToken, + verifyRefreshToken, +} from "../src/index.ts"; + +const secret = "a-long-jwt-package-helper-test-secret"; + +describe("JWT helper kit", () => { + test("creates and verifies typed access and refresh tokens", async () => { + const access = await createAccessToken("user-1", secret, { + scopes: ["profile:read", "profile:write"], + expiresIn: 60, + now: 100, + }); + const refresh = await createRefreshToken("user-1", secret, { + family: "family-1", + expiresIn: 600, + now: 100, + }); + const accessClaims = await verifyAccessToken(access, secret, { now: 120 }); + const refreshClaims = await verifyRefreshToken(refresh, secret, { now: 120 }); + expect(hasScopes(accessClaims, ["profile:read", "profile:write"])).toBe(true); + expect(tokenScopes(accessClaims)).toEqual(["profile:read", "profile:write"]); + expect(refreshClaims.family).toBe("family-1"); + }); + + test("creates complete access and refresh pairs", async () => { + const pair = await createTokenPair("user-1", { + accessSecret: secret, + accessExpiresIn: 60, + refreshExpiresIn: 600, + scopes: ["profile:read"], + family: "family-2", + accessOptions: { now: 100 }, + refreshOptions: { now: 100 }, + }); + expect((await verifyAccessToken(pair.accessToken, secret, { now: 120 })).type).toBe("access"); + expect((await verifyRefreshToken(pair.refreshToken, secret, { now: 120 })).family).toBe( + "family-2", + ); + }); + + test("extracts bearer and cookie tokens from supported sources", () => { + expect(extractBearerToken("Bearer abc")).toBe("abc"); + expect(extractBearerToken(new Headers({ authorization: "bearer xyz" }))).toBe("xyz"); + expect(readJwtCookie("other=x; __Host-wrn_token=abc%20123")).toBe("abc 123"); + }); + + test("enforces secure cookie invariants", () => { + expect(jwtCookie("token")).toContain("__Host-wrn_token=token"); + expect(clearJwtCookie()).toContain("Max-Age=0"); + expect(() => jwtCookie("token", { path: "/auth" })).toThrow("__Host-"); + expect(() => jwtCookie("token", { name: "bad;name" })).toThrow("cookie name"); + expect(() => jwtCookie("token", { name: "token", sameSite: "None", secure: false })).toThrow( + "SameSite=None", + ); + }); +}); diff --git a/packages/jwt/test/jwks.test.ts b/packages/jwt/test/jwks.test.ts new file mode 100644 index 00000000..4302a252 --- /dev/null +++ b/packages/jwt/test/jwks.test.ts @@ -0,0 +1,97 @@ +import { expect, test } from "bun:test"; +import { createRemoteJwks, verifyJwtWithJwks } from "../src/index.ts"; + +function base64url(value: Uint8Array): string { + let binary = ""; + for (const byte of value) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +async function rsaKey(kid: string) { + const pair = (await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + )) as CryptoKeyPair; + const jwk = await crypto.subtle.exportKey("jwk", pair.publicKey); + return { pair, jwk: { ...jwk, kid, alg: "RS256", use: "sig" } }; +} + +async function sign(privateKey: CryptoKey, kid: string, claims: Record) { + const encoder = new TextEncoder(); + const header = base64url(encoder.encode(JSON.stringify({ alg: "RS256", typ: "JWT", kid }))); + const payload = base64url(encoder.encode(JSON.stringify(claims))); + const input = `${header}.${payload}`; + const signature = await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + privateKey, + encoder.encode(input), + ); + return `${input}.${base64url(new Uint8Array(signature))}`; +} + +test("remote JWKS verifies RS256 claims and refreshes immediately for key rotation", async () => { + const first = await rsaKey("key-1"); + const second = await rsaKey("key-2"); + const documents = [{ keys: [first.jwk] }, { keys: [second.jwk] }]; + let fetches = 0; + const jwks = createRemoteJwks("https://issuer.example/jwks", { + now: () => 0, + cacheTtlMs: 60_000, + fetch: (async () => + Response.json( + documents[Math.min(fetches++, documents.length - 1)], + )) as unknown as typeof fetch, + }); + const tokenOne = await sign(first.pair.privateKey, "key-1", { + sub: "user-1", + iss: "https://issuer.example", + aud: "client-1", + iat: 100, + exp: 200, + }); + expect( + ( + await verifyJwtWithJwks(tokenOne, jwks, { + issuer: "https://issuer.example", + audience: "client-1", + now: 150, + }) + ).sub, + ).toBe("user-1"); + expect(fetches).toBe(1); + + const tokenTwo = await sign(second.pair.privateKey, "key-2", { + sub: "user-2", + iss: "https://issuer.example", + aud: "client-1", + exp: 200, + }); + expect((await verifyJwtWithJwks(tokenTwo, jwks, { now: 150 })).sub).toBe("user-2"); + expect(fetches).toBe(2); + expect(jwks.stats()).toMatchObject({ fetches: 2, hits: 2, keys: 1 }); + await expect(verifyJwtWithJwks(tokenOne, jwks, { now: 150 })).rejects.toThrow("Unknown key"); +}); + +test("remote JWKS rejects insecure, oversized, and incompatible key sets", async () => { + expect(() => createRemoteJwks("http://issuer.example/jwks")).toThrow("HTTPS"); + const oversized = createRemoteJwks("https://issuer.example/jwks", { + maxBytes: 10, + fetch: (async () => Response.json({ keys: [] })) as unknown as typeof fetch, + }); + await expect(oversized.refresh()).rejects.toThrow("too large"); + + const incompatible = createRemoteJwks("https://issuer.example/jwks", { + fetch: (async () => + Response.json({ + keys: [{ kid: "ec", kty: "EC", alg: "ES256" }], + })) as unknown as typeof fetch, + }); + await incompatible.refresh(); + await expect(incompatible.resolve("ec", "RS256")).rejects.toThrow("Unknown key"); +}); diff --git a/packages/language-server/README.md b/packages/language-server/README.md new file mode 100644 index 00000000..444a0a25 --- /dev/null +++ b/packages/language-server/README.md @@ -0,0 +1,26 @@ +# @wrnexus/language-server + +Editor-neutral Language Server Protocol support for `.wrn` files. It uses the canonical +`@wrnexus/syntax` parser, diagnostics, accessibility rules, and formatter. + +```bash +bunx wrnexus-language-server --stdio +``` + +Capabilities include syntax, accessibility and TypeScript expression diagnostics, formatting, +completion, hover, document symbols, go-to-definition, references, rename, and quick fixes. +The custom `wrnexus/virtualDocument` request returns the mapped TypeScript representation of an +open `.wrn` document for editor TypeScript plugins and safe refactoring tools. Any LSP 3.x client can launch the +stdio command. Example Neovim configuration: + +```lua +vim.lsp.start({ + name = "wrnexus", + cmd = { "bunx", "wrnexus-language-server", "--stdio" }, + root_dir = vim.fs.root(0, { "wrnexus.config.ts", "package.json", ".git" }), +}) +``` + +JetBrains users can register the same command through an LSP client/plugin. The server +does not require VS Code and never reads environment secrets or sends source over a +network. diff --git a/packages/language-server/package.json b/packages/language-server/package.json new file mode 100644 index 00000000..6fa80108 --- /dev/null +++ b/packages/language-server/package.json @@ -0,0 +1,18 @@ +{ + "name": "@wrnexus/language-server", + "version": "0.8.0", + "type": "module", + "description": "Editor-neutral Language Server Protocol implementation for WRNexus .wrn files.", + "main": "src/index.ts", + "bin": { + "wrnexus-language-server": "src/server.ts" + }, + "exports": { + ".": "./src/index.ts", + "./server": "./src/server.ts" + }, + "dependencies": { + "@wrnexus/syntax": "workspace:*", + "@wrnexus/typecheck": "workspace:*" + } +} diff --git a/packages/language-server/src/index.ts b/packages/language-server/src/index.ts new file mode 100644 index 00000000..16e03e70 --- /dev/null +++ b/packages/language-server/src/index.ts @@ -0,0 +1,238 @@ +import { diagnose, formatWrn, type WrnDiagnostic } from "@wrnexus/syntax"; +import { checkWrnSource, virtualTypeScriptModule } from "@wrnexus/typecheck"; + +export interface Position { + line: number; + character: number; +} +export interface Range { + start: Position; + end: Position; +} +export interface TextDocument { + uri: string; + text: string; + version?: number; +} + +export const WRN_COMPLETIONS = [ + "page", + "component", + "layout", + "props", + "outputs", + "state", + "computed", + "effect", + "watch", + "lifecycle", + "load", + "action", + "api", + "realtime", + "view", + "style", + "runtime", + "hydrate", +] as const; + +export function offsetAt(text: string, position: Position): number { + const lines = text.split(/\r?\n/); + let offset = 0; + for (let line = 0; line < Math.min(position.line, lines.length); line++) + offset += (lines[line]?.length ?? 0) + 1; + return Math.min(text.length, offset + Math.max(0, position.character)); +} + +export function positionAt(text: string, requestedOffset: number): Position { + const offset = Math.max(0, Math.min(text.length, requestedOffset)); + const prefix = text.slice(0, offset); + const lines = prefix.split(/\r?\n/); + return { line: lines.length - 1, character: lines.at(-1)?.length ?? 0 }; +} + +export function wordAt(text: string, position: Position): { word: string; range: Range } | null { + const offset = offsetAt(text, position); + const left = text.slice(0, offset).match(/[A-Za-z_$][\w$]*$/)?.[0] ?? ""; + const right = text.slice(offset).match(/^[\w$]*/)?.[0] ?? ""; + const word = left + right; + if (!word) return null; + const start = offset - left.length; + return { + word, + range: { start: positionAt(text, start), end: positionAt(text, start + word.length) }, + }; +} + +function diagnosticRange(diagnostic: WrnDiagnostic): Range { + const start = diagnostic.position ?? { line: 1, column: 1, offset: 0 }; + return { + start: { line: Math.max(0, start.line - 1), character: Math.max(0, start.column - 1) }, + end: { + line: Math.max(0, start.line - 1), + character: Math.max(1, start.column - 1 + Math.max(1, diagnostic.received?.length ?? 1)), + }, + }; +} + +export function documentDiagnostics(document: TextDocument) { + if (Buffer.byteLength(document.text, "utf8") > 1_048_576) { + return [ + { + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }, + severity: 2, + code: "WRN-LSP-FILE-SIZE", + source: "wrnexus", + message: "Type analysis is disabled because this WRN document exceeds 1 MiB.", + }, + ]; + } + const syntax = diagnose(document.text, { file: document.uri, accessibility: true }).map( + (diagnostic) => ({ + range: diagnosticRange(diagnostic), + severity: diagnostic.severity === "error" ? 1 : diagnostic.severity === "warning" ? 2 : 3, + code: diagnostic.code, + source: "wrnexus", + message: diagnostic.message, + }), + ); + if (syntax.some((diagnostic) => diagnostic.severity === 1)) return syntax; + const types = checkWrnSource(document.text, { filePath: documentPath(document.uri) }).map( + (diagnostic) => ({ + range: { + start: { + line: Math.max(0, diagnostic.line - 1), + character: Math.max(0, diagnostic.column - 1), + }, + end: { + line: Math.max(0, diagnostic.line - 1), + character: Math.max(1, diagnostic.column - 1 + Math.max(1, diagnostic.length)), + }, + }, + severity: diagnostic.category === "error" ? 1 : diagnostic.category === "warning" ? 2 : 3, + code: diagnostic.code, + source: "wrnexus-types", + message: diagnostic.message, + }), + ); + const seen = new Set( + syntax.map((item) => `${item.code}:${item.range.start.line}:${item.range.start.character}`), + ); + return [ + ...syntax, + ...types.filter( + (item) => !seen.has(`${item.code}:${item.range.start.line}:${item.range.start.character}`), + ), + ]; +} + +function documentPath(uri: string): string { + if (!uri.startsWith("file://")) return uri; + const value = decodeURIComponent(uri.slice("file://".length)); + return /^\/[A-Za-z]:\//.test(value) ? value.slice(1) : value; +} + +/** TypeScript representation consumed by editor TypeScript plugins and safe refactoring tools. */ +export function virtualTypeScriptDocument(document: TextDocument): { + uri: string; + languageId: "typescript"; + text: string; + mappings: Array<{ + virtualStartLine: number; + virtualEndLine: number; + sourceStartLine: number; + sourceStartColumn: number; + }>; +} { + const virtual = virtualTypeScriptModule(document.text, documentPath(document.uri)); + return { + uri: `${document.uri}.ts`, + languageId: "typescript", + text: virtual.code, + mappings: virtual.mappings, + }; +} + +export function formatDocument(document: TextDocument, tabSize = 4, insertSpaces = true) { + const formatted = formatWrn(document.text, { tabSize, insertSpaces }); + if (formatted === document.text) return []; + return [ + { + range: { + start: { line: 0, character: 0 }, + end: positionAt(document.text, document.text.length), + }, + newText: formatted, + }, + ]; +} + +export function documentSymbols(document: TextDocument) { + const pattern = + /\b(page|component|layout|state|computed|watch|effect|load|action|api)\s+([A-Za-z_$][\w$]*)/g; + return [...document.text.matchAll(pattern)].map((match) => { + const name = match[2]!; + const start = match.index! + match[0].lastIndexOf(name); + const range = { + start: positionAt(document.text, start), + end: positionAt(document.text, start + name.length), + }; + return { + name, + kind: ["page", "component", "layout"].includes(match[1]!) ? 5 : 13, + range, + selectionRange: range, + }; + }); +} + +export function symbolLocations(document: TextDocument, position: Position) { + const selected = wordAt(document.text, position); + if (!selected) return []; + const pattern = new RegExp( + `(? ({ + uri: document.uri, + range: { + start: positionAt(document.text, match.index!), + end: positionAt(document.text, match.index! + selected.word.length), + }, + })); +} + +export function definitionLocation(document: TextDocument, position: Position) { + const selected = wordAt(document.text, position); + if (!selected) return null; + const declaration = new RegExp( + `\\b(?:state|computed|page|component|layout)\\s+${selected.word}\\b|\\b${selected.word}\\s*(?=[:?])`, + ).exec(document.text); + if (!declaration) return null; + const start = declaration.index + declaration[0].lastIndexOf(selected.word); + return { + uri: document.uri, + range: { + start: positionAt(document.text, start), + end: positionAt(document.text, start + selected.word.length), + }, + }; +} + +export function hover(document: TextDocument, position: Position) { + const selected = wordAt(document.text, position); + if (!selected) return null; + const declaration = new RegExp( + `\\b(state|computed|prop|page|component|layout)\\s+${selected.word}\\b`, + ).exec(document.text); + if (!declaration) return null; + return { + contents: { kind: "markdown", value: `\`\`\`wrn\n${declaration[0]}\n\`\`\`` }, + range: selected.range, + }; +} + +export function completionItems() { + return WRN_COMPLETIONS.map((label) => ({ label, kind: 14, detail: "WRNexus language keyword" })); +} +export * from "./workspace.ts"; diff --git a/packages/language-server/src/server.ts b/packages/language-server/src/server.ts new file mode 100644 index 00000000..5a76ce5d --- /dev/null +++ b/packages/language-server/src/server.ts @@ -0,0 +1,278 @@ +#!/usr/bin/env bun +import { + completionItems, + definitionLocation, + documentDiagnostics, + documentSymbols, + formatDocument, + hover, + symbolLocations, + wordAt, + virtualTypeScriptDocument, + workspaceCompletionItems, + extractComponentRefactor, + htmlToWrn, + type TextDocument, +} from "./index.ts"; + +type JsonRpc = { jsonrpc?: string; id?: number | string; method?: string; params?: any }; +const documents = new Map(); +const diagnosticTimers = new Map>(); +const MAX_OPEN_DOCUMENTS = 256; +const DIAGNOSTIC_DEBOUNCE_MS = 300; +let buffer = Buffer.alloc(0); +let workspaceRoot = process.cwd(); + +function rootFromUri(uri?: string): string { + if (!uri?.startsWith("file://")) return workspaceRoot; + return decodeURIComponent(uri.slice(7)).replace(/^\/([A-Za-z]:)/, "$1"); +} + +function send(value: unknown): void { + const body = Buffer.from(JSON.stringify(value)); + process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`); + process.stdout.write(body); +} +function result(id: JsonRpc["id"], value: unknown): void { + send({ jsonrpc: "2.0", id, result: value }); +} +function publish(document: TextDocument): void { + send({ + jsonrpc: "2.0", + method: "textDocument/publishDiagnostics", + params: { uri: document.uri, diagnostics: documentDiagnostics(document) }, + }); +} + +function clearDiagnosticTimer(uri: string): void { + const timer = diagnosticTimers.get(uri); + if (timer) clearTimeout(timer); + diagnosticTimers.delete(uri); +} + +function schedulePublish(document: TextDocument): void { + clearDiagnosticTimer(document.uri); + const expectedVersion = document.version; + diagnosticTimers.set( + document.uri, + setTimeout(() => { + diagnosticTimers.delete(document.uri); + const current = documents.get(document.uri); + if (current && current.version === expectedVersion) publish(current); + }, DIAGNOSTIC_DEBOUNCE_MS), + ); +} + +function rememberDocument(document: TextDocument): void { + documents.delete(document.uri); + documents.set(document.uri, document); + while (documents.size > MAX_OPEN_DOCUMENTS) { + const oldest = documents.keys().next().value; + if (typeof oldest !== "string") break; + documents.delete(oldest); + clearDiagnosticTimer(oldest); + } +} + +async function handle(message: JsonRpc): Promise { + const params = message.params ?? {}; + switch (message.method) { + case "initialize": + workspaceRoot = params.rootPath ?? rootFromUri(params.rootUri); + result(message.id, { + serverInfo: { name: "WRNexus Language Server", version: "0.8.0" }, + capabilities: { + textDocumentSync: 1, + documentFormattingProvider: true, + completionProvider: { triggerCharacters: ["<", "@", ":", "."] }, + hoverProvider: true, + definitionProvider: true, + referencesProvider: true, + renameProvider: { prepareProvider: true }, + documentSymbolProvider: true, + codeActionProvider: { + codeActionKinds: ["quickfix", "refactor.extract", "refactor.rewrite"], + }, + experimental: { wrnexusVirtualTypeScript: true }, + }, + }); + break; + case "initialized": + break; + case "shutdown": + for (const timer of diagnosticTimers.values()) clearTimeout(timer); + diagnosticTimers.clear(); + result(message.id, null); + break; + case "exit": + process.exit(0); + break; + case "textDocument/didOpen": { + const item = params.textDocument; + const document = { uri: item.uri, text: item.text, version: item.version }; + rememberDocument(document); + publish(document); + break; + } + case "textDocument/didChange": { + const existing = documents.get(params.textDocument.uri); + const text = params.contentChanges?.at(-1)?.text; + if (existing && typeof text === "string") { + existing.text = text; + existing.version = params.textDocument.version; + schedulePublish(existing); + } + break; + } + case "textDocument/didClose": + clearDiagnosticTimer(params.textDocument.uri); + documents.delete(params.textDocument.uri); + send({ + jsonrpc: "2.0", + method: "textDocument/publishDiagnostics", + params: { uri: params.textDocument.uri, diagnostics: [] }, + }); + break; + case "wrnexus/serverStatus": + result(message.id, { + openDocuments: documents.size, + pendingDiagnostics: diagnosticTimers.size, + memory: process.memoryUsage(), + }); + break; + case "textDocument/formatting": { + const document = documents.get(params.textDocument.uri); + result( + message.id, + document + ? formatDocument(document, params.options?.tabSize, params.options?.insertSpaces) + : [], + ); + break; + } + case "textDocument/completion": + result(message.id, [...completionItems(), ...workspaceCompletionItems(workspaceRoot)]); + break; + case "textDocument/documentSymbol": { + const document = documents.get(params.textDocument.uri); + result(message.id, document ? documentSymbols(document) : []); + break; + } + case "textDocument/hover": { + const document = documents.get(params.textDocument.uri); + result(message.id, document ? hover(document, params.position) : null); + break; + } + case "textDocument/definition": { + const document = documents.get(params.textDocument.uri); + result(message.id, document ? definitionLocation(document, params.position) : null); + break; + } + case "textDocument/references": { + const document = documents.get(params.textDocument.uri); + result(message.id, document ? symbolLocations(document, params.position) : []); + break; + } + case "textDocument/prepareRename": { + const document = documents.get(params.textDocument.uri); + result(message.id, document ? (wordAt(document.text, params.position)?.range ?? null) : null); + break; + } + case "textDocument/rename": { + const document = documents.get(params.textDocument.uri); + const edits = document + ? symbolLocations(document, params.position).map(({ range }) => ({ + range, + newText: params.newName, + })) + : []; + result(message.id, document ? { changes: { [document.uri]: edits } } : null); + break; + } + case "textDocument/codeAction": { + const document = documents.get(params.textDocument.uri); + if (!document) { + result(message.id, []); + break; + } + const actions: any[] = documentDiagnostics(document) + .filter((item) => item.code === "WRNA11Y001") + .map((item) => ({ + title: "Add empty alt attribute", + kind: "quickfix", + diagnostics: [item], + edit: { + changes: { + [document.uri]: [ + { range: { start: item.range.end, end: item.range.end }, newText: ' alt=""' }, + ], + }, + }, + })); + const selected = document.text.slice( + document.text + .split(/\r?\n/) + .slice(0, params.range.start.line) + .reduce((n, line) => n + line.length + 1, 0) + params.range.start.character, + document.text + .split(/\r?\n/) + .slice(0, params.range.end.line) + .reduce((n, line) => n + line.length + 1, 0) + params.range.end.character, + ); + if (selected.trim().startsWith("<")) { + try { + actions.push({ + title: "Extract selection to WRN component", + kind: "refactor.extract", + edit: extractComponentRefactor(document, params.range, "ExtractedComponent"), + }); + actions.push({ + title: "Convert selected HTML to WRN page", + kind: "refactor.rewrite", + edit: { + changes: { + [document.uri]: [ + { range: params.range, newText: htmlToWrn(selected, "ImportedPage") }, + ], + }, + }, + }); + } catch { + /* selection is not safely convertible */ + } + } + result(message.id, actions); + break; + } + case "wrnexus/virtualDocument": { + const document = documents.get(params.textDocument?.uri ?? params.uri); + result(message.id, document ? virtualTypeScriptDocument(document) : null); + break; + } + default: + if (message.id !== undefined) result(message.id, null); + } +} + +function consume(): void { + while (true) { + const end = buffer.indexOf("\r\n\r\n"); + if (end < 0) return; + const header = buffer.subarray(0, end).toString(); + const length = Number(/Content-Length:\s*(\d+)/i.exec(header)?.[1]); + if (!Number.isFinite(length)) { + buffer = Buffer.alloc(0); + return; + } + const bodyStart = end + 4; + if (buffer.length < bodyStart + length) return; + const body = buffer.subarray(bodyStart, bodyStart + length).toString(); + buffer = buffer.subarray(bodyStart + length); + void handle(JSON.parse(body)); + } +} +process.stdin.on("data", (chunk) => { + buffer = Buffer.concat([buffer, Buffer.from(chunk)]); + consume(); +}); +process.stdin.resume(); diff --git a/packages/language-server/src/workspace.ts b/packages/language-server/src/workspace.ts new file mode 100644 index 00000000..35822cfb --- /dev/null +++ b/packages/language-server/src/workspace.ts @@ -0,0 +1,217 @@ +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { basename, extname, join, relative, resolve } from "node:path"; +import { parse } from "@wrnexus/syntax"; +import type { Range, TextDocument } from "./index.ts"; + +const SKIPPED_DIRECTORIES = new Set([ + ".git", + ".wrnexus", + ".wirefw", + "build", + "coverage", + "dist", + "node_modules", + "out", +]); +const MAX_INDEX_FILES = 5_000; +const MAX_SOURCE_BYTES = 1_048_576; +const INDEX_TTL_MS = 5_000; +const MAX_CACHED_ROOTS = 8; +const workspaceIndexCache = new Map< + string, + { expiresAt: number; items: WorkspaceCompletionItem[] } +>(); + +function walk(root: string, test: (file: string) => boolean): string[] { + if (!existsSync(root)) return []; + const files: string[] = []; + const pending = [root]; + while (pending.length > 0 && files.length < MAX_INDEX_FILES) { + const directory = pending.pop()!; + let entries; + try { + entries = readdirSync(directory, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (files.length >= MAX_INDEX_FILES) break; + const file = join(directory, entry.name); + if (entry.isDirectory()) { + if (!SKIPPED_DIRECTORIES.has(entry.name)) pending.push(file); + } else if (entry.isFile() && test(file)) { + try { + if (statSync(file).size <= MAX_SOURCE_BYTES) files.push(file); + } catch { + // Files can disappear while the editor indexes a changing workspace. + } + } + } + } + return files; +} +function flatten(value: unknown, prefix = ""): string[] { + if (!value || typeof value !== "object" || Array.isArray(value)) return prefix ? [prefix] : []; + return Object.entries(value).flatMap(([key, child]) => + flatten(child, prefix ? `${prefix}.${key}` : key), + ); +} +function routeFor(root: string, file: string): string { + let value = relative(join(root, "app", "pages"), file) + .replace(/\\/g, "/") + .replace(/\.wrn$/, ""); + value = value + .replace(/(?:^|\/)index$/, "") + .replace(/\[\.\.\.([^\]]+)\]/g, "*$1") + .replace(/\[([^\]]+)\]/g, ":$1"); + return `/${value}`.replace(/\/$/, "") || "/"; +} + +export interface WorkspaceCompletionItem { + label: string; + kind: number; + detail: string; + insertText?: string; + data?: Record; +} + +function buildWorkspaceCompletionItems(root: string): WorkspaceCompletionItem[] { + const app = join(root, "app"); + const items: WorkspaceCompletionItem[] = []; + for (const file of walk(join(app, "components"), (path) => extname(path) === ".wrn")) { + try { + const source = readFileSync(file, "utf8"); + const ast = parse(source); + const slots = [...source.matchAll(/ match[1] ?? "default", + ); + items.push({ + label: ast.name, + kind: 7, + detail: `Component · ${relative(root, file)}`, + insertText: `<${ast.name} />`, + data: { file, props: ast.props, outputs: ast.outputs, slots }, + }); + for (const prop of ast.props) + items.push({ label: prop.name, kind: 10, detail: `${ast.name} prop · ${prop.valueType}` }); + for (const output of ast.outputs) + items.push({ label: `@${output.name}`, kind: 10, detail: `${ast.name} event` }); + for (const slot of slots) + items.push({ label: `slot:${slot}`, kind: 10, detail: `${ast.name} slot` }); + } catch { + // Diagnostics handle invalid components; indexing remains best-effort. + } + } + for (const file of walk(join(app, "pages"), (path) => extname(path) === ".wrn")) { + const route = routeFor(root, file); + items.push({ + label: route, + kind: 12, + detail: `Application route · ${relative(root, file)}`, + data: { file }, + }); + } + for (const file of walk(join(app, "locales"), (path) => extname(path) === ".json")) { + try { + for (const key of flatten(JSON.parse(readFileSync(file, "utf8")))) + items.push({ + label: key, + kind: 12, + detail: `Translation key · ${basename(file, ".json")}`, + }); + } catch { + // Invalid locale JSON is reported by application diagnostics. + } + } + const sourceFiles = walk(app, (path) => /\.(?:ts|js|wrn)$/.test(path)); + for (const file of sourceFiles) { + const source = readFileSync(file, "utf8"); + for (const match of source.matchAll( + /\b(?:schema|model)\s+([A-Za-z_$][\w$]*)|\bexport\s+const\s+([A-Za-z_$][\w$]*Schema)\b/g, + )) { + const name = match[1] ?? match[2]; + if (name) + items.push({ + label: name, + kind: 7, + detail: `Database/validation schema · ${relative(root, file)}`, + data: { file }, + }); + } + for (const match of source.matchAll(/(?:class|className)\s*=\s*["']([^"']+)["']/g)) { + for (const name of match[1]!.split(/\s+/)) + if (name) items.push({ label: name, kind: 12, detail: "Workspace CSS/Tailwind class" }); + } + } + const unique = new Map(items.map((item) => [`${item.label}:${item.detail}`, item])); + return [...unique.values()].slice(0, 2_000); +} + +export function workspaceCompletionItems(root: string): WorkspaceCompletionItem[] { + const normalizedRoot = resolve(root); + const now = Date.now(); + const cached = workspaceIndexCache.get(normalizedRoot); + if (cached && cached.expiresAt > now) return cached.items; + const items = buildWorkspaceCompletionItems(normalizedRoot); + workspaceIndexCache.delete(normalizedRoot); + workspaceIndexCache.set(normalizedRoot, { expiresAt: now + INDEX_TTL_MS, items }); + while (workspaceIndexCache.size > MAX_CACHED_ROOTS) { + const oldest = workspaceIndexCache.keys().next().value; + if (typeof oldest !== "string") break; + workspaceIndexCache.delete(oldest); + } + return items; +} + +export function clearWorkspaceIndexCache(root?: string): void { + if (root) workspaceIndexCache.delete(resolve(root)); + else workspaceIndexCache.clear(); +} + +export function extractComponentRefactor(document: TextDocument, range: Range, name: string) { + if (!/^[A-Z][A-Za-z0-9_$]*$/.test(name)) throw new Error("Component name must use PascalCase"); + const lines = document.text.split(/\r?\n/); + const start = + lines.slice(0, range.start.line).reduce((n, line) => n + line.length + 1, 0) + + range.start.character; + const end = + lines.slice(0, range.end.line).reduce((n, line) => n + line.length + 1, 0) + + range.end.character; + const selected = document.text.slice(start, end); + if (!selected.trim().startsWith("<")) throw new Error("Select WRN markup to extract"); + const sourcePath = decodeURIComponent(document.uri.replace(/^file:\/\//, "")).replace( + /^\/([A-Za-z]:)/, + "$1", + ); + const root = sourcePath.includes(`${join("app", "pages")}`) + ? sourcePath.slice(0, sourcePath.indexOf(`${join("app", "pages")}`)) + : resolve("."); + const target = join(root, "app", "components", `${name}.wrn`); + return { + documentChanges: [ + { kind: "create", uri: `file:///${target.replace(/\\/g, "/")}` }, + { + textDocument: { uri: `file:///${target.replace(/\\/g, "/")}`, version: null }, + edits: [ + { + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, + newText: `component ${name} {\n view {\n ${selected.trim()}\n }\n}\n`, + }, + ], + }, + { + textDocument: { uri: document.uri, version: document.version ?? null }, + edits: [{ range, newText: `<${name} />` }], + }, + ], + }; +} + +export function htmlToWrn(html: string, name = "ImportedPage"): string { + if (!/^[A-Z][A-Za-z0-9_$]*$/.test(name)) throw new Error("WRN name must use PascalCase"); + const converted = html + .replace(/\sclass=/g, " class=") + .replace(/\son([a-z]+)=/gi, (_all, event) => ` @${String(event).toLowerCase()}=`) + .replace(//g, "{/*$1*/}"); + return `page ${name} {\n view {\n ${converted.trim()}\n }\n}\n`; +} diff --git a/packages/language-server/test/language-server.test.ts b/packages/language-server/test/language-server.test.ts new file mode 100644 index 00000000..6b48d781 --- /dev/null +++ b/packages/language-server/test/language-server.test.ts @@ -0,0 +1,219 @@ +import { expect, test } from "bun:test"; +import { fileURLToPath } from "node:url"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + completionItems, + definitionLocation, + documentDiagnostics, + documentSymbols, + formatDocument, + positionAt, + symbolLocations, + virtualTypeScriptDocument, + workspaceCompletionItems, + clearWorkspaceIndexCache, + extractComponentRefactor, + htmlToWrn, +} from "../src/index.ts"; + +const document = { + uri: "file:///Counter.wrn", + text: `component Counter {\nstate count = 0\nview { }\n}`, +}; + +test("provides editor-neutral language features", () => { + expect(documentDiagnostics(document)).toEqual([]); + expect(completionItems().some((item) => item.label === "state")).toBe(true); + expect(documentSymbols(document).map((item) => item.name)).toContain("count"); + const reference = positionAt(document.text, document.text.lastIndexOf("count")); + expect(definitionLocation(document, reference)).not.toBeNull(); + expect(symbolLocations(document, reference)).toHaveLength(3); + expect(formatDocument(document)).toBeArray(); +}); + +test("indexes workspace components, contracts, routes, translations, schemas and classes", () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-lsp-workspace-")); + try { + for (const directory of ["components", "pages", "locales", "db"]) + mkdirSync(join(root, "app", directory), { recursive: true }); + mkdirSync(join(root, "app", "pages", "users"), { recursive: true }); + writeFileSync( + join(root, "app", "components", "Card.wrn"), + `component Card { props { title: string } outputs { close() } view { } }`, + ); + writeFileSync( + join(root, "app", "pages", "users", "[id].wrn"), + `page User { view {

    User

    } }`, + { flag: "w" }, + ); + writeFileSync( + join(root, "app", "locales", "en.json"), + JSON.stringify({ user: { title: "User" } }), + ); + writeFileSync(join(root, "app", "db", "schema.ts"), `export const UserSchema = {};`); + const labels = workspaceCompletionItems(root).map((item) => item.label); + for (const label of [ + "Card", + "title", + "@close", + "slot:body", + "/users/:id", + "user.title", + "UserSchema", + "text-red-500", + ]) + expect(labels).toContain(label); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("workspace indexing is cached and skips dependency and generated trees", () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-lsp-bounded-")); + try { + mkdirSync(join(root, "app", "pages"), { recursive: true }); + mkdirSync(join(root, "app", "node_modules", "large-package"), { recursive: true }); + mkdirSync(join(root, "app", ".wrnexus"), { recursive: true }); + writeFileSync(join(root, "app", "pages", "index.wrn"), `page Home { view {

    Home

    } }`); + writeFileSync( + join(root, "app", "node_modules", "large-package", "secret.ts"), + `export const DependencySchema = {};`, + ); + writeFileSync(join(root, "app", ".wrnexus", "generated.ts"), `export const CacheSchema = {};`); + + const first = workspaceCompletionItems(root); + const second = workspaceCompletionItems(root); + expect(second).toBe(first); + expect(first.map((item) => item.label)).not.toContain("DependencySchema"); + expect(first.map((item) => item.label)).not.toContain("CacheSchema"); + + writeFileSync(join(root, "app", "pages", "later.wrn"), `page Later { view {

    Later

    } }`); + expect(workspaceCompletionItems(root).map((item) => item.label)).not.toContain("/later"); + clearWorkspaceIndexCache(root); + expect(workspaceCompletionItems(root).map((item) => item.label)).toContain("/later"); + } finally { + clearWorkspaceIndexCache(root); + rmSync(root, { recursive: true, force: true }); + } +}); + +test("provides safe component extraction and HTML conversion refactors", () => { + const doc = { + uri: "file:///project/app/pages/index.wrn", + version: 2, + text: "
    Hello
    ", + }; + const range = { start: { line: 0, character: 0 }, end: { line: 0, character: doc.text.length } }; + const edit = extractComponentRefactor(doc, range, "Greeting"); + expect(edit.documentChanges).toHaveLength(3); + expect(JSON.stringify(edit)).toContain(""); + expect(htmlToWrn(``, "Imported")).toContain( + `@click="save()"`, + ); +}); + +test("provides a mapped TypeScript virtual document and expression type diagnostics", () => { + const typed = { + uri: "file:///Typed.wrn", + text: `component Typed {\nprops { count: number }\nstate label: string = count\nview {

    {label}

    }\n}`, + }; + const virtual = virtualTypeScriptDocument(typed); + expect(virtual.languageId).toBe("typescript"); + expect(virtual.uri).toEndWith(".wrn.ts"); + expect(virtual.text).toContain("declare const count: Readonly"); + expect(virtual.mappings.length).toBeGreaterThan(0); + expect(documentDiagnostics(typed).some((item) => String(item.code).startsWith("WRN-TYPE-"))).toBe( + true, + ); +}); + +test("reports compiler and accessibility diagnostics", () => { + const diagnostics = documentDiagnostics({ + uri: "file:///bad.wrn", + text: `page Bad {\n view {\n \n }\n}`, + }); + expect(diagnostics.some((item) => String(item.code).startsWith("WRN-A11Y"))).toBe(true); +}); + +test("skips compiler graph creation for oversized WRN documents", () => { + const diagnostics = documentDiagnostics({ + uri: "file:///large.wrn", + text: `page Large { view {

    ${"x".repeat(1_048_576)}

    } }`, + }); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.code).toBe("WRN-LSP-FILE-SIZE"); +}); + +function packet(value: unknown): Uint8Array { + const body = JSON.stringify(value); + return new TextEncoder().encode(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`); +} + +test("serves initialize over standard LSP stdio framing", async () => { + const process = Bun.spawn( + ["bun", "run", fileURLToPath(new URL("../src/server.ts", import.meta.url))], + { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }, + ); + process.stdin.write(packet({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} })); + await process.stdin.flush(); + const reader = process.stdout.getReader(); + let output = ""; + while (!output.includes('"id":1')) { + const chunk = await reader.read(); + if (chunk.done) break; + output += new TextDecoder().decode(chunk.value); + } + expect(output).toContain("WRNexus Language Server"); + expect(output).toContain("documentFormattingProvider"); + expect(output).toContain("wrnexusVirtualTypeScript"); + process.kill(); + await process.exited; +}); + +test("coalesces rapid document changes into one pending diagnostic analysis", async () => { + const process = Bun.spawn( + ["bun", "run", fileURLToPath(new URL("../src/server.ts", import.meta.url))], + { stdin: "pipe", stdout: "pipe", stderr: "pipe" }, + ); + const uri = "file:///rapid.wrn"; + process.stdin.write( + packet({ + jsonrpc: "2.0", + method: "textDocument/didOpen", + params: { textDocument: { uri, version: 1, text: `page Rapid { view {

    1

    } }` } }, + }), + ); + for (let version = 2; version <= 50; version++) { + process.stdin.write( + packet({ + jsonrpc: "2.0", + method: "textDocument/didChange", + params: { + textDocument: { uri, version }, + contentChanges: [{ text: `page Rapid { view {

    ${version}

    } }` }], + }, + }), + ); + } + process.stdin.write( + packet({ jsonrpc: "2.0", id: 99, method: "wrnexus/serverStatus", params: {} }), + ); + await process.stdin.flush(); + const reader = process.stdout.getReader(); + let output = ""; + while (!output.includes('"id":99')) { + const chunk = await reader.read(); + if (chunk.done) break; + output += new TextDecoder().decode(chunk.value); + } + expect(output).toContain('"pendingDiagnostics":1'); + expect(output).toContain('"openDocuments":1'); + process.kill(); + await process.exited; +}); diff --git a/packages/mcp/README.md b/packages/mcp/README.md new file mode 100644 index 00000000..2322531d --- /dev/null +++ b/packages/mcp/README.md @@ -0,0 +1,11 @@ +# @wrnexus/mcp + +Editor-neutral Model Context Protocol server for AI development tools. + +```bash +bunx wrnexus-mcp --root=. +``` + +It exposes current routes, components with props/events, database schema files, compiler diagnostics, +runtime errors, dev-server health, framework documentation and installed packages. Files are resolved +inside the configured application root and returned as bounded structured JSON. diff --git a/packages/mcp/package.json b/packages/mcp/package.json new file mode 100644 index 00000000..ae60346a --- /dev/null +++ b/packages/mcp/package.json @@ -0,0 +1,14 @@ +{ + "name": "@wrnexus/mcp", + "version": "0.8.0", + "type": "module", + "description": "Model Context Protocol server exposing WRNexus application and framework context.", + "main": "src/index.ts", + "exports": { + ".": "./src/index.ts", + "./stdio": "./src/stdio.ts" + }, + "bin": { + "wrnexus-mcp": "src/stdio.ts" + } +} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts new file mode 100644 index 00000000..6da74b6d --- /dev/null +++ b/packages/mcp/src/index.ts @@ -0,0 +1,275 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { extname, join, relative, resolve } from "node:path"; + +export interface McpTool { + name: string; + description: string; + inputSchema: { + type: "object"; + properties?: Record; + additionalProperties?: boolean; + }; +} +export interface McpServerOptions { + maxFiles?: number; + maxFileBytes?: number; + fetch?: typeof fetch; + devServerUrl?: string; + runtimeErrors?: () => unknown[] | Promise; +} +export interface McpServer { + tools(): McpTool[]; + call(name: string, args?: Record): Promise; + handle(message: unknown): Promise | null>; +} + +const TOOLS: McpTool[] = [ + { + name: "wrnexus_routes", + description: "List current application routes and source files.", + inputSchema: { type: "object", additionalProperties: false }, + }, + { + name: "wrnexus_components", + description: "List components with declared props and public events.", + inputSchema: { type: "object", additionalProperties: false }, + }, + { + name: "wrnexus_database_schema", + description: "Read bounded database schema and migration sources.", + inputSchema: { type: "object", additionalProperties: false }, + }, + { + name: "wrnexus_diagnostics", + description: "Read current compiler diagnostics and build report.", + inputSchema: { type: "object", additionalProperties: false }, + }, + { + name: "wrnexus_runtime_errors", + description: "Read captured runtime errors without secrets.", + inputSchema: { type: "object", additionalProperties: false }, + }, + { + name: "wrnexus_dev_status", + description: "Check development server liveness and readiness.", + inputSchema: { type: "object", additionalProperties: false }, + }, + { + name: "wrnexus_docs", + description: "List framework documentation or read one documentation file.", + inputSchema: { + type: "object", + properties: { file: { type: "string" } }, + additionalProperties: false, + }, + }, + { + name: "wrnexus_packages", + description: "List installed WRNexus packages and versions.", + inputSchema: { type: "object", additionalProperties: false }, + }, +]; + +function inside(root: string, path: string): boolean { + const rel = relative(root, path); + return rel === "" || (!rel.startsWith("..") && !resolve(rel).startsWith("..")); +} +function walk( + root: string, + directories: string[], + options: Required>, + extensions?: string[], +) { + const output: Array<{ file: string; content: string }> = []; + const visit = (directory: string) => { + if (!existsSync(directory) || output.length >= options.maxFiles) return; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if ( + output.length >= options.maxFiles || + entry.name.startsWith(".") || + entry.name === "node_modules" || + entry.name === "dist" + ) + continue; + const path = join(directory, entry.name); + if (entry.isDirectory()) visit(path); + else if (!extensions || extensions.includes(extname(entry.name))) + output.push({ + file: relative(root, path).replace(/\\/g, "/"), + content: readFileSync(path, "utf8").slice(0, options.maxFileBytes), + }); + } + }; + for (const directory of directories) visit(resolve(root, directory)); + return output; +} +function routePath(file: string) { + const value = file + .replace(/^app\/(pages|api)\//, "") + .replace(/\.(wrn|tsx?|jsx?)$/, "") + .replace(/\/index$/, "") + .replace(/\[\.\.\.(\w+)\]/g, "*$1") + .replace(/\[(\w+)\]/g, ":$1"); + if (value === "index") return "/"; + return `/${value}`.replace(/\/$/, "") || "/"; +} +function componentContract(content: string) { + return { + props: [...content.matchAll(/^\s*prop\s+([A-Za-z_$][\w$]*)/gm)].map((match) => match[1]), + events: [...content.matchAll(/^\s*(?:event|emit)\s+([A-Za-z_$][\w$.-]*)/gm)].map( + (match) => match[1], + ), + }; +} +function redact(value: unknown): unknown { + if (Array.isArray(value)) return value.map(redact); + if (value && typeof value === "object") + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + /token|secret|password|authorization|cookie/i.test(key) ? key : key, + /token|secret|password|authorization|cookie/i.test(key) ? "[REDACTED]" : redact(item), + ]), + ); + return typeof value === "string" ? value.slice(0, 4_000) : value; +} + +export function createFrameworkMcpServer( + appRoot: string, + options: McpServerOptions = {}, +): McpServer { + const root = resolve(appRoot); + const limits = { + maxFiles: options.maxFiles ?? 200, + maxFileBytes: options.maxFileBytes ?? 128 * 1024, + }; + const files = (directories: string[], extensions?: string[]) => + walk(root, directories, limits, extensions); + const call = async (name: string, args: Record = {}): Promise => { + if (name === "wrnexus_routes") + return files(["app/pages", "app/api"], [".wrn", ".ts", ".tsx", ".js", ".jsx"]) + .map(({ file }) => ({ + path: routePath(file), + kind: file.startsWith("app/api/") ? "api" : "page", + file, + })) + .sort((left, right) => left.path.localeCompare(right.path)); + if (name === "wrnexus_components") + return files(["app/components"], [".wrn"]).map(({ file, content }) => ({ + name: file + .split("/") + .at(-1)! + .replace(/\.wrn$/, ""), + file, + ...componentContract(content), + })); + if (name === "wrnexus_database_schema") return files(["app/db"], [".sql", ".ts", ".json"]); + if (name === "wrnexus_diagnostics") + return files([".wrnexus", "dist"], [".json"]) + .filter((entry) => /diagnostic|report|contract/i.test(entry.file)) + .map((entry) => ({ + file: entry.file, + data: (() => { + try { + return redact(JSON.parse(entry.content)); + } catch { + return entry.content; + } + })(), + })); + if (name === "wrnexus_runtime_errors") return redact((await options.runtimeErrors?.()) ?? []); + if (name === "wrnexus_dev_status") { + const base = (options.devServerUrl ?? "http://localhost:3000").replace(/\/$/, ""); + const check = async (path: string) => { + try { + const response = await (options.fetch ?? fetch)(`${base}${path}`); + return { status: response.status, ok: response.ok }; + } catch (error) { + return { + status: 0, + ok: false, + error: error instanceof Error ? error.message : "Unavailable", + }; + } + }; + return { url: base, liveness: await check("/healthz"), readiness: await check("/readyz") }; + } + if (name === "wrnexus_docs") { + const docs = files(["docs"], [".md"]); + if (typeof args.file !== "string") return docs.map(({ file }) => file); + const path = resolve(root, args.file); + if (!inside(root, path) || !path.startsWith(resolve(root, "docs")) || !existsSync(path)) + throw new Error( + "WRN-MCP-DOC-PATH: documentation file is outside the application docs directory.", + ); + return { + file: relative(root, path).replace(/\\/g, "/"), + content: readFileSync(path, "utf8").slice(0, limits.maxFileBytes), + }; + } + if (name === "wrnexus_packages") { + const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { + dependencies?: Record; + devDependencies?: Record; + }; + return Object.entries({ ...manifest.dependencies, ...manifest.devDependencies }) + .filter(([packageName]) => packageName.startsWith("@wrnexus/")) + .map(([packageName, version]) => ({ name: packageName, version })) + .sort((a, b) => a.name.localeCompare(b.name)); + } + throw new Error(`WRN-MCP-TOOL: unknown tool '${name}'.`); + }; + return { + tools: () => TOOLS.map((tool) => ({ ...tool })), + call, + async handle(message) { + if (!message || typeof message !== "object") + return { jsonrpc: "2.0", id: null, error: { code: -32600, message: "Invalid Request" } }; + const request = message as { id?: unknown; method?: unknown; params?: any }; + if (request.method === "notifications/initialized") return null; + if (request.method === "initialize") + return { + jsonrpc: "2.0", + id: request.id ?? null, + result: { + protocolVersion: "2025-03-26", + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: "wrnexus", version: "0.8.0" }, + }, + }; + if (request.method === "tools/list") + return { jsonrpc: "2.0", id: request.id ?? null, result: { tools: TOOLS } }; + if (request.method === "tools/call") { + try { + const value = await call( + String(request.params?.name ?? ""), + request.params?.arguments ?? {}, + ); + return { + jsonrpc: "2.0", + id: request.id ?? null, + result: { + content: [{ type: "text", text: JSON.stringify(value, null, 2) }], + structuredContent: { value }, + }, + }; + } catch (error) { + return { + jsonrpc: "2.0", + id: request.id ?? null, + result: { + isError: true, + content: [ + { type: "text", text: error instanceof Error ? error.message : "Tool failed" }, + ], + }, + }; + } + } + return { + jsonrpc: "2.0", + id: request.id ?? null, + error: { code: -32601, message: "Method not found" }, + }; + }, + }; +} diff --git a/packages/mcp/src/stdio.ts b/packages/mcp/src/stdio.ts new file mode 100644 index 00000000..4d2b1e75 --- /dev/null +++ b/packages/mcp/src/stdio.ts @@ -0,0 +1,27 @@ +#!/usr/bin/env bun +import { createInterface } from "node:readline"; +import { createFrameworkMcpServer } from "./index.ts"; + +export async function runMcpStdio(root = process.cwd()): Promise { + const server = createFrameworkMcpServer(root); + const lines = createInterface({ input: process.stdin, terminal: false }); + for await (const line of lines) { + if (!line.trim()) continue; + let message: unknown; + try { + message = JSON.parse(line); + } catch { + process.stdout.write( + `${JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } })}\n`, + ); + continue; + } + const response = await server.handle(message); + if (response) process.stdout.write(`${JSON.stringify(response)}\n`); + } +} + +if (import.meta.main) { + const root = process.argv.find((value) => value.startsWith("--root="))?.slice(7) ?? process.cwd(); + await runMcpStdio(root); +} diff --git a/packages/mcp/test/mcp.test.ts b/packages/mcp/test/mcp.test.ts new file mode 100644 index 00000000..a1f51414 --- /dev/null +++ b/packages/mcp/test/mcp.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createFrameworkMcpServer } from "../src/index.ts"; + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "wrnexus-mcp-")); + for (const dir of ["app/pages", "app/api", "app/components", "app/db/migrations", "docs"]) + mkdirSync(join(root, dir), { recursive: true }); + writeFileSync(join(root, "app/pages/index.wrn"), "

    Home

    "); + writeFileSync(join(root, "app/api/users.ts"), "export const GET = () => Response.json([])"); + writeFileSync( + join(root, "app/components/Button.wrn"), + "prop label: string\nevent click\n", + ); + writeFileSync(join(root, "app/db/migrations/001.sql"), "CREATE TABLE users(id TEXT);"); + writeFileSync(join(root, "docs/GUIDE.md"), "# Guide"); + writeFileSync( + join(root, "package.json"), + JSON.stringify({ dependencies: { "@wrnexus/core": "0.8.0", other: "1.0.0" } }), + ); + return root; +} + +describe("WRNexus MCP", () => { + test("exposes application framework context through bounded tools", async () => { + const server = createFrameworkMcpServer(fixture(), { + fetch: (async (input: URL | RequestInfo) => + new Response(null, { + status: String(input).endsWith("readyz") ? 503 : 200, + })) as unknown as typeof fetch, + runtimeErrors: () => [{ message: "bad", token: "secret" }], + }); + expect(((await server.call("wrnexus_routes")) as any[]).map((route) => route.path)).toEqual([ + "/", + "/users", + ]); + expect(await server.call("wrnexus_components")).toEqual([ + { name: "Button", file: "app/components/Button.wrn", props: ["label"], events: ["click"] }, + ]); + expect(((await server.call("wrnexus_database_schema")) as any[])[0].content).toContain( + "CREATE TABLE", + ); + expect(await server.call("wrnexus_packages")).toEqual([ + { name: "@wrnexus/core", version: "0.8.0" }, + ]); + expect(await server.call("wrnexus_runtime_errors")).toEqual([ + { message: "bad", token: "[REDACTED]" }, + ]); + expect(await server.call("wrnexus_dev_status")).toMatchObject({ + liveness: { ok: true }, + readiness: { ok: false }, + }); + }); + test("speaks MCP JSON-RPC initialize, list and call", async () => { + const server = createFrameworkMcpServer(fixture()); + expect(await server.handle({ jsonrpc: "2.0", id: 1, method: "initialize" })).toHaveProperty( + "result.serverInfo.name", + "wrnexus", + ); + expect(await server.handle({ jsonrpc: "2.0", id: 2, method: "tools/list" })).toHaveProperty( + "result.tools.0.name", + "wrnexus_routes", + ); + const result = await server.handle({ + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { name: "wrnexus_docs", arguments: {} }, + }); + expect(result).toHaveProperty("result.structuredContent.value.0", "docs/GUIDE.md"); + }); + test("rejects documentation traversal", async () => { + const server = createFrameworkMcpServer(fixture()); + await expect(server.call("wrnexus_docs", { file: "../secret" })).rejects.toThrow( + "WRN-MCP-DOC-PATH", + ); + }); +}); diff --git a/packages/mobile/README.md b/packages/mobile/README.md index 58087db7..a8a9a6e4 100644 --- a/packages/mobile/README.md +++ b/packages/mobile/README.md @@ -76,6 +76,14 @@ const status = network ? await network.getStatus() : { connected: true, connecti Unavailable required plugins throw `MobileUnavailableError` with an actionable message. +The package also provides portable application-facing primitives: + +- `listenDeepLinks` normalizes initial and live links with an allowed-scheme list. +- `PushNotifications` performs permission gating and validates registrations. +- `SecureStorage` namespaces and validates keys over an application-supplied encrypted + Keychain/Keystore adapter; it does not mislabel browser `localStorage` as secure. +- `OfflineQueue` persists bounded sync batches through a pluggable durable store. + ## Requirements / Notes - Capacitor plugin imports must remain in browser-owned modules. diff --git a/packages/mobile/package.json b/packages/mobile/package.json index d623b136..b068b0f6 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/mobile", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/mobile/src/advanced.ts b/packages/mobile/src/advanced.ts index 8fc476b4..d86098ff 100644 --- a/packages/mobile/src/advanced.ts +++ b/packages/mobile/src/advanced.ts @@ -12,6 +12,84 @@ export function parseDeepLink(value: string, schemes: string[] = []): DeepLink | return null; } } + +export interface DeepLinkSource { + current?(): Promise; + subscribe(listener: (url: string) => void): void | (() => void); +} + +/** Normalize initial and live native links and ignore malformed/unapproved schemes. */ +export function listenDeepLinks( + source: DeepLinkSource, + listener: (link: DeepLink) => void, + schemes: string[] = [], +): () => void { + let active = true; + const emit = (value: string) => { + const link = parseDeepLink(value, schemes); + if (active && link) listener(link); + }; + void source.current?.().then((value) => value && emit(value)); + const unsubscribe = source.subscribe(emit); + return () => { + active = false; + unsubscribe?.(); + }; +} + +export interface PushRegistration { + token: string; + platform?: string; +} + +export interface PushAdapter { + permission(): Promise<"granted" | "denied" | "prompt" | "unavailable">; + requestPermission?(): Promise<"granted" | "denied">; + register(): Promise; + subscribe?(listener: (notification: unknown) => void): () => void; +} + +export class PushNotifications { + constructor(private readonly adapter: PushAdapter) {} + async register(): Promise { + let permission = await this.adapter.permission(); + if (permission === "prompt" && this.adapter.requestPermission) + permission = await this.adapter.requestPermission(); + if (permission !== "granted") throw new Error("WRN-MOBILE-PUSH-PERMISSION-DENIED"); + const registration = await this.adapter.register(); + if (!registration.token.trim()) throw new Error("WRN-MOBILE-PUSH-EMPTY-TOKEN"); + return registration; + } + subscribe(listener: (notification: unknown) => void): () => void { + return this.adapter.subscribe?.(listener) ?? (() => undefined); + } +} + +export interface SecureStorageAdapter { + get(key: string): Promise; + set(key: string, value: string): Promise; + remove(key: string): Promise; +} + +export class SecureStorage { + constructor( + private readonly adapter: SecureStorageAdapter, + private readonly namespace = "wrnexus", + ) {} + #key(key: string): string { + if (!/^[A-Za-z0-9._-]{1,128}$/.test(key)) throw new TypeError("WRN-MOBILE-STORAGE-KEY"); + return `${this.namespace}:${key}`; + } + get(key: string): Promise { + return this.adapter.get(this.#key(key)); + } + set(key: string, value: string): Promise { + return this.adapter.set(this.#key(key), value); + } + remove(key: string): Promise { + return this.adapter.remove(this.#key(key)); + } +} export interface OfflineTask { id: string; type: string; @@ -85,7 +163,11 @@ export interface MobileEnvironment { userAgent?: string; } export function mobileEnvironment(): MobileEnvironment { - const capacitor = (globalThis as any).Capacitor; + const capacitor = ( + globalThis as typeof globalThis & { + Capacitor?: { isNativePlatform?: () => boolean; getPlatform?: () => string }; + } + ).Capacitor; const native = capacitor?.isNativePlatform?.() === true; return { platform: capacitor?.getPlatform?.() ?? "web", diff --git a/packages/mobile/src/index.ts b/packages/mobile/src/index.ts index 0c56183e..04bb3015 100644 --- a/packages/mobile/src/index.ts +++ b/packages/mobile/src/index.ts @@ -90,9 +90,21 @@ export const mobile = { whenNative, }; export { + listenDeepLinks, parseDeepLink, memoryOfflineTaskStore, OfflineQueue, + PushNotifications, + SecureStorage, mobileEnvironment, } from "./advanced.ts"; -export type { DeepLink, OfflineTask, OfflineTaskStore, MobileEnvironment } from "./advanced.ts"; +export type { + DeepLink, + DeepLinkSource, + OfflineTask, + OfflineTaskStore, + MobileEnvironment, + PushAdapter, + PushRegistration, + SecureStorageAdapter, +} from "./advanced.ts"; diff --git a/packages/mobile/test/mobile.test.ts b/packages/mobile/test/mobile.test.ts index 4ad302ff..aa3f49b5 100644 --- a/packages/mobile/test/mobile.test.ts +++ b/packages/mobile/test/mobile.test.ts @@ -1,6 +1,8 @@ import { afterEach, expect, test } from "bun:test"; import { MobileUnavailableError, + PushNotifications, + SecureStorage, invoke, isNative, platform, @@ -59,3 +61,24 @@ test("explains missing plugins", async () => { root.Capacitor = { isNativePlatform: () => true, Plugins: {} }; expect(invoke("Camera", "getPhoto")).rejects.toBeInstanceOf(MobileUnavailableError); }); + +test("normalizes push permission and rejects empty registrations", async () => { + const push = new PushNotifications({ + permission: async () => "prompt", + requestPermission: async () => "granted", + register: async () => ({ token: "device-token", platform: "ios" }), + }); + expect(await push.register()).toEqual({ token: "device-token", platform: "ios" }); +}); + +test("namespaces and validates secure-storage keys", async () => { + const values = new Map(); + const storage = new SecureStorage({ + get: async (key) => values.get(key) ?? null, + set: async (key, value) => void values.set(key, value), + remove: async (key) => void values.delete(key), + }); + await storage.set("session", "encrypted-value"); + expect(values.get("wrnexus:session")).toBe("encrypted-value"); + expect(() => storage.get("bad key")).toThrow(TypeError); +}); diff --git a/packages/native/README.md b/packages/native/README.md index 3a2f5bc4..aa659c46 100644 --- a/packages/native/README.md +++ b/packages/native/README.md @@ -79,6 +79,9 @@ const position = await native.run( Built-ins include `camera`, `clipboard.write`, `share`, `geolocation`, `network`, `haptics`, storage, filesystem, notifications, and device information. +`defineNativeManifest` declares required capabilities and typed permissions, while +`PermissionManager` normalizes permission query/request flows across platform adapters. + ## Requirements / Notes Use `supports()` before showing optional controls. Mobile capabilities require their diff --git a/packages/native/package.json b/packages/native/package.json index b2272b3f..cf8f6cc7 100644 --- a/packages/native/package.json +++ b/packages/native/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/native", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/native/src/index.ts b/packages/native/src/index.ts index ce14763c..134071af 100644 --- a/packages/native/src/index.ts +++ b/packages/native/src/index.ts @@ -31,5 +31,6 @@ export { export type { NativeCapabilityManifestEntry, NativeCapabilityManifest, + NativePermission, PermissionAdapter, } from "./manifest.ts"; diff --git a/packages/native/src/manifest.ts b/packages/native/src/manifest.ts index 1cb0d8ef..c83be055 100644 --- a/packages/native/src/manifest.ts +++ b/packages/native/src/manifest.ts @@ -1,9 +1,11 @@ import { registered, supports } from "./registry.ts"; import type { NativeTarget } from "./types.ts"; +export type NativePermission = + "camera" | "geolocation" | "microphone" | "notifications" | "photos" | "storage" | (string & {}); export interface NativeCapabilityManifestEntry { name: string; description?: string; - permissions?: string[]; + permissions?: NativePermission[]; targets?: NativeTarget[]; optional?: boolean; } @@ -40,15 +42,15 @@ export function missingNativeCapabilities( ); } export interface PermissionAdapter { - query(name: string): Promise<"granted" | "denied" | "prompt" | "unavailable">; - request?(name: string): Promise<"granted" | "denied">; + query(name: NativePermission): Promise<"granted" | "denied" | "prompt" | "unavailable">; + request?(name: NativePermission): Promise<"granted" | "denied">; } export class PermissionManager { constructor(private readonly adapter: PermissionAdapter) {} - query(name: string) { + query(name: NativePermission) { return this.adapter.query(name); } - async ensure(name: string): Promise { + async ensure(name: NativePermission): Promise { const state = await this.adapter.query(name); if (state === "granted") return true; if (state === "prompt" && this.adapter.request) diff --git a/packages/oauth/README.md b/packages/oauth/README.md index e5f1d27f..badbe154 100644 --- a/packages/oauth/README.md +++ b/packages/oauth/README.md @@ -194,3 +194,24 @@ const gitlab = defineProvider({ `verifier` between `startAuth` and `completeAuth` (session or signed cookie). - Pairs with [`@wrnexus/core`](../core) — feed the normalized `OAuthProfile` into `logIn` to establish a session. + OIDC integrations can combine strict discovery with the rotating JWKS verifier: + +```ts +import { createRemoteJwks } from "@wrnexus/jwt"; +import { discoverOidc, verifyOidcIdToken } from "@wrnexus/oauth"; + +const metadata = await discoverOidc("https://issuer.example"); +const jwks = createRemoteJwks(metadata.jwks_uri); +const claims = await verifyOidcIdToken(idToken, { + issuer: metadata.issuer, + clientId: "client-id", + jwks, + nonce: expectedNonce, + accessToken, +}); +``` + +Discovery requires an exact normalized issuer and HTTPS endpoints without URL +credentials/fragments. ID-token verification checks the RS256 signature, +expiry/not-before, issuer, audience, required OIDC claims, nonce, multi-audience +`azp`, optional token age, and optional `at_hash` binding. diff --git a/packages/oauth/package.json b/packages/oauth/package.json index d5a0438a..75527e9e 100644 --- a/packages/oauth/package.json +++ b/packages/oauth/package.json @@ -1,10 +1,13 @@ { "name": "@wrnexus/oauth", - "version": "0.7.0", + "version": "0.8.0", "private": true, "type": "module", "main": "src/index.ts", "exports": { ".": "./src/index.ts" + }, + "dependencies": { + "@wrnexus/jwt": "workspace:*" } } diff --git a/packages/oauth/src/advanced.ts b/packages/oauth/src/advanced.ts index dfa63467..581b212d 100644 --- a/packages/oauth/src/advanced.ts +++ b/packages/oauth/src/advanced.ts @@ -1,4 +1,5 @@ import { randomToken, type OAuthProvider, type OAuthTokens } from "./index.ts"; +import { verifyJwtWithJwks, type JwtClaims, type RemoteJwks } from "@wrnexus/jwt"; export interface OAuthStateRecord { state: string; @@ -70,6 +71,16 @@ export interface OidcDiscovery { jwks_uri: string; revocation_endpoint?: string; } + +function requireHttpsEndpoint(value: unknown, name: string): string { + if (typeof value !== "string") throw new Error(`OIDC discovery is missing ${name}`); + const url = new URL(value); + if (url.protocol !== "https:" || url.username || url.password || url.hash) { + throw new Error(`OIDC ${name} must be an HTTPS URL without credentials or a fragment`); + } + return value; +} + export async function discoverOidc( issuer: string, fetchImpl: typeof fetch = fetch, @@ -77,9 +88,97 @@ export async function discoverOidc( const base = issuer.replace(/\/$/, ""); const response = await fetchImpl(`${base}/.well-known/openid-configuration`); if (!response.ok) throw new Error(`OIDC discovery failed (${response.status})`); - const value = (await response.json()) as OidcDiscovery; - if (value.issuer !== issuer && value.issuer !== base) throw new Error("OIDC issuer mismatch"); - return value; + const value = (await response.json()) as Partial; + if (value.issuer !== base) throw new Error("OIDC issuer mismatch"); + return { + issuer: base, + authorization_endpoint: requireHttpsEndpoint( + value.authorization_endpoint, + "authorization_endpoint", + ), + token_endpoint: requireHttpsEndpoint(value.token_endpoint, "token_endpoint"), + jwks_uri: requireHttpsEndpoint(value.jwks_uri, "jwks_uri"), + userinfo_endpoint: value.userinfo_endpoint + ? requireHttpsEndpoint(value.userinfo_endpoint, "userinfo_endpoint") + : undefined, + revocation_endpoint: value.revocation_endpoint + ? requireHttpsEndpoint(value.revocation_endpoint, "revocation_endpoint") + : undefined, + }; +} + +export interface OidcIdTokenClaims extends JwtClaims { + sub: string; + iss: string; + aud: string | string[]; + exp: number; + iat: number; + nonce?: string; + azp?: string; + at_hash?: string; +} + +export interface VerifyOidcIdTokenOptions { + issuer: string; + clientId: string; + jwks: RemoteJwks; + nonce?: string; + accessToken?: string; + now?: number; + clockTolerance?: number; + maxAge?: number; +} + +export function validateOidcClaims( + claims: JwtClaims, + options: Pick, +): asserts claims is OidcIdTokenClaims { + if (typeof claims.sub !== "string" || !claims.sub) throw new Error("OIDC token has no subject"); + if ( + typeof claims.iss !== "string" || + (typeof claims.aud !== "string" && !Array.isArray(claims.aud)) || + typeof claims.exp !== "number" || + typeof claims.iat !== "number" + ) { + throw new Error("OIDC token is missing required claims"); + } + const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud]; + if (audiences.length > 1 && claims.azp !== options.clientId) + throw new Error("OIDC token has invalid authorized party"); + if (claims.azp !== undefined && claims.azp !== options.clientId) + throw new Error("OIDC token has invalid authorized party"); + if (options.nonce !== undefined && claims.nonce !== options.nonce) + throw new Error("OIDC token has invalid nonce"); +} + +async function accessTokenHash(accessToken: string): Promise { + const digest = new Uint8Array( + await crypto.subtle.digest("SHA-256", new TextEncoder().encode(accessToken)), + ).slice(0, 16); + let binary = ""; + for (const byte of digest) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +export async function verifyOidcIdToken( + token: string, + options: VerifyOidcIdTokenOptions, +): Promise { + const issuer = options.issuer.replace(/\/$/, ""); + const claims = await verifyJwtWithJwks(token, options.jwks, { + issuer, + audience: options.clientId, + now: options.now, + clockTolerance: options.clockTolerance, + maxAge: options.maxAge, + }); + validateOidcClaims(claims, options); + if (options.accessToken !== undefined) { + if (typeof claims.at_hash !== "string") throw new Error("OIDC token has no access-token hash"); + if ((await accessTokenHash(options.accessToken)) !== claims.at_hash) + throw new Error("OIDC token has invalid access-token hash"); + } + return claims; } export function validateOAuthReturnTo( diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index 6b5b9c81..d1ec65b1 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -246,6 +246,14 @@ export { createOAuthState, refreshOAuthTokens, discoverOidc, + validateOidcClaims, + verifyOidcIdToken, validateOAuthReturnTo, } from "./advanced.ts"; -export type { OAuthStateRecord, OAuthStateStore, OidcDiscovery } from "./advanced.ts"; +export type { + OAuthStateRecord, + OAuthStateStore, + OidcDiscovery, + OidcIdTokenClaims, + VerifyOidcIdTokenOptions, +} from "./advanced.ts"; diff --git a/packages/oauth/test/oauth.test.ts b/packages/oauth/test/oauth.test.ts index 7447bd20..0b0a528d 100644 --- a/packages/oauth/test/oauth.test.ts +++ b/packages/oauth/test/oauth.test.ts @@ -8,6 +8,8 @@ import { exchangeCode, completeAuth, randomToken, + discoverOidc, + validateOidcClaims, } from "../src/index.ts"; const CREDS = { clientId: "cid", clientSecret: "secret" }; @@ -93,3 +95,42 @@ test("completeAuth maps the provider profile (custom provider)", async () => { expect(profile.id).toBe("99"); expect(profile.email).toBe("x@acme.test"); }); + +test("OIDC discovery enforces issuer and secure required endpoints", async () => { + const valid = await discoverOidc("https://issuer.example/", (async () => + Response.json({ + issuer: "https://issuer.example", + authorization_endpoint: "https://issuer.example/authorize", + token_endpoint: "https://issuer.example/token", + jwks_uri: "https://keys.example/jwks", + })) as unknown as typeof fetch); + expect(valid.jwks_uri).toBe("https://keys.example/jwks"); + await expect( + discoverOidc("https://issuer.example", (async () => + Response.json({ + issuer: "https://attacker.example", + authorization_endpoint: "https://issuer.example/authorize", + token_endpoint: "http://issuer.example/token", + jwks_uri: "https://issuer.example/jwks", + })) as unknown as typeof fetch), + ).rejects.toThrow("issuer mismatch"); +}); + +test("OIDC claim conformance enforces nonce, subject, and authorized party", () => { + const valid = { + sub: "user-1", + iss: "https://issuer.example", + aud: ["client-1", "api"], + azp: "client-1", + exp: 200, + iat: 100, + nonce: "nonce-1", + }; + expect(() => validateOidcClaims(valid, { clientId: "client-1", nonce: "nonce-1" })).not.toThrow(); + expect(() => + validateOidcClaims({ ...valid, nonce: "wrong" }, { clientId: "client-1", nonce: "nonce-1" }), + ).toThrow("nonce"); + expect(() => validateOidcClaims({ ...valid, azp: "other" }, { clientId: "client-1" })).toThrow( + "authorized party", + ); +}); diff --git a/packages/observability/README.md b/packages/observability/README.md index f4938ec9..b84b4be9 100644 --- a/packages/observability/README.md +++ b/packages/observability/README.md @@ -1,5 +1,9 @@ # @wrnexus/observability +Open-standard traces, metrics, logs, health checks, Web Vitals, error reporting and profiling. + +Use `createOperationTracer()` for `database`, `cache`, `queue`, `realtime`, `server-action` or custom `application` spans. Export through OTLP, Prometheus, Zipkin/Jaeger, or the Sentry-compatible error reporter; Grafana can consume the Prometheus or OTLP signals. + Privacy-conscious counters, gauges, histograms, HTTP middleware, Web Vitals ingestion, browser collection, and exporter adapters. Request bodies and user identifiers are not collected by default. ```ts @@ -7,3 +11,68 @@ export default { observability: { enabled: true, serverTiming: true, sampleRate: 0.1, webVitals: true }, }; ``` + +## Traces, correlated logs, and OTLP + +```ts +import { + createOtlpMetricExporter, + createOtlpTraceExporter, + createStructuredLogger, + metricsMiddleware, + traceMiddleware, +} from "@wrnexus/observability"; + +const traces = createOtlpTraceExporter("https://collector.example/v1/traces", { + serviceName: "checkout", + headers: { authorization: `Bearer ${process.env.OTLP_TOKEN}` }, +}); + +export const tracing = traceMiddleware({ + serviceName: "checkout", + sampleRate: 0.1, + exporter: traces, + onExportError(error) { + console.error("trace export failed", error); + }, +}); + +export const metrics = metricsMiddleware(); +export const metricExporter = createOtlpMetricExporter("https://collector.example/v1/metrics", { + serviceName: "checkout", +}); + +export const logger = createStructuredLogger({ service: "checkout" }); +// Request middleware can create a correlated child from ctx.locals. +logger + .child({ + traceId: ctx.locals.traceId, + spanId: ctx.locals.spanId, + requestId: ctx.locals.requestId, + }) + .info("order accepted", { orderId }); +``` + +The tracing middleware accepts and validates W3C `traceparent`, creates a child server span, +stores correlation identifiers in `ctx.locals`, installs the framework tracer on `ctx.tracer`, +and returns `traceparent` plus `x-request-id`. Export failures are isolated from application +responses when `onExportError` is configured. + +## Liveness and readiness + +```ts +import { HealthRegistry } from "@wrnexus/core"; +import { createLivenessHandler, createReadinessHandler } from "@wrnexus/observability"; + +const health = new HealthRegistry(); +health.register("database", async () => + (await db.ping()) ? { status: "up" } : { status: "down" }, +); + +export const live = createLivenessHandler(); +export const ready = createReadinessHandler(health); +``` + +Liveness reports whether the process can answer requests. Readiness returns HTTP 503 when a +registered dependency is down. Dependency messages and details are hidden unless +`exposeDetails: true` is explicitly selected for a trusted endpoint. diff --git a/packages/observability/package.json b/packages/observability/package.json index 95325909..ad9d7463 100644 --- a/packages/observability/package.json +++ b/packages/observability/package.json @@ -1,12 +1,18 @@ { "name": "@wrnexus/observability", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "description": "Metrics, Web Vitals collection, request instrumentation, and exporter adapters for WRNexusJS.", "main": "src/index.ts", "exports": { ".": "./src/index.ts", - "./client": "./src/client.ts" + "./client": "./src/client.ts", + "./health": "./src/health.ts", + "./integrations": "./src/integrations.ts", + "./logging": "./src/logging.ts", + "./metrics": "./src/metrics.ts", + "./server": "./src/server.ts", + "./trace": "./src/trace.ts" }, "dependencies": { "@wrnexus/core": "workspace:*" diff --git a/packages/observability/src/health.ts b/packages/observability/src/health.ts new file mode 100644 index 00000000..a2b1ace1 --- /dev/null +++ b/packages/observability/src/health.ts @@ -0,0 +1,35 @@ +import type { HealthRegistry } from "@wrnexus/core"; + +export interface HealthHandlerOptions { + exposeDetails?: boolean; + cacheControl?: string; +} + +export function createLivenessHandler(options: HealthHandlerOptions = {}) { + return async (request: Request): Promise => { + if (request.method !== "GET" && request.method !== "HEAD") { + return new Response("Method Not Allowed", { status: 405, headers: { allow: "GET, HEAD" } }); + } + return Response.json( + { status: "up" }, + { status: 200, headers: { "cache-control": options.cacheControl ?? "no-store" } }, + ); + }; +} + +export function createReadinessHandler( + registry: HealthRegistry, + options: HealthHandlerOptions = {}, +) { + return async (request: Request): Promise => { + if (request.method !== "GET" && request.method !== "HEAD") { + return new Response("Method Not Allowed", { status: 405, headers: { allow: "GET, HEAD" } }); + } + const result = await registry.check(); + const body = options.exposeDetails ? result : { status: result.status }; + return Response.json(body, { + status: result.status === "down" ? 503 : 200, + headers: { "cache-control": options.cacheControl ?? "no-store" }, + }); + }; +} diff --git a/packages/observability/src/index.ts b/packages/observability/src/index.ts index 1ee2f5f8..d30078ca 100644 --- a/packages/observability/src/index.ts +++ b/packages/observability/src/index.ts @@ -2,6 +2,7 @@ export { MetricsRegistry } from "./metrics.ts"; export type { MetricLabels, MetricPoint } from "./metrics.ts"; export { createHttpMetricExporter, + createOtlpMetricExporter, createWebVitalsHandler, defaultMetrics, metricsMiddleware, @@ -14,3 +15,30 @@ export type { } from "./server.ts"; export { webVitalsClient } from "./client.ts"; export type { WebVitalsClientOptions } from "./client.ts"; +export { + createOtlpTraceExporter, + formatTraceparent, + parseTraceparent, + traceMiddleware, +} from "./trace.ts"; +export type { SpanExporter, SpanRecord, TraceContext, TraceMiddlewareOptions } from "./trace.ts"; +export { createLivenessHandler, createReadinessHandler } from "./health.ts"; +export type { HealthHandlerOptions } from "./health.ts"; +export { createStructuredLogger } from "./logging.ts"; +export type { LogLevel, LogRecord, StructuredLogger, StructuredLoggerOptions } from "./logging.ts"; +export { + createJaegerExporter, + createOperationTracer, + createOtlpLogExporter, + createPerformanceProfiler, + createPrometheusPushExporter, + createSentryCompatibleReporter, + createZipkinExporter, + renderPrometheus, +} from "./integrations.ts"; +export type { + ErrorReporter, + FrameworkSpanKind, + LogExporter, + OperationTracer, +} from "./integrations.ts"; diff --git a/packages/observability/src/integrations.ts b/packages/observability/src/integrations.ts new file mode 100644 index 00000000..dbd93716 --- /dev/null +++ b/packages/observability/src/integrations.ts @@ -0,0 +1,239 @@ +import type { MetricPoint } from "./metrics.ts"; +import type { LogRecord } from "./logging.ts"; +import type { MetricExporter } from "./server.ts"; +import type { SpanExporter, SpanRecord, TraceContext } from "./trace.ts"; + +export type FrameworkSpanKind = + "database" | "cache" | "queue" | "realtime" | "server-action" | "application"; + +export interface OperationTracer { + span( + kind: FrameworkSpanKind, + name: string, + operation: () => T | Promise, + attributes?: Record, + ): Promise; +} + +const randomHex = (bytes: number) => + Array.from(crypto.getRandomValues(new Uint8Array(bytes)), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + +export function createOperationTracer( + options: { + exporter?: SpanExporter; + context?: () => Partial; + now?: () => number; + onExportError?: (error: unknown) => void; + } = {}, +): OperationTracer { + const now = options.now ?? Date.now; + return { + async span(kind, name, operation, attributes = {}) { + const context = options.context?.() ?? {}; + const started = now(); + let status: SpanRecord["status"] = "ok"; + let failure: unknown; + try { + return await operation(); + } catch (error) { + status = "error"; + failure = error; + throw error; + } finally { + const ended = now(); + const record: SpanRecord = { + name, + traceId: context.traceId ?? randomHex(16), + spanId: randomHex(8), + ...(context.spanId ? { parentSpanId: context.spanId } : {}), + sampled: context.sampled ?? true, + startTime: started, + endTime: ended, + durationMs: ended - started, + status, + attributes: { "wrnexus.span.kind": kind, ...attributes }, + ...(failure + ? { + error: { + name: failure instanceof Error ? failure.name : "Error", + message: failure instanceof Error ? failure.message : String(failure), + }, + } + : {}), + }; + try { + await options.exporter?.export([record]); + } catch (error) { + options.onExportError?.(error); + } + } + }, + }; +} + +const safeMetricName = (name: string) => name.replace(/[^a-zA-Z0-9_:]/g, "_"); +const labels = (point: MetricPoint) => { + const entries = Object.entries(point.labels); + return entries.length + ? `{${entries.map(([key, value]) => `${safeMetricName(key)}=${JSON.stringify(String(value))}`).join(",")}}` + : ""; +}; + +export function renderPrometheus(points: readonly MetricPoint[]): string { + return `${points.map((point) => `${safeMetricName(point.name)}${labels(point)} ${point.type === "histogram" ? (point.sum ?? 0) : point.value} ${point.timestamp}`).join("\n")}\n`; +} + +export function createPrometheusPushExporter( + endpoint: string, + options: { fetch?: typeof fetch; headers?: HeadersInit } = {}, +): MetricExporter { + return { + async export(points) { + const response = await (options.fetch ?? fetch)(endpoint, { + method: "POST", + headers: { + "content-type": "text/plain; version=0.0.4", + ...Object.fromEntries(new Headers(options.headers)), + }, + body: renderPrometheus(points), + }); + if (!response.ok) throw new Error(`Prometheus export failed with ${response.status}.`); + }, + }; +} + +export function createZipkinExporter( + endpoint: string, + options: { fetch?: typeof fetch; serviceName?: string } = {}, +): SpanExporter { + return { + async export(spans) { + const body = spans.map((span) => ({ + traceId: span.traceId, + id: span.spanId, + parentId: span.parentSpanId, + name: span.name, + timestamp: Math.trunc(span.startTime * 1000), + duration: Math.trunc(span.durationMs * 1000), + localEndpoint: { serviceName: options.serviceName ?? "wrnexus" }, + tags: Object.fromEntries( + Object.entries(span.attributes).map(([key, value]) => [key, String(value)]), + ), + ...(span.error ? { error: span.error.message } : {}), + })); + const response = await (options.fetch ?? fetch)(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (!response.ok) throw new Error(`Zipkin export failed with ${response.status}.`); + }, + }; +} + +/** Jaeger accepts Zipkin v2 JSON at its compatibility endpoint. */ +export const createJaegerExporter = createZipkinExporter; + +export interface LogExporter { + export(records: readonly LogRecord[]): void | Promise; +} +export function createOtlpLogExporter( + endpoint: string, + options: { fetch?: typeof fetch; headers?: HeadersInit } = {}, +): LogExporter { + return { + async export(records) { + const response = await (options.fetch ?? fetch)(endpoint, { + method: "POST", + headers: { + "content-type": "application/json", + ...Object.fromEntries(new Headers(options.headers)), + }, + body: JSON.stringify({ + resourceLogs: [ + { + scopeLogs: [ + { + scope: { name: "@wrnexus/observability" }, + logRecords: records.map((record) => ({ + timeUnixNano: String(BigInt(new Date(record.timestamp).getTime()) * 1_000_000n), + severityText: record.level.toUpperCase(), + body: { stringValue: record.message }, + attributes: Object.entries(record.attributes).map(([key, value]) => ({ + key, + value: { stringValue: String(value) }, + })), + })), + }, + ], + }, + ], + }), + }); + if (!response.ok) throw new Error(`OTLP log export failed with ${response.status}.`); + }, + }; +} + +export interface ErrorReporter { + capture(error: unknown, context?: Record): Promise; +} +export function createSentryCompatibleReporter( + endpoint: string, + options: { fetch?: typeof fetch; publicKey?: string } = {}, +): ErrorReporter { + return { + async capture(error, context = {}) { + const eventId = randomHex(16); + const failure = error instanceof Error ? error : new Error(String(error)); + const response = await (options.fetch ?? fetch)(endpoint, { + method: "POST", + headers: { + "content-type": "application/json", + ...(options.publicKey + ? { "x-sentry-auth": `Sentry sentry_key=${options.publicKey}, sentry_version=7` } + : {}), + }, + body: JSON.stringify({ + event_id: eventId, + timestamp: new Date().toISOString(), + platform: "javascript", + level: "error", + exception: { + values: [{ type: failure.name, value: failure.message, stacktrace: failure.stack }], + }, + contexts: context, + }), + }); + if (!response.ok) throw new Error(`Error report failed with ${response.status}.`); + return eventId; + }, + }; +} + +export function createPerformanceProfiler( + options: { + now?: () => number; + onProfile?: (profile: { + name: string; + durationMs: number; + attributes: Record; + }) => void; + } = {}, +) { + const now = options.now ?? performance.now.bind(performance); + return async ( + name: string, + operation: () => T | Promise, + attributes: Record = {}, + ): Promise => { + const started = now(); + try { + return await operation(); + } finally { + options.onProfile?.({ name, durationMs: now() - started, attributes }); + } + }; +} diff --git a/packages/observability/src/logging.ts b/packages/observability/src/logging.ts new file mode 100644 index 00000000..7a2bd598 --- /dev/null +++ b/packages/observability/src/logging.ts @@ -0,0 +1,79 @@ +export type LogLevel = "debug" | "info" | "warn" | "error"; + +export interface LogRecord { + timestamp: string; + level: LogLevel; + message: string; + service: string; + traceId?: string; + spanId?: string; + requestId?: string; + attributes: Record; +} + +export interface StructuredLogger { + log(level: LogLevel, message: string, attributes?: Record): void; + debug(message: string, attributes?: Record): void; + info(message: string, attributes?: Record): void; + warn(message: string, attributes?: Record): void; + error(message: string, attributes?: Record): void; + child(attributes: Record): StructuredLogger; +} + +export interface StructuredLoggerOptions { + service?: string; + level?: LogLevel; + now?: () => Date; + sink?: (record: LogRecord, line: string) => void; + redact?: readonly string[]; + attributes?: Record; +} + +const LEVELS: Record = { debug: 10, info: 20, warn: 30, error: 40 }; + +function scrub(value: Record, keys: Set): Record { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + keys.has(key.toLowerCase()) ? "[REDACTED]" : item, + ]), + ); +} + +export function createStructuredLogger(options: StructuredLoggerOptions = {}): StructuredLogger { + const threshold = LEVELS[options.level ?? "info"]; + const now = options.now ?? (() => new Date()); + const sink = options.sink ?? ((_record, line) => console.log(line)); + const redacted = new Set( + (options.redact ?? ["authorization", "cookie", "password", "secret", "token"]).map((key) => + key.toLowerCase(), + ), + ); + const base = { ...(options.attributes ?? {}) }; + const logger = (attributes: Record): StructuredLogger => { + const log = (level: LogLevel, message: string, values: Record = {}) => { + if (LEVELS[level] < threshold) return; + const combined = scrub({ ...attributes, ...values }, redacted); + const record: LogRecord = { + timestamp: now().toISOString(), + level, + message, + service: options.service ?? "wrnexus", + ...(typeof combined.traceId === "string" ? { traceId: combined.traceId } : {}), + ...(typeof combined.spanId === "string" ? { spanId: combined.spanId } : {}), + ...(typeof combined.requestId === "string" ? { requestId: combined.requestId } : {}), + attributes: combined, + }; + sink(record, JSON.stringify(record)); + }; + return { + log, + debug: (message, values) => log("debug", message, values), + info: (message, values) => log("info", message, values), + warn: (message, values) => log("warn", message, values), + error: (message, values) => log("error", message, values), + child: (values) => logger({ ...attributes, ...values }), + }; + }; + return logger(base); +} diff --git a/packages/observability/src/server.ts b/packages/observability/src/server.ts index b30e4be8..dfa409cb 100644 --- a/packages/observability/src/server.ts +++ b/packages/observability/src/server.ts @@ -129,4 +129,81 @@ export function createHttpMetricExporter( }; } +export function createOtlpMetricExporter( + endpoint: string, + options: { headers?: HeadersInit; fetch?: typeof fetch; serviceName?: string } = {}, +): MetricExporter { + const send = options.fetch ?? fetch; + return { + async export(points) { + if (!points.length) return; + const metrics = points.map((point) => { + const attributes = Object.entries(point.labels).map(([key, value]) => ({ + key, + value: + typeof value === "boolean" + ? { boolValue: value } + : typeof value === "number" + ? { doubleValue: value } + : { stringValue: value }, + })); + const timeUnixNano = String(BigInt(Math.trunc(point.timestamp)) * 1_000_000n); + if (point.type === "histogram") { + return { + name: point.name, + histogram: { + aggregationTemporality: 2, + dataPoints: [ + { + attributes, + timeUnixNano, + count: String(point.count ?? 0), + sum: point.sum ?? 0, + min: point.min, + max: point.max, + bucketCounts: [String(point.count ?? 0)], + explicitBounds: [], + }, + ], + }, + }; + } + const kind = point.type === "counter" ? "sum" : "gauge"; + return { + name: point.name, + [kind]: { + ...(kind === "sum" ? { aggregationTemporality: 2, isMonotonic: true } : {}), + dataPoints: [{ attributes, timeUnixNano, asDouble: point.value }], + }, + }; + }); + const response = await send(endpoint, { + method: "POST", + headers: { + "content-type": "application/json", + ...Object.fromEntries(new Headers(options.headers)), + }, + body: JSON.stringify({ + resourceMetrics: [ + { + resource: { + attributes: [ + { key: "service.name", value: { stringValue: options.serviceName ?? "wrnexus" } }, + ], + }, + scopeMetrics: [ + { + scope: { name: "@wrnexus/observability", version: "0.8.0" }, + metrics, + }, + ], + }, + ], + }), + }); + if (!response.ok) throw new Error(`OTLP metric export failed with ${response.status}.`); + }, + }; +} + export const defaultMetrics = new MetricsRegistry(); diff --git a/packages/observability/src/trace.ts b/packages/observability/src/trace.ts new file mode 100644 index 00000000..4a59574b --- /dev/null +++ b/packages/observability/src/trace.ts @@ -0,0 +1,226 @@ +import { createTracer, type Context, type Middleware } from "@wrnexus/core"; + +export interface TraceContext { + version: "00"; + traceId: string; + spanId: string; + sampled: boolean; +} + +export interface SpanRecord { + name: string; + traceId: string; + spanId: string; + parentSpanId?: string; + sampled: boolean; + startTime: number; + endTime: number; + durationMs: number; + status: "ok" | "error"; + attributes: Record; + error?: { name: string; message: string }; +} + +export interface SpanExporter { + export(spans: readonly SpanRecord[]): Promise | void; +} + +const TRACEPARENT = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/; + +export function parseTraceparent(value: string | null | undefined): TraceContext | null { + if (!value) return null; + const match = TRACEPARENT.exec(value.trim().toLowerCase()); + if (!match || /^0+$/.test(match[1]!) || /^0+$/.test(match[2]!)) return null; + return { + version: "00", + traceId: match[1]!, + spanId: match[2]!, + sampled: (Number.parseInt(match[3]!, 16) & 1) === 1, + }; +} + +export function formatTraceparent(context: TraceContext): string { + return `00-${context.traceId}-${context.spanId}-${context.sampled ? "01" : "00"}`; +} + +function randomHex(bytes: number, random: (target: Uint8Array) => Uint8Array): string { + let value = ""; + while (!value || /^0+$/.test(value)) { + value = Array.from(random(new Uint8Array(bytes)), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + } + return value; +} + +export interface TraceMiddlewareOptions { + serviceName?: string; + sampleRate?: number; + exporter?: SpanExporter; + onSpan?: (span: SpanRecord) => void | Promise; + now?: () => number; + random?: (target: Uint8Array) => Uint8Array; + routeName?: (ctx: Context) => string; + serverTiming?: boolean; + onExportError?: (error: unknown, span: SpanRecord) => void | Promise; +} + +export function traceMiddleware(options: TraceMiddlewareOptions = {}): Middleware { + const sampleRate = options.sampleRate ?? 1; + if (!Number.isFinite(sampleRate) || sampleRate < 0 || sampleRate > 1) { + throw new Error("Observability trace sampleRate must be between 0 and 1."); + } + const now = options.now ?? Date.now; + const random = options.random ?? ((target: Uint8Array) => crypto.getRandomValues(target)); + return async (ctx, next) => { + const parent = parseTraceparent(ctx.req.headers.get("traceparent")); + const sampled = parent?.sampled ?? Math.random() < sampleRate; + const traceId = parent?.traceId ?? randomHex(16, random); + const spanId = randomHex(8, random); + const trace: TraceContext = { version: "00", traceId, spanId, sampled }; + const started = now(); + ctx.locals.traceId = traceId; + ctx.locals.spanId = spanId; + ctx.locals.traceparent = formatTraceparent(trace); + ctx.locals.requestId ??= traceId; + ctx.tracer ??= createTracer(now); + const frameworkSpan = ctx.tracer.startSpan("http.request", { + traceId, + spanId, + method: ctx.req.method, + path: ctx.url.pathname, + }); + let response: Response | undefined; + let failure: unknown; + let failed = false; + try { + response = await next(); + } catch (error) { + failure = error; + failed = true; + } finally { + const ended = now(); + const span: SpanRecord = { + name: options.routeName?.(ctx) ?? `${ctx.req.method} ${ctx.url.pathname}`, + traceId, + spanId, + ...(parent ? { parentSpanId: parent.spanId } : {}), + sampled, + startTime: started, + endTime: ended, + durationMs: ended - started, + status: failed || (response?.status ?? 500) >= 500 ? "error" : "ok", + attributes: { + "service.name": options.serviceName ?? "wrnexus", + "http.request.method": ctx.req.method, + "url.path": ctx.url.pathname, + "http.response.status_code": response?.status ?? 500, + }, + ...(failed + ? { + error: { + name: failure instanceof Error ? failure.name : "Error", + message: failure instanceof Error ? failure.message : String(failure), + }, + } + : {}), + }; + frameworkSpan.end(span.status, failure); + if (sampled) { + try { + await options.onSpan?.(span); + await options.exporter?.export([span]); + } catch (error) { + await options.onExportError?.(error, span); + } + } + } + if (failed) throw failure; + const headers = new Headers(response!.headers); + headers.set("traceparent", formatTraceparent(trace)); + headers.set("x-request-id", String(ctx.locals.requestId)); + if (options.serverTiming !== false) { + headers.append("server-timing", `trace;dur=${Math.max(0, now() - started).toFixed(2)}`); + } + return new Response(response!.body, { + status: response!.status, + statusText: response!.statusText, + headers, + }); + }; +} + +function otlpValue(value: string | number | boolean) { + if (typeof value === "boolean") return { boolValue: value }; + if (typeof value === "number") + return Number.isInteger(value) ? { intValue: String(value) } : { doubleValue: value }; + return { stringValue: value }; +} + +export function createOtlpTraceExporter( + endpoint: string, + options: { headers?: HeadersInit; fetch?: typeof fetch; serviceName?: string } = {}, +): SpanExporter { + const send = options.fetch ?? fetch; + return { + async export(spans) { + if (!spans.length) return; + const body = { + resourceSpans: [ + { + resource: { + attributes: [ + { key: "service.name", value: { stringValue: options.serviceName ?? "wrnexus" } }, + ], + }, + scopeSpans: [ + { + scope: { name: "@wrnexus/observability", version: "0.8.0" }, + spans: spans.map((span) => ({ + traceId: span.traceId, + spanId: span.spanId, + ...(span.parentSpanId ? { parentSpanId: span.parentSpanId } : {}), + name: span.name, + kind: 2, + startTimeUnixNano: String(BigInt(Math.trunc(span.startTime)) * 1_000_000n), + endTimeUnixNano: String(BigInt(Math.trunc(span.endTime)) * 1_000_000n), + attributes: Object.entries(span.attributes).map(([key, value]) => ({ + key, + value: otlpValue(value), + })), + status: { code: span.status === "error" ? 2 : 1 }, + ...(span.error + ? { + events: [ + { + timeUnixNano: String(BigInt(Math.trunc(span.endTime)) * 1_000_000n), + name: "exception", + attributes: [ + { key: "exception.type", value: { stringValue: span.error.name } }, + { + key: "exception.message", + value: { stringValue: span.error.message }, + }, + ], + }, + ], + } + : {}), + })), + }, + ], + }, + ], + }; + const response = await send(endpoint, { + method: "POST", + headers: { + "content-type": "application/json", + ...Object.fromEntries(new Headers(options.headers)), + }, + body: JSON.stringify(body), + }); + if (!response.ok) throw new Error(`OTLP trace export failed with ${response.status}.`); + }, + }; +} diff --git a/packages/observability/test/integrations.test.ts b/packages/observability/test/integrations.test.ts new file mode 100644 index 00000000..af416cf3 --- /dev/null +++ b/packages/observability/test/integrations.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "bun:test"; +import { MetricsRegistry } from "../src/metrics.ts"; +import { + createOperationTracer, + createOtlpLogExporter, + createPerformanceProfiler, + createPrometheusPushExporter, + createSentryCompatibleReporter, + createZipkinExporter, + renderPrometheus, +} from "../src/integrations.ts"; + +describe("observability integrations", () => { + test("records every framework operation kind and failures", async () => { + const spans: any[] = []; + const tracer = createOperationTracer({ + exporter: { + export: (records) => { + spans.push(...records); + }, + }, + now: (() => { + let time = 0; + return () => (time += 5); + })(), + }); + for (const kind of [ + "database", + "cache", + "queue", + "realtime", + "server-action", + "application", + ] as const) + expect(await tracer.span(kind, `${kind}.work`, async () => kind)).toBe(kind); + await expect( + tracer.span("application", "failure", async () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + expect(spans.map((span) => span.attributes["wrnexus.span.kind"])).toContain("database"); + expect(spans.at(-1).status).toBe("error"); + }); + + test("exports Prometheus, Zipkin/Jaeger, OTLP logs and Sentry-compatible errors", async () => { + const requests: Array<{ url: string; init?: RequestInit }> = []; + const send = (async (url: URL | RequestInfo, init?: RequestInit) => { + requests.push({ url: String(url), init }); + return new Response(null, { status: 200 }); + }) as typeof fetch; + const registry = new MetricsRegistry(() => 10); + registry.increment("http.requests", 1, { route: "/" }); + expect(renderPrometheus(registry.snapshot())).toContain('http_requests{route="/"} 1'); + await createPrometheusPushExporter("https://prom.test", { fetch: send }).export( + registry.snapshot(), + ); + const span = { + name: "db", + traceId: "1".repeat(32), + spanId: "2".repeat(16), + sampled: true, + startTime: 1, + endTime: 2, + durationMs: 1, + status: "ok" as const, + attributes: {}, + }; + await createZipkinExporter("https://zipkin.test", { fetch: send }).export([span]); + await createOtlpLogExporter("https://otlp.test", { fetch: send }).export([ + { + timestamp: new Date(0).toISOString(), + level: "info", + message: "ready", + service: "app", + attributes: {}, + }, + ]); + expect( + await createSentryCompatibleReporter("https://sentry.test", { fetch: send }).capture( + new Error("bad"), + ), + ).toHaveLength(32); + expect(requests).toHaveLength(4); + }); + + test("profiles application operations", async () => { + const profiles: any[] = []; + let time = 0; + const profile = createPerformanceProfiler({ + now: () => (time += 4), + onProfile: (value) => profiles.push(value), + }); + expect(await profile("render", () => "ok")).toBe("ok"); + expect(profiles[0]).toMatchObject({ name: "render", durationMs: 4 }); + }); +}); diff --git a/packages/observability/test/operations.test.ts b/packages/observability/test/operations.test.ts new file mode 100644 index 00000000..c03213ef --- /dev/null +++ b/packages/observability/test/operations.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, test } from "bun:test"; +import { createContext, HealthRegistry } from "@wrnexus/core"; +import { + createLivenessHandler, + createOtlpMetricExporter, + createOtlpTraceExporter, + createReadinessHandler, + createStructuredLogger, + formatTraceparent, + MetricsRegistry, + parseTraceparent, + traceMiddleware, + type SpanRecord, +} from "../src/index.ts"; + +describe("production observability operations", () => { + test("parses and formats strict W3C trace context", () => { + const value = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"; + const parsed = parseTraceparent(value); + expect(parsed).toEqual({ + version: "00", + traceId: "4bf92f3577b34da6a3ce929d0e0e4736", + spanId: "00f067aa0ba902b7", + sampled: true, + }); + expect(formatTraceparent(parsed!)).toBe(value); + expect(parseTraceparent("00-00000000000000000000000000000000-00f067aa0ba902b7-01")).toBeNull(); + expect(parseTraceparent("ff-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")).toBeNull(); + }); + + test("propagates trace context, correlates locals, and records a framework span", async () => { + const parent = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"; + const ctx = createContext( + new Request("https://example.test/orders", { headers: { traceparent: parent } }), + new URL("https://example.test/orders"), + ); + const spans: SpanRecord[] = []; + const times = [100, 125]; + let randomValue = 1; + const middleware = traceMiddleware({ + serviceName: "orders", + now: () => times.shift() ?? 125, + random: (target) => target.fill(randomValue++), + onSpan: (span) => { + spans.push(span); + }, + }); + const response = await middleware(ctx, () => new Response("ok", { status: 201 })); + + expect(ctx.locals.traceId).toBe("4bf92f3577b34da6a3ce929d0e0e4736"); + expect(ctx.locals.requestId).toBe(ctx.locals.traceId); + expect(response.headers.get("traceparent")).toMatch( + /^00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01$/, + ); + expect(response.headers.get("x-request-id")).toBe(String(ctx.locals.traceId)); + expect(spans[0]).toEqual( + expect.objectContaining({ + parentSpanId: "00f067aa0ba902b7", + durationMs: 25, + status: "ok", + }), + ); + expect(ctx.tracer?.records()[0]).toEqual(expect.objectContaining({ status: "ok" })); + }); + + test("does not break responses when a telemetry exporter fails", async () => { + const failures: unknown[] = []; + const ctx = createContext( + new Request("https://example.test/"), + new URL("https://example.test/"), + ); + const middleware = traceMiddleware({ + random: (target) => target.fill(7), + exporter: { export: () => Promise.reject(new Error("collector unavailable")) }, + onExportError: (error) => { + failures.push(error); + }, + }); + const response = await middleware(ctx, () => new Response("ok")); + expect(response.status).toBe(200); + expect(failures).toHaveLength(1); + }); + + test("exports OTLP JSON traces and metrics", async () => { + const requests: unknown[] = []; + const send = (async (_url: URL | RequestInfo, init?: RequestInit) => { + requests.push(JSON.parse(String(init?.body))); + return new Response(null, { status: 200 }); + }) as typeof fetch; + const span: SpanRecord = { + name: "GET /", + traceId: "1".repeat(32), + spanId: "2".repeat(16), + sampled: true, + startTime: 100, + endTime: 125, + durationMs: 25, + status: "ok", + attributes: { "http.response.status_code": 200 }, + }; + await createOtlpTraceExporter("https://collector.test/v1/traces", { fetch: send }).export([ + span, + ]); + const metrics = new MetricsRegistry(() => 200); + metrics.increment("requests", 1, { route: "/" }); + await createOtlpMetricExporter("https://collector.test/v1/metrics", { fetch: send }).export( + metrics.snapshot(), + ); + expect(requests[0]).toHaveProperty( + "resourceSpans.0.scopeSpans.0.spans.0.traceId", + span.traceId, + ); + expect(requests[1]).toHaveProperty( + "resourceMetrics.0.scopeMetrics.0.metrics.0.name", + "requests", + ); + }); + + test("serves liveness and dependency readiness without leaking details by default", async () => { + const registry = new HealthRegistry(); + registry.register("database", () => ({ status: "down", message: "connection refused" })); + const live = await createLivenessHandler()(new Request("https://example.test/live")); + const ready = await createReadinessHandler(registry)(new Request("https://example.test/ready")); + const detailed = await createReadinessHandler(registry, { exposeDetails: true })( + new Request("https://example.test/ready"), + ); + expect(live.status).toBe(200); + expect(ready.status).toBe(503); + expect(await ready.json()).toEqual({ status: "down" }); + expect(await detailed.json()).toHaveProperty("checks.database.message", "connection refused"); + }); + + test("creates correlated structured child logs with secret redaction", () => { + const records: unknown[] = []; + const logger = createStructuredLogger({ + service: "api", + now: () => new Date("2026-08-02T00:00:00.000Z"), + sink: (record) => records.push(record), + }).child({ traceId: "trace", requestId: "request" }); + logger.info("signed in", { userId: "user-1", token: "secret-value" }); + expect(records[0]).toEqual( + expect.objectContaining({ + service: "api", + traceId: "trace", + requestId: "request", + attributes: expect.objectContaining({ token: "[REDACTED]", userId: "user-1" }), + }), + ); + }); +}); diff --git a/packages/playground/README.md b/packages/playground/README.md new file mode 100644 index 00000000..a426da55 --- /dev/null +++ b/packages/playground/README.md @@ -0,0 +1,6 @@ +# @wrnexus/playground + +A deployable, shareable `.wrn` playground with diagnostics, generated JavaScript, +safe SSR-shaped HTML, sandboxed preview, reactive/UI examples, and version adapters. + +Run `wrnexus playground`, or deploy `createPlaygroundHandler()`. diff --git a/packages/playground/package.json b/packages/playground/package.json new file mode 100644 index 00000000..e6e82946 --- /dev/null +++ b/packages/playground/package.json @@ -0,0 +1,14 @@ +{ + "name": "@wrnexus/playground", + "version": "0.8.0", + "type": "module", + "description": "Secure, shareable WRNexus compiler and UI playground.", + "main": "src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "@wrnexus/compiler": "workspace:*", + "@wrnexus/syntax": "workspace:*" + } +} diff --git a/packages/playground/src/index.ts b/packages/playground/src/index.ts new file mode 100644 index 00000000..6696dfaf --- /dev/null +++ b/packages/playground/src/index.ts @@ -0,0 +1,230 @@ +import { compile, generateBrowserModule } from "@wrnexus/compiler"; +import type { ViewNode } from "@wrnexus/syntax"; + +const escapeHtml = (value: string) => + value.replace( + /[&<>"']/g, + (character) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]!, + ); + +export interface PlaygroundCompilation { + source: string; + generated: string; + client: string; + html: string; + interactiveHtml: string; + diagnostics: Array<{ code: string; message: string; severity: string }>; + version: string; +} +export interface PlaygroundVersionAdapter { + version: string; + compile(source: string): Promise>; +} +const DEFAULT_SOURCE = `component Counter { + state count = 0 + functions { client function increment(): void { count++ } } + view { } +}`; + +function preview(nodes: ViewNode[]): string { + return nodes + .map((node) => { + if (node.type === "text") + return escapeHtml(node.value).replace( + /\{([^{}]+)\}/g, + '{$1}', + ); + if (node.type === "each") + return ``; + if (node.type === "if") + return ``; + const tag = /^[a-z][a-z0-9-]*$/.test(node.tag) ? node.tag : "div"; + const attrs = node.attrs + .filter((attribute) => !/^(?:srcdoc|on\w+)$/i.test(attribute.name)) + .map((attribute) => + attribute.event + ? ` data-play-event-${attribute.name}="${escapeHtml(attribute.value)}"` + : attribute.boolean + ? ` ${attribute.name}` + : ` ${attribute.name}="${escapeHtml(attribute.value)}"`, + ) + .join(""); + return `<${tag}${attrs}>${preview(node.children)}`; + }) + .join(""); +} + +function literalState(ast: ReturnType["ast"]): Record { + const values: Record = {}; + for (const state of ast.states) { + const value = state.expr.trim(); + if (/^-?\d+(?:\.\d+)?$/.test(value)) values[state.name] = Number(value); + else if (value === "true" || value === "false") values[state.name] = value === "true"; + else if (value === "null") values[state.name] = null; + else if (/^(["']).*\1$/.test(value)) values[state.name] = value.slice(1, -1); + } + return values; +} + +function safeOperations(ast: ReturnType["ast"]): Record { + const output: Record = {}; + const states = new Set(ast.states.map((state) => state.name)); + for (const fn of ast.runtimeFunctions.filter((entry) => entry.runtime !== "server")) { + const body = fn.body.trim().replace(/;$/, ""); + const unary = /^([A-Za-z_$][\w$]*)(\+\+|--)$/.exec(body); + const assignment = + /^([A-Za-z_$][\w$]*)\s*(\+=|-=|=)\s*(-?\d+(?:\.\d+)?|true|false|(["']).*\4)$/.exec(body); + const match = unary ?? assignment; + if (!match || !states.has(match[1]!)) continue; + const raw = assignment?.[3]; + const value = + raw === undefined + ? undefined + : /^-?\d/.test(raw) + ? Number(raw) + : raw === "true" || raw === "false" + ? raw === "true" + : raw.slice(1, -1); + output[fn.name] = { + state: match[1]!, + operation: match[2]!, + ...(value !== undefined ? { value } : {}), + }; + } + return output; +} + +function interactiveDocument(fragment: string, ast: ReturnType["ast"]): string { + const data = JSON.stringify({ + state: literalState(ast), + operations: safeOperations(ast), + }).replace(/${fragment}`; +} + +export function compilePlayground(source: string, version = "0.8.0"): PlaygroundCompilation { + if (new TextEncoder().encode(source).byteLength > 128 * 1024) + throw new RangeError("WRN-PLAYGROUND-SOURCE-LIMIT"); + const result = compile(source, "playground.wrn"); + const html = preview(result.ast.view); + return { + source, + generated: result.code, + client: generateBrowserModule(result.ast), + html, + interactiveHtml: interactiveDocument(html, result.ast), + diagnostics: result.richDiagnostics.map(({ code, message, severity }) => ({ + code, + message, + severity, + })), + version, + }; +} + +export function encodePlaygroundShare(source: string): string { + const bytes = new TextEncoder().encode(source); + if (bytes.byteLength > 64 * 1024) throw new RangeError("WRN-PLAYGROUND-SHARE-LIMIT"); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} +export function decodePlaygroundShare(value: string): string { + if (!/^[A-Za-z0-9_-]{1,100000}$/.test(value)) + throw new Error("WRN-PLAYGROUND-SHARE: invalid payload"); + const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/")); + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); + if (bytes.byteLength > 64 * 1024) throw new RangeError("WRN-PLAYGROUND-SHARE-LIMIT"); + return new TextDecoder().decode(bytes); +} +export async function comparePlaygroundVersions( + source: string, + adapters: PlaygroundVersionAdapter[], +) { + return { + current: compilePlayground(source), + comparisons: await Promise.all( + adapters.map(async (adapter) => ({ + version: adapter.version, + ...(await adapter.compile(source)), + })), + ), + }; +} + +const CLIENT = String.raw` +const source=document.querySelector('#source'),status=document.querySelector('#status'),examples=document.querySelector('#examples'); +async function run(versions=false){status.textContent=versions?'Comparing':'Compiling';const response=await fetch('/api/compile',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({source:source.value,versions})});const value=await response.json(),current=value.current||value;status.textContent=response.ok?(versions?'Compared':'Compiled'):'Failed';for(const name of ['html','generated','client','diagnostics'])document.querySelector('#'+name).textContent=typeof current[name]==='string'?current[name]:JSON.stringify(current[name]||[],null,2);document.querySelector('#versions').textContent=JSON.stringify(value.comparisons||[],null,2);document.querySelector('#preview').srcdoc=current.interactiveHtml||current.html||'';if(value.share)history.replaceState(null,'','?code='+value.share)} +async function loadExamples(){const response=await fetch('/api/examples'),value=await response.json();for(const [name,code] of Object.entries(value)){const option=document.createElement('option');option.value=name;option.textContent=name;examples.append(option);examples.dataset[name]=code}if(examples.options.length)examples.dispatchEvent(new Event('change'))} +document.querySelector('#run').addEventListener('click',()=>run()); +document.querySelector('#compare').addEventListener('click',()=>run(true)); +examples.addEventListener('change',()=>{const value=examples.dataset[examples.value];if(value)source.value=value}); +document.querySelector('#share').addEventListener('click',()=>navigator.clipboard&&navigator.clipboard.writeText(location.href)); +loadExamples().catch(()=>{status.textContent='Examples unavailable'}); +`; + +function page(source: string) { + return `WRNexus Playground

    WRNexus Playground

    Write WRN, inspect SSR HTML/client/generated output, test components, share the URL, or attach it to a report.

    WRN source

    Sandboxed preview

    SSR HTML

    Generated JavaScript

    Client output

    Diagnostics

    Version comparisons

    `; +} + +export function createPlaygroundHandler( + options: { versions?: PlaygroundVersionAdapter[]; examples?: Record } = {}, +) { + return async (request: Request): Promise => { + const url = new URL(request.url); + if (url.pathname === "/playground.js") + return new Response(CLIENT, { + headers: { + "content-type": "text/javascript; charset=utf-8", + "cache-control": "public, max-age=3600", + "x-content-type-options": "nosniff", + }, + }); + if (url.pathname === "/api/examples") + return Response.json(options.examples ?? { counter: DEFAULT_SOURCE }); + if (url.pathname === "/api/compile" && request.method === "POST") { + try { + const text = await request.text(); + if (new TextEncoder().encode(text).byteLength > 140 * 1024) + return Response.json({ error: "Payload too large" }, { status: 413 }); + const body = JSON.parse(text) as { source?: unknown; versions?: boolean }; + if (typeof body.source !== "string") + return Response.json({ error: "source is required" }, { status: 400 }); + const result = + body.versions && options.versions?.length + ? await comparePlaygroundVersions(body.source, options.versions) + : compilePlayground(body.source); + return Response.json( + { ...result, share: encodePlaygroundShare(body.source) }, + { headers: { "cache-control": "no-store" } }, + ); + } catch (error) { + return Response.json( + { error: error instanceof Error ? error.message : "Compilation failed" }, + { status: 400 }, + ); + } + } + if (url.pathname !== "/") return new Response("Not Found", { status: 404 }); + let source = DEFAULT_SOURCE; + const encoded = url.searchParams.get("code"); + if (encoded) { + try { + source = decodePlaygroundShare(encoded); + } catch { + source = DEFAULT_SOURCE; + } + } + return new Response(page(source), { + headers: { + "content-type": "text/html; charset=utf-8", + "content-security-policy": + "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; frame-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'", + "x-frame-options": "SAMEORIGIN", + "x-content-type-options": "nosniff", + "referrer-policy": "no-referrer", + }, + }); + }; +} diff --git a/packages/playground/test/playground.test.ts b/packages/playground/test/playground.test.ts new file mode 100644 index 00000000..cb0e9493 --- /dev/null +++ b/packages/playground/test/playground.test.ts @@ -0,0 +1,68 @@ +import { expect, test } from "bun:test"; +import { + compilePlayground, + createPlaygroundHandler, + decodePlaygroundShare, + encodePlaygroundShare, +} from "../src/index.ts"; + +const source = `component Hello { props { name = "World" } view {

    Hello {name}

    } }`; +test("playground exposes generated, client, SSR-shaped and diagnostic output", () => { + const result = compilePlayground(source); + expect(result.generated).toContain("export function render"); + expect(result.client).toContain("Hello"); + expect(result.html).toContain("Hello"); + expect(result.interactiveHtml).toContain("Content-Security-Policy"); + expect(result.diagnostics).toEqual([]); +}); +test("share URLs round-trip Unicode and enforce bounds", () => { + const unicode = `${source}\n// नमस्ते`; + expect(decodePlaygroundShare(encodePlaygroundShare(unicode))).toBe(unicode); + expect(() => encodePlaygroundShare("x".repeat(70_000))).toThrow("WRN-PLAYGROUND-SHARE-LIMIT"); +}); +test("deployable handler compiles and serves a sandboxed CSP page", async () => { + const handler = createPlaygroundHandler(); + const response = await handler( + new Request("https://play.test/api/compile", { + method: "POST", + body: JSON.stringify({ source }), + }), + ); + expect(response.status).toBe(200); + expect(await response.json()).toHaveProperty("share"); + const html = await handler(new Request("https://play.test/")); + expect(html.headers.get("content-security-policy")).toContain("script-src 'self'"); + const page = await html.text(); + expect(page).toContain('sandbox="allow-scripts"'); + expect(page).toContain('id="compare"'); + expect(page).toContain('id="examples"'); +}); + +test("handler exposes examples and optional version comparisons", async () => { + const handler = createPlaygroundHandler({ + examples: { hello: source }, + versions: [{ version: "0.7.0", compile: async () => compilePlayground(source, "0.7.0") }], + }); + const examples = await handler(new Request("https://play.test/api/examples")); + expect(await examples.json()).toEqual({ hello: source }); + const comparison = await handler( + new Request("https://play.test/api/compile", { + method: "POST", + body: JSON.stringify({ source, versions: true }), + }), + ); + const result = (await comparison.json()) as { comparisons: Array<{ version: string }> }; + expect(result.comparisons[0]?.version).toBe("0.7.0"); +}); + +test("reactive preview interprets only bounded state operations", () => { + const result = compilePlayground( + `component Counter { + state count = 0 + functions { client function increment(): void { count++ } } + view { } + }`, + ); + expect(result.interactiveHtml).toContain('"operation":"++"'); + expect(result.interactiveHtml).toContain("data-play-event-click"); +}); diff --git a/packages/plugin/README.md b/packages/plugin/README.md index 910d50a5..80c96a43 100644 --- a/packages/plugin/README.md +++ b/packages/plugin/README.md @@ -1,7 +1,60 @@ # @wrnexus/plugin +## Least-privilege package permissions + +Package manifests declare every framework capability they register: + +```json +{ + "wrnexus": { + "permissions": ["routes", "migrations"], + "routes": [{ "kind": "api", "path": "/api/example", "entry": "./route.ts" }] + } +} +``` + +Applications can enable fail-closed grants: + +```ts +export default { + pluginPermissions: { + enforce: true, + grants: { "example-plugin": ["routes"] }, + }, +}; +``` + +Discovery rejects used-but-undeclared capabilities with +`WRN-PLUGIN-PERMISSION-UNDECLARED` and ungranted capabilities with +`WRN-PLUGIN-PERMISSION-DENIED`. Permissions cover components, browser runtime, +assets, styles, routes, middleware, migrations, config, transforms, +diagnostics/tooling, and server/build hooks. + +## Compatibility matrices + +Manifests can add `compatibility: { bunMin: "1.3.0", os: ["linux", +"darwin"] }` alongside `runtimes` and `requires`. Use +`testPluginCompatibility(manifest, targets)` in a package test to exercise the +complete support matrix. Runtime discovery enforces the same Bun minimum, OS, +runtime, and capability declarations used by the test kit. + Deterministic WRNexusJS plugin contracts for configuration, AST/code transforms, diagnostics, development servers, production builds, and DevToolbar extensions. Use `definePlugin()` and declare `enforce`, `before`, or `after` when ordering matters. Duplicate names and dependency cycles are rejected. + +## Complete lifecycle and contributions + +Plugins may implement `setup`, `configure`, `configResolved`, `transformAst`, +`transformCode`, `diagnostics`, `routes`, `configureServer`, `buildStart`, +`buildEnd`, `render`, `deploy`, `shutdown`, and `hmrUpdate`. The runner preserves +resolved plugin order for every hook and executes `setup` exactly once. + +In addition to components, routes, middleware, assets, styles, runtimes, and +migrations, plugins can contribute `directives`, `cliCommands`, +`virtualModules`, `deploymentAdapters`, `configSchemas`, `documentation`, and +`typeDefinitions`. Names are collision checked. Configuration schemas run after +configuration resolution, CLI commands are callable as normal `wrnexus` +commands, directives participate in AST transformation, and production builds +materialize virtual modules and invoke matching contributed adapters. diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 1cbd8101..b8dc67ab 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/plugin", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/plugin/src/compatibility.ts b/packages/plugin/src/compatibility.ts new file mode 100644 index 00000000..6d18aa59 --- /dev/null +++ b/packages/plugin/src/compatibility.ts @@ -0,0 +1,78 @@ +import type { WrnexusPackageManifest } from "./types.ts"; + +export interface PluginCompatibilityTarget { + runtime: "bun" | "node" | "edge" | "worker" | "service-worker" | "browser"; + version?: string; + os?: "win32" | "linux" | "darwin" | string; + capabilities?: readonly string[]; +} + +export interface PluginCompatibilityResult { + target: PluginCompatibilityTarget; + ok: boolean; + issues: Array<{ + code: + | "WRN-PLUGIN-MATRIX-RUNTIME" + | "WRN-PLUGIN-MATRIX-VERSION" + | "WRN-PLUGIN-MATRIX-OS" + | "WRN-PLUGIN-MATRIX-CAPABILITY"; + message: string; + }>; +} + +function parts(version: string): number[] { + return version + .replace(/^[^\d]*/, "") + .split(/[.-]/) + .slice(0, 3) + .map((value) => Number(value) || 0); +} + +function atLeast(version: string, minimum: string): boolean { + const left = parts(version); + const right = parts(minimum); + for (let index = 0; index < 3; index += 1) { + if (left[index] !== right[index]) return (left[index] ?? 0) > (right[index] ?? 0); + } + return true; +} + +export function testPluginCompatibility( + manifest: WrnexusPackageManifest, + targets: readonly PluginCompatibilityTarget[], +): PluginCompatibilityResult[] { + return targets.map((target) => { + const issues: PluginCompatibilityResult["issues"] = []; + if (manifest.runtimes?.length && !manifest.runtimes.includes(target.runtime)) { + issues.push({ + code: "WRN-PLUGIN-MATRIX-RUNTIME", + message: `${target.runtime} is not declared.`, + }); + } + const compatibility = manifest.compatibility; + if ( + target.runtime === "bun" && + target.version && + compatibility?.bunMin && + !atLeast(target.version, compatibility.bunMin) + ) { + issues.push({ + code: "WRN-PLUGIN-MATRIX-VERSION", + message: `Bun ${target.version} is below ${compatibility.bunMin}.`, + }); + } + if (target.os && compatibility?.os?.length && !compatibility.os.includes(target.os)) { + issues.push({ code: "WRN-PLUGIN-MATRIX-OS", message: `${target.os} is not declared.` }); + } + const missing = (manifest.requires ?? []).filter( + (capability) => !target.capabilities?.includes(capability), + ); + if (missing.length) { + issues.push({ + code: "WRN-PLUGIN-MATRIX-CAPABILITY", + message: `Missing: ${missing.join(", ")}.`, + }); + } + return { target: { ...target }, ok: issues.length === 0, issues }; + }); +} diff --git a/packages/plugin/src/discovery.ts b/packages/plugin/src/discovery.ts index bf5e8291..2466ae4c 100644 --- a/packages/plugin/src/discovery.ts +++ b/packages/plugin/src/discovery.ts @@ -12,8 +12,10 @@ import type { PackageStyleDefinition, PackageRouteDefinition, PackageMigrationDefinition, + PluginPermission, } from "./types.ts"; import { definePlugin, flattenPlugins } from "./base.ts"; +import { testPluginCompatibility } from "./compatibility.ts"; interface PackageJson { name?: string; @@ -32,6 +34,70 @@ export interface DiscoverPluginOptions { strict?: boolean; includePeerDependencies?: boolean; warn?: (message: string) => void; + runtime?: string; + capabilities?: readonly string[]; + runtimeVersion?: string; + os?: string; + /** When true, every requested capability must be declared and granted by the application. */ + enforcePermissions?: boolean; + grantedPermissions?: Readonly>; +} + +function manifestPermissions(manifest: WrnexusPackageManifest): PluginPermission[] { + const permissions: PluginPermission[] = []; + if (manifest.components?.length) permissions.push("components"); + if (manifest.clientRuntimes?.length) permissions.push("client-runtime"); + if (manifest.assets?.length) permissions.push("assets"); + if (manifest.styles?.length) permissions.push("styles"); + if (manifest.routes?.length) permissions.push("routes"); + if (manifest.middleware?.length) permissions.push("middleware"); + if (manifest.migrations?.length) permissions.push("migrations"); + return permissions; +} + +function modulePermissions(plugin: WrnexusPlugin): PluginPermission[] { + const permissions: PluginPermission[] = []; + if (plugin.componentDirs) permissions.push("components"); + if (plugin.clientRuntimes) permissions.push("client-runtime"); + if (plugin.assets) permissions.push("assets"); + if (plugin.styleSources) permissions.push("styles"); + if (plugin.routeEntries || plugin.routes) permissions.push("routes"); + if (plugin.middleware) permissions.push("middleware"); + if (plugin.migrations) permissions.push("migrations"); + if (plugin.configure || plugin.configResolved) permissions.push("config"); + if (plugin.transformAst || plugin.transformCode) permissions.push("transform"); + if (plugin.diagnostics || plugin.devToolbarPanels) permissions.push("diagnostics"); + if (plugin.configureServer || plugin.buildStart || plugin.buildEnd) permissions.push("server"); + if (plugin.cliCommands) permissions.push("cli"); + if (plugin.directives) permissions.push("directives"); + if (plugin.virtualModules) permissions.push("virtual-modules"); + if (plugin.deploymentAdapters || plugin.deploy) permissions.push("deployment"); + if (plugin.documentation) permissions.push("documentation"); + if (plugin.typeDefinitions) permissions.push("types"); + return [...new Set(permissions)]; +} + +function enforcePermissions( + packageName: string, + manifest: WrnexusPackageManifest, + required: readonly PluginPermission[], + options: DiscoverPluginOptions, +): void { + if (!options.enforcePermissions) return; + const declared = new Set(manifest.permissions ?? []); + const undeclared = required.filter((permission) => !declared.has(permission)); + if (undeclared.length) { + throw new Error( + `WRN-PLUGIN-PERMISSION-UNDECLARED: ${packageName} uses ${undeclared.join(", ")} without declaring them.`, + ); + } + const grants = new Set(options.grantedPermissions?.[packageName] ?? []); + const denied = required.filter((permission) => !grants.has(permission)); + if (denied.length) { + throw new Error( + `WRN-PLUGIN-PERMISSION-DENIED: ${packageName} is not granted ${denied.join(", ")}.`, + ); + } } function readJson(path: string): PackageJson | null { @@ -244,6 +310,40 @@ export async function discoverPlugins( const packageJson = readJson(join(packageRoot, "package.json")); const packageManifest = packageJson?.wrnexus; if (!packageManifest) continue; + enforcePermissions(name, packageManifest, manifestPermissions(packageManifest), options); + if ( + options.runtime && + packageManifest.runtimes?.length && + !packageManifest.runtimes.includes(options.runtime as never) + ) { + throw new Error(`WRN-PLUGIN-RUNTIME: ${name} does not support ${options.runtime}.`); + } + const missingCapabilities = (packageManifest.requires ?? []).filter( + (capability) => !options.capabilities?.includes(capability), + ); + if (options.runtime && missingCapabilities.length) { + throw new Error( + `WRN-PLUGIN-CAPABILITY: ${name} requires unavailable capabilities: ${missingCapabilities.join(", ")}.`, + ); + } + if (options.runtime) { + const matrix = testPluginCompatibility(packageManifest, [ + { + runtime: options.runtime as + "bun" | "node" | "edge" | "worker" | "service-worker" | "browser", + version: + options.runtimeVersion ?? + (options.runtime === "bun" && typeof Bun !== "undefined" ? Bun.version : undefined), + os: options.os ?? process.platform, + capabilities: options.capabilities, + }, + ])[0]!; + const compatibilityIssue = matrix.issues.find((issue) => + ["WRN-PLUGIN-MATRIX-VERSION", "WRN-PLUGIN-MATRIX-OS"].includes(issue.code), + ); + if (compatibilityIssue) + throw new Error(`${compatibilityIssue.code}: ${name}: ${compatibilityIssue.message}`); + } const manifestPlugin = manifestContributionPlugin( packageRoot, packageJson?.name ?? name, @@ -258,6 +358,7 @@ export async function discoverPlugins( if (manifest.autoDiscover === false) continue; try { const plugin = await loadPlugin(packageRoot, manifest); + enforcePermissions(name, packageManifest, modulePermissions(plugin), options); if (!explicitNames.has(plugin.name) && !automatic.some((item) => item.name === plugin.name)) { automatic.push(plugin); } diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index ae8ed538..9925e30c 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -1,4 +1,4 @@ -import type { PageAst, WrnDiagnostic } from "@wrnexus/syntax"; +import type { PageAst, ViewNode, WrnDiagnostic } from "@wrnexus/syntax"; import { normalizeClientRuntime, normalizePackageAsset, validateStyleIds } from "./manifest.ts"; import type { ClientRuntimeDefinition, @@ -11,7 +11,13 @@ import type { PluginContext, PluginInput, PluginOrder, + PluginPermission, PluginRunner, + PluginDirective, + PluginCliCommand, + PluginVirtualModule, + PluginDeploymentAdapter, + PluginConfigSchema, TransformContext, WrnexusPlugin, } from "./types.ts"; @@ -19,6 +25,8 @@ import type { export * from "./types.ts"; export * from "./manifest.ts"; export { discoverPlugins, type DiscoverPluginOptions } from "./discovery.ts"; +export { testPluginCompatibility } from "./compatibility.ts"; +export type { PluginCompatibilityResult, PluginCompatibilityTarget } from "./compatibility.ts"; export { definePlugin, flattenPlugins } from "./base.ts"; import { flattenPlugins } from "./base.ts"; @@ -112,9 +120,40 @@ function assertUnique(kind: string, entries: readonly T[], key: (entry: T) => } } +async function transformDirectives( + nodes: ViewNode[], + directives: readonly PluginDirective[], + context: TransformContext, +): Promise { + const registry = new Map(directives.map((directive) => [directive.name, directive])); + for (const node of nodes) { + if (node.type === "element") { + for (const attribute of node.attrs) { + if (!attribute.name.startsWith("use:")) continue; + const name = attribute.name.slice(4); + const directive = registry.get(name); + if (!directive) continue; + const transformed = await directive.transform?.(attribute.value, context); + if (transformed) { + attribute.name = transformed.name; + attribute.value = transformed.value; + } else attribute.name = `data-wrn-directive-${name}`; + } + await transformDirectives(node.children, directives, context); + } else if (node.type === "each") { + await transformDirectives(node.body, directives, context); + await transformDirectives(node.empty, directives, context); + } else if (node.type === "if") { + for (const branch of node.branches) + await transformDirectives(branch.body, directives, context); + } + } +} + export function createPluginRunner(input: PluginInput, context: PluginContext): PluginRunner { const plugins = resolvePlugins(input); let contributionCache: PluginContributions | null = null; + let setupComplete = false; const transformContext = (file: string): TransformContext => ({ ...context, file }); const contributions = async (): Promise => { @@ -126,6 +165,13 @@ export function createPluginRunner(input: PluginInput, context: PluginContext): const routes: PackageRouteDefinition[] = []; const middleware: string[] = []; const migrations: PackageMigrationDefinition[] = []; + const directives: PluginDirective[] = []; + const cliCommands: PluginCliCommand[] = []; + const virtualModules: PluginVirtualModule[] = []; + const deploymentAdapters: PluginDeploymentAdapter[] = []; + const configSchemas: PluginConfigSchema[] = []; + const documentation: string[] = []; + const typeDefinitions: string[] = []; for (const plugin of plugins) { componentDirs.push(...(await resolveContribution(plugin.componentDirs, context))); @@ -135,6 +181,13 @@ export function createPluginRunner(input: PluginInput, context: PluginContext): routes.push(...(await resolveContribution(plugin.routeEntries, context))); middleware.push(...(await resolveContribution(plugin.middleware, context))); migrations.push(...(await resolveContribution(plugin.migrations, context))); + directives.push(...(await resolveContribution(plugin.directives, context))); + cliCommands.push(...(await resolveContribution(plugin.cliCommands, context))); + virtualModules.push(...(await resolveContribution(plugin.virtualModules, context))); + deploymentAdapters.push(...(await resolveContribution(plugin.deploymentAdapters, context))); + configSchemas.push(...(await resolveContribution(plugin.configSchemas, context))); + documentation.push(...(await resolveContribution(plugin.documentation, context))); + typeDefinitions.push(...(await resolveContribution(plugin.typeDefinitions, context))); } const normalizedRuntimes = clientRuntimes.map(normalizeClientRuntime); @@ -164,6 +217,11 @@ export function createPluginRunner(input: PluginInput, context: PluginContext): } } assertUnique("MIGRATION", migrations, (entry) => `${entry.database ?? "default"}:${entry.id}`); + assertUnique("DIRECTIVE", directives, (entry) => entry.name); + assertUnique("CLI-COMMAND", cliCommands, (entry) => entry.name); + assertUnique("VIRTUAL-MODULE", virtualModules, (entry) => entry.id); + assertUnique("DEPLOYMENT-ADAPTER", deploymentAdapters, (entry) => entry.name); + assertUnique("CONFIG-SCHEMA", configSchemas, (entry) => entry.namespace); contributionCache = { componentDirs: [...new Set(componentDirs)], @@ -173,6 +231,13 @@ export function createPluginRunner(input: PluginInput, context: PluginContext): routes, middleware: [...new Set(middleware)], migrations, + directives, + cliCommands, + virtualModules, + deploymentAdapters, + configSchemas, + documentation: [...new Set(documentation)], + typeDefinitions: [...new Set(typeDefinitions)], }; context.metadata.set("@wrnexus/plugin:contributions", contributionCache); return contributionCache; @@ -181,18 +246,29 @@ export function createPluginRunner(input: PluginInput, context: PluginContext): return { plugins, async configure(config) { + if (!setupComplete) { + for (const plugin of plugins) await plugin.setup?.(context); + setupComplete = true; + } for (const plugin of plugins) await plugin.configure?.(config, context); contributionCache = null; }, async configResolved(config) { for (const plugin of plugins) await plugin.configResolved?.(config, context); contributionCache = null; - await contributions(); + const resolved = await contributions(); + for (const schema of resolved.configSchemas) + await schema.validate(config[schema.namespace], context); }, async transformAst(ast, file) { let current = ast; for (const plugin of plugins) current = (await plugin.transformAst?.(current, transformContext(file))) ?? current; + await transformDirectives( + current.view, + (await contributions()).directives, + transformContext(file), + ); return current; }, async transformCode(code, file) { @@ -219,11 +295,19 @@ export function createPluginRunner(input: PluginInput, context: PluginContext): for (const plugin of plugins) current = (await plugin.routes?.(current, context)) ?? current; return current; }, + async render(html) { + let current = html; + for (const plugin of plugins) current = (await plugin.render?.(current, context)) ?? current; + return current; + }, async hook(name, value) { for (const plugin of plugins) { if (name === "buildStart") await plugin.buildStart?.(context); else if (name === "buildEnd") await plugin.buildEnd?.(value, context); - else await plugin.configureServer?.(value, context); + else if (name === "configureServer") await plugin.configureServer?.(value, context); + else if (name === "deploy") await plugin.deploy?.(value, context); + else if (name === "shutdown") await plugin.shutdown?.(context); + else await plugin.hmrUpdate?.((value as readonly string[]) ?? [], context); } }, }; @@ -237,6 +321,7 @@ export type { PluginContext, TransformContext, WrnexusPlugin, + PluginPermission, PluginInput, PluginRunner, }; diff --git a/packages/plugin/src/types.ts b/packages/plugin/src/types.ts index 95cd1b20..a6b78e8b 100644 --- a/packages/plugin/src/types.ts +++ b/packages/plugin/src/types.ts @@ -1,10 +1,28 @@ import type { PageAst, WrnDiagnostic } from "@wrnexus/syntax"; export type PluginOrder = "pre" | "normal" | "post"; -export type PluginCommand = "dev" | "build" | "test"; +export type PluginCommand = "dev" | "build" | "test" | "cli" | "deploy"; export type ClientRuntimeType = "module" | "script"; export type ClientRuntimeLoad = "eager" | "defer" | "idle"; export type ClientRuntimeInject = "head" | "body-end"; +export type PluginPermission = + | "components" + | "client-runtime" + | "assets" + | "styles" + | "routes" + | "middleware" + | "migrations" + | "config" + | "transform" + | "diagnostics" + | "server" + | "cli" + | "directives" + | "virtual-modules" + | "deployment" + | "documentation" + | "types"; export interface PluginContext { root: string; @@ -100,6 +118,36 @@ export interface PluginDevToolbarPanel { data?: unknown; } +export interface PluginCliCommand { + name: string; + description?: string; + run(args: string[], context: PluginContext): void | Promise; +} + +export interface PluginVirtualModule { + id: string; + load(context: PluginContext): string | Promise; +} + +export interface PluginDirective { + name: string; + transform?: ( + value: string, + context: TransformContext, + ) => { name: string; value: string } | void | Promise<{ name: string; value: string } | void>; +} + +export interface PluginDeploymentAdapter { + name: string; + build(output: unknown, context: PluginContext): unknown | Promise; + deploy?(output: unknown, context: PluginContext): unknown | Promise; +} + +export interface PluginConfigSchema { + namespace: string; + validate(value: unknown, context: PluginContext): void | Promise; +} + export interface PluginContributions { componentDirs: string[]; clientRuntimes: ClientRuntimeDefinition[]; @@ -108,6 +156,13 @@ export interface PluginContributions { routes: PackageRouteDefinition[]; middleware: string[]; migrations: PackageMigrationDefinition[]; + directives: PluginDirective[]; + cliCommands: PluginCliCommand[]; + virtualModules: PluginVirtualModule[]; + deploymentAdapters: PluginDeploymentAdapter[]; + configSchemas: PluginConfigSchema[]; + documentation: string[]; + typeDefinitions: string[]; } export interface WrnexusPlugin { @@ -145,7 +200,25 @@ export interface WrnexusPlugin { | (( context: PluginContext, ) => PackageMigrationDefinition[] | Promise); + directives?: + | PluginDirective[] + | ((context: PluginContext) => PluginDirective[] | Promise); + cliCommands?: + | PluginCliCommand[] + | ((context: PluginContext) => PluginCliCommand[] | Promise); + virtualModules?: + | PluginVirtualModule[] + | ((context: PluginContext) => PluginVirtualModule[] | Promise); + deploymentAdapters?: + | PluginDeploymentAdapter[] + | ((context: PluginContext) => PluginDeploymentAdapter[] | Promise); + configSchemas?: + | PluginConfigSchema[] + | ((context: PluginContext) => PluginConfigSchema[] | Promise); + documentation?: string[] | ((context: PluginContext) => string[] | Promise); + typeDefinitions?: string[] | ((context: PluginContext) => string[] | Promise); + setup?(context: PluginContext): void | Promise; configure?(config: Record, context: PluginContext): void | Promise; configResolved?( config: Readonly>, @@ -158,6 +231,10 @@ export interface WrnexusPlugin { configureServer?(server: unknown, context: PluginContext): void | Promise; buildStart?(context: PluginContext): void | Promise; buildEnd?(result: unknown, context: PluginContext): void | Promise; + render?(html: string, context: PluginContext): string | void | Promise; + deploy?(result: unknown, context: PluginContext): void | Promise; + shutdown?(context: PluginContext): void | Promise; + hmrUpdate?(files: readonly string[], context: PluginContext): void | Promise; devToolbarPanels?( context: PluginContext, ): PluginDevToolbarPanel[] | Promise; @@ -179,6 +256,16 @@ export interface PackagePluginManifest { export interface WrnexusPackageManifest { name?: string; version?: string; + runtimes?: Array<"bun" | "node" | "edge" | "worker" | "service-worker" | "browser">; + requires?: Array< + "filesystem" | "tcp" | "process" | "websocket" | "crypto" | "streams" | "background-tasks" + >; + /** Framework capabilities requested by this package; applications may enforce explicit grants. */ + permissions?: PluginPermission[]; + compatibility?: { + bunMin?: string; + os?: string[]; + }; plugin?: string | PackagePluginManifest; components?: string[]; clientRuntimes?: ClientRuntimeDefinition[]; @@ -199,5 +286,9 @@ export interface PluginRunner { contributions(): Promise; devToolbarPanels(): Promise; transformRoutes(routes: unknown[]): Promise; - hook(name: "buildStart" | "buildEnd" | "configureServer", value?: unknown): Promise; + render(html: string): Promise; + hook( + name: "buildStart" | "buildEnd" | "configureServer" | "deploy" | "shutdown" | "hmrUpdate", + value?: unknown, + ): Promise; } diff --git a/packages/plugin/test/compatibility.test.ts b/packages/plugin/test/compatibility.test.ts new file mode 100644 index 00000000..dbbaa4eb --- /dev/null +++ b/packages/plugin/test/compatibility.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from "bun:test"; +import { testPluginCompatibility } from "../src/index.ts"; + +test("plugin compatibility kit evaluates runtime, Bun, OS, and capability matrices", () => { + const results = testPluginCompatibility( + { + runtimes: ["bun", "node"], + requires: ["filesystem"], + compatibility: { bunMin: "1.3.0", os: ["linux", "darwin"] }, + }, + [ + { runtime: "bun", version: "1.3.2", os: "linux", capabilities: ["filesystem"] }, + { runtime: "bun", version: "1.2.9", os: "win32", capabilities: [] }, + { runtime: "edge", os: "linux", capabilities: ["crypto"] }, + ], + ); + expect(results[0]?.ok).toBe(true); + expect(results[1]?.issues.map((issue) => issue.code)).toEqual([ + "WRN-PLUGIN-MATRIX-VERSION", + "WRN-PLUGIN-MATRIX-OS", + "WRN-PLUGIN-MATRIX-CAPABILITY", + ]); + expect(results[2]?.issues.map((issue) => issue.code)).toEqual([ + "WRN-PLUGIN-MATRIX-RUNTIME", + "WRN-PLUGIN-MATRIX-CAPABILITY", + ]); +}); diff --git a/packages/plugin/test/plugin.test.ts b/packages/plugin/test/plugin.test.ts index d634f206..536f3346 100644 --- a/packages/plugin/test/plugin.test.ts +++ b/packages/plugin/test/plugin.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createPluginRunner, discoverPlugins, resolvePlugins } from "../src/index.ts"; +import { parse } from "@wrnexus/syntax"; describe("plugin ordering", () => { test("orders pre, normal, and post plugins", () => { @@ -22,6 +23,85 @@ describe("plugin ordering", () => { }); }); +test("runs the complete lifecycle and exposes ecosystem contribution channels", async () => { + const calls: string[] = []; + const runner = createPluginRunner( + { + name: "ecosystem", + setup: () => { + calls.push("setup"); + }, + configure: () => { + calls.push("configure"); + }, + configSchemas: [ + { + namespace: "feature", + validate(value) { + calls.push(`schema:${String(value)}`); + }, + }, + ], + directives: [ + { + name: "focus", + transform: (value) => ({ name: "data-focus", value }), + }, + ], + cliCommands: [ + { + name: "hello", + run: () => { + calls.push("cli"); + }, + }, + ], + virtualModules: [{ id: "virtual:feature", load: () => "export default true" }], + deploymentAdapters: [{ name: "test-cloud", build: (value) => value }], + documentation: ["docs/feature.md"], + typeDefinitions: ["types/feature.d.ts"], + render: (html) => `${html}`, + hmrUpdate: (files) => { + calls.push(`hmr:${files.join(",")}`); + }, + deploy: () => { + calls.push("deploy"); + }, + shutdown: () => { + calls.push("shutdown"); + }, + }, + context("."), + ); + await runner.configure({ feature: true }); + await runner.configResolved({ feature: true }); + const contributions = await runner.contributions(); + expect(contributions.directives[0]?.name).toBe("focus"); + expect(contributions.cliCommands[0]?.name).toBe("hello"); + expect(contributions.virtualModules[0]?.id).toBe("virtual:feature"); + expect(contributions.deploymentAdapters[0]?.name).toBe("test-cloud"); + expect(contributions.documentation).toEqual(["docs/feature.md"]); + expect(contributions.typeDefinitions).toEqual(["types/feature.d.ts"]); + const transformed = await runner.transformAst( + parse('page Demo { view { } }'), + "app/pages/demo.wrn", + ); + const input = transformed.view.find((node) => node.type === "element"); + expect(input?.type === "element" ? input.attrs[0]?.name : undefined).toBe("data-focus"); + expect(await runner.render("
    ")).toContain(""); + await runner.hook("hmrUpdate", ["app/page.wrn"]); + await runner.hook("deploy", {}); + await runner.hook("shutdown"); + expect(calls).toEqual([ + "setup", + "configure", + "schema:true", + "hmr:app/page.wrn", + "deploy", + "shutdown", + ]); +}); + function context(root: string) { return { root, @@ -185,6 +265,96 @@ test("strict discovery surfaces invalid package plugin exports", async () => { } }); +test("discovery enforces declared deployment runtimes and capabilities", async () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-runtime-discovery-")); + const app = join(root, "apps", "web"); + const pkg = join(root, "packages", "filesystem-plugin"); + try { + mkdirSync(app, { recursive: true }); + mkdirSync(pkg, { recursive: true }); + writeFileSync( + join(root, "package.json"), + JSON.stringify({ private: true, workspaces: ["apps/*", "packages/*"] }), + ); + writeFileSync( + join(app, "package.json"), + JSON.stringify({ name: "web", dependencies: { "filesystem-plugin": "workspace:*" } }), + ); + writeFileSync( + join(pkg, "package.json"), + JSON.stringify({ + name: "filesystem-plugin", + version: "1.0.0", + wrnexus: { runtimes: ["bun", "node"], requires: ["filesystem"] }, + }), + ); + await expect( + discoverPlugins(app, undefined, { runtime: "edge", capabilities: ["crypto"] }), + ).rejects.toThrow("WRN-PLUGIN-RUNTIME"); + await expect( + discoverPlugins(app, undefined, { runtime: "bun", capabilities: ["filesystem"] }), + ).resolves.toBeDefined(); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("discovery enforces declared and application-granted plugin permissions", async () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-permission-discovery-")); + const app = join(root, "apps", "web"); + const pkg = join(root, "packages", "route-plugin"); + try { + mkdirSync(app, { recursive: true }); + mkdirSync(pkg, { recursive: true }); + writeFileSync( + join(root, "package.json"), + JSON.stringify({ private: true, workspaces: ["apps/*", "packages/*"] }), + ); + writeFileSync( + join(app, "package.json"), + JSON.stringify({ name: "web", dependencies: { "route-plugin": "workspace:*" } }), + ); + writeFileSync(join(pkg, "route.ts"), "export default {};"); + writeFileSync( + join(pkg, "package.json"), + JSON.stringify({ + name: "route-plugin", + version: "1.0.0", + wrnexus: { + permissions: ["routes"], + routes: [{ kind: "api", path: "/api/plugin", entry: "./route.ts" }], + }, + }), + ); + await expect( + discoverPlugins(app, undefined, { enforcePermissions: true, grantedPermissions: {} }), + ).rejects.toThrow("WRN-PLUGIN-PERMISSION-DENIED"); + await expect( + discoverPlugins(app, undefined, { + enforcePermissions: true, + grantedPermissions: { "route-plugin": ["routes"] }, + }), + ).resolves.toBeDefined(); + + writeFileSync( + join(pkg, "package.json"), + JSON.stringify({ + name: "route-plugin", + version: "1.0.0", + wrnexus: { routes: [{ kind: "api", path: "/api/plugin", entry: "./route.ts" }] }, + }), + ); + await expect( + discoverPlugins(app, undefined, { + enforcePermissions: true, + grantedPermissions: { "route-plugin": ["routes"] }, + }), + ).rejects.toThrow("WRN-PLUGIN-PERMISSION-UNDECLARED"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("orders package style contributions by pre, normal, and post", async () => { const runner = createPluginRunner( { diff --git a/packages/pubsub/README.md b/packages/pubsub/README.md index 424cfb88..fd7a0fd2 100644 --- a/packages/pubsub/README.md +++ b/packages/pubsub/README.md @@ -31,13 +31,15 @@ Creates a bus over a driver. Defaults to `memoryDriver()` (in-process). interface PubSub { publish(topic: string, message: T): Promise; subscribe(pattern: string, handler: Handler): () => void; + close(): Promise; } type Handler = (message: T, topic: string) => void | Promise; ``` -- `publish(topic, message)` — resolves once the driver has dispatched the message. +- `publish(topic, message)` — resolves once the driver and in-memory async handlers finish. - `subscribe(pattern, handler)` — returns an unsubscribe function. +- `close()` — idempotently rejects new work, clears local subscriptions, and closes the driver. ### Pattern matching @@ -67,7 +69,7 @@ then `redis://localhost:6379`. The URL may carry a password and a database index (e.g. `redis://:secret@host:6379/2`). ```ts -function redisDriver(url?: string): PubSubDriver & { close(): void }; +function redisDriver(url?: string, options?: RedisDriverOptions): PubSubDriver & { close(): void }; ``` - Exact topics use Redis `SUBSCRIBE`; wildcard patterns (`ns:*`, `*`) use @@ -75,6 +77,9 @@ function redisDriver(url?: string): PubSubDriver & { close(): void }; - Messages are JSON-stringified on publish and `JSON.parse`d on receipt; a payload that isn't valid JSON is delivered as the raw string. - `close()` tears down both the subscriber and publisher connections. +- Lost sockets reconnect with bounded exponential backoff and active subscriptions + are replayed. `maxPending` bounds unavailable-connection writes (default 1000); + `reconnectDelayMs` and `reconnectMaxDelayMs` tune recovery (100ms/5000ms). ### RESP codec (internal) @@ -115,8 +120,8 @@ bus.subscribe("order:*", (msg, topic) => { await bus.publish("order:created", { id: 7 }); -// on shutdown -driver.close(); +// on shutdown (also closes the driver) +await bus.close(); ``` ## Requirements / Notes diff --git a/packages/pubsub/package.json b/packages/pubsub/package.json index 155b740a..13563ee0 100644 --- a/packages/pubsub/package.json +++ b/packages/pubsub/package.json @@ -1,11 +1,12 @@ { "name": "@wrnexus/pubsub", - "version": "0.7.0", + "version": "0.8.0", "private": true, "type": "module", "main": "src/index.ts", "exports": { ".": "./src/index.ts", + "./brokers": "./src/brokers.ts", "./redis": "./src/redis.ts" } } diff --git a/packages/pubsub/src/brokers.ts b/packages/pubsub/src/brokers.ts new file mode 100644 index 00000000..08036509 --- /dev/null +++ b/packages/pubsub/src/brokers.ts @@ -0,0 +1,59 @@ +import type { Handler, PubSubDriver } from "./index.ts"; + +export interface NatsClient { + publish(subject: string, data: Uint8Array): void | Promise; + subscribe(subject: string, handler: (data: Uint8Array, subject: string) => void): () => void; + close?(): void | Promise; +} + +export function natsDriver(client: NatsClient): PubSubDriver { + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + return { + publish(topic, message) { + return client.publish(topic, encoder.encode(JSON.stringify(message))); + }, + subscribe(pattern, handler) { + const subject = + pattern === "*" ? ">" : pattern.endsWith(":*") ? `${pattern.slice(0, -2)}:>` : pattern; + return client.subscribe(subject, (data, topic) => { + const raw = decoder.decode(data); + let value: unknown = raw; + try { + value = JSON.parse(raw); + } catch { + /* raw broker payload */ + } + void handler(value, topic); + }); + }, + close: () => client.close?.(), + }; +} + +export interface KafkaClient { + publish(topic: string, value: string): void | Promise; + subscribe(pattern: string, handler: (value: string, topic: string) => void): () => void; + close?(): void | Promise; +} + +/** Kafka adapter contract; consumer-group/rebalance policy remains owned by the selected client. */ +export function kafkaDriver(client: KafkaClient): PubSubDriver { + return { + publish(topic, message) { + return client.publish(topic, JSON.stringify(message)); + }, + subscribe(pattern, handler: Handler) { + return client.subscribe(pattern, (raw, topic) => { + let value: unknown = raw; + try { + value = JSON.parse(raw); + } catch { + /* raw broker payload */ + } + void handler(value, topic); + }); + }, + close: () => client.close?.(), + }; +} diff --git a/packages/pubsub/src/index.ts b/packages/pubsub/src/index.ts index 60adcc06..0e3a742f 100644 --- a/packages/pubsub/src/index.ts +++ b/packages/pubsub/src/index.ts @@ -15,11 +15,14 @@ export type Handler = (message: T, topic: string) => void | Promise export interface PubSubDriver { publish(topic: string, message: unknown): void | Promise; subscribe(pattern: string, handler: Handler): () => void; + close?(): void | Promise; } export interface PubSub { publish(topic: string, message: T): Promise; subscribe(pattern: string, handler: Handler): () => void; + /** Stop new work, remove subscriptions, and close the backing driver. */ + close(): Promise; } function patternMatches(pattern: string, topic: string): boolean { @@ -31,14 +34,19 @@ function patternMatches(pattern: string, topic: string): boolean { /** In-process pub/sub driver (default). */ export function memoryDriver(): PubSubDriver { const subs = new Map>(); + let closed = false; return { - publish(topic, message) { + async publish(topic, message) { + if (closed) throw new Error("WRN-PUBSUB-CLOSED: pubsub driver is closed"); + const pending: Promise[] = []; for (const [pattern, handlers] of subs) { if (!patternMatches(pattern, topic)) continue; - for (const handler of handlers) void handler(message, topic); + for (const handler of handlers) pending.push(Promise.resolve(handler(message, topic))); } + await Promise.all(pending); }, subscribe(pattern, handler) { + if (closed) throw new Error("WRN-PUBSUB-CLOSED: pubsub driver is closed"); let set = subs.get(pattern); if (!set) subs.set(pattern, (set = new Set())); set.add(handler); @@ -47,19 +55,35 @@ export function memoryDriver(): PubSubDriver { if (!set!.size) subs.delete(pattern); }; }, + close() { + closed = true; + subs.clear(); + }, }; } /** Create a pub/sub bus over a driver (in-memory by default). */ export function createPubSub(driver: PubSubDriver = memoryDriver()): PubSub { + let closed = false; return { async publish(topic, message) { + if (closed) throw new Error("WRN-PUBSUB-CLOSED: pubsub is closed"); + if (!topic.trim()) throw new TypeError("pubsub topic cannot be empty"); await driver.publish(topic, message); }, subscribe(pattern, handler) { + if (closed) throw new Error("WRN-PUBSUB-CLOSED: pubsub is closed"); + if (!pattern.trim()) throw new TypeError("pubsub pattern cannot be empty"); return driver.subscribe(pattern, handler as Handler); }, + async close() { + if (closed) return; + closed = true; + await driver.close?.(); + }, }; } export { createResilientPubSub, PresenceChannel } from "./resilient.ts"; export type { MessageEnvelope, ResilientPubSubOptions, PresenceMember } from "./resilient.ts"; +export { natsDriver, kafkaDriver } from "./brokers.ts"; +export type { NatsClient, KafkaClient } from "./brokers.ts"; diff --git a/packages/pubsub/src/redis.ts b/packages/pubsub/src/redis.ts index a976426d..d1cee391 100644 --- a/packages/pubsub/src/redis.ts +++ b/packages/pubsub/src/redis.ts @@ -32,6 +32,15 @@ interface ParsedUrl { tls: boolean; } +export interface RedisDriverOptions { + /** Maximum writes buffered while Redis is unavailable. Default 1000. */ + maxPending?: number; + /** Initial reconnect delay. Doubles up to reconnectMaxDelayMs. Default 100. */ + reconnectDelayMs?: number; + /** Maximum reconnect delay. Default 5000. */ + reconnectMaxDelayMs?: number; +} + function parseUrl(url: string): ParsedUrl { const u = new URL(url); if (u.protocol !== "redis:" && u.protocol !== "rediss:") { @@ -59,58 +68,101 @@ const isPattern = (p: string): boolean => p.includes("*"); * Open a Redis TCP connection. `onReply` receives every parsed top-level reply * (used by the subscriber connection to dispatch message/pmessage pushes). */ -function connect(cfg: ParsedUrl, onReply?: (value: RespValue) => void): RedisConn { +function connect( + cfg: ParsedUrl, + options: Required, + onReply?: (value: RespValue) => void, + onReconnect?: () => void, +): RedisConn { let socket: { write(data: Uint8Array): void; end(): void } | null = null; const pending: Uint8Array[] = []; - const maxPending = 1000; let buf: Uint8Array = new Uint8Array(0); + let closed = false; + let connecting = false; + let reconnectTimer: ReturnType | null = null; + let reconnectAttempt = 0; + let openedOnce = false; // Bun.connect is available in the Bun runtime. const Bun = (globalThis as { Bun?: { connect: (opts: unknown) => Promise } }).Bun; if (!Bun?.connect) throw new Error("redisDriver requires the Bun runtime (Bun.connect)."); - void Bun.connect({ - hostname: cfg.host, - port: cfg.port, - tls: cfg.tls, - socket: { - open(s: { write(data: Uint8Array): void; end(): void }) { - socket = s; - if (cfg.password) s.write(encodeCommand(["AUTH", cfg.password])); - if (cfg.db) s.write(encodeCommand(["SELECT", String(cfg.db)])); - for (const p of pending) s.write(p); - pending.length = 0; + const scheduleReconnect = () => { + if (closed || reconnectTimer) return; + const delay = Math.min( + options.reconnectMaxDelayMs, + options.reconnectDelayMs * 2 ** reconnectAttempt++, + ); + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + start(); + }, delay); + }; + + const start = () => { + if (closed || connecting) return; + connecting = true; + void Bun.connect({ + hostname: cfg.host, + port: cfg.port, + tls: cfg.tls, + socket: { + open(s: { write(data: Uint8Array): void; end(): void }) { + connecting = false; + socket = s; + reconnectAttempt = 0; + if (cfg.password) s.write(encodeCommand(["AUTH", cfg.password])); + if (cfg.db) s.write(encodeCommand(["SELECT", String(cfg.db)])); + if (openedOnce) onReconnect?.(); + openedOnce = true; + for (const p of pending) s.write(p); + pending.length = 0; + }, + data(_s: unknown, chunk: Uint8Array) { + buf = buf.length ? concat([buf, chunk]) : chunk; + for (;;) { + const r = parseReply(buf); + if (!r) break; + buf = buf.slice(r.next); + onReply?.(r.value); + } + }, + error() { + socket = null; + connecting = false; + scheduleReconnect(); + }, + close() { + socket = null; + connecting = false; + scheduleReconnect(); + }, }, - data(_s: unknown, chunk: Uint8Array) { - buf = buf.length ? concat([buf, chunk]) : chunk; - for (;;) { - const r = parseReply(buf); - if (!r) break; - buf = buf.slice(r.next); - onReply?.(r.value); - } - }, - error() { - /* connection error — writes silently no-op until reconnect */ - }, - close() { - socket = null; - }, - }, - }); + }).catch(() => { + connecting = false; + scheduleReconnect(); + }); + }; + start(); return { send(bytes) { if (socket) socket.write(bytes); else { - if (pending.length >= maxPending) { + if (closed) throw new Error("Redis pubsub connection is closed"); + if (pending.length >= options.maxPending) { throw new Error("Redis connection is unavailable and its pending write queue is full"); } pending.push(bytes); } }, close() { + closed = true; + if (reconnectTimer) clearTimeout(reconnectTimer); + reconnectTimer = null; + pending.length = 0; socket?.end(); + socket = null; }, }; } @@ -119,8 +171,22 @@ function connect(cfg: ParsedUrl, onReply?: (value: RespValue) => void): RedisCon * A cross-process pub/sub driver backed by Redis. `url` defaults to * `$REDIS_URL` or `redis://localhost:6379`. */ -export function redisDriver(url?: string): PubSubDriver & { close(): void } { +export function redisDriver( + url?: string, + options: RedisDriverOptions = {}, +): PubSubDriver & { close(): void } { const cfg = parseUrl(url ?? getEnv("REDIS_URL") ?? "redis://localhost:6379"); + const resolved = { + maxPending: positiveInteger(options.maxPending ?? 1000, "Redis maxPending"), + reconnectDelayMs: positiveInteger(options.reconnectDelayMs ?? 100, "Redis reconnectDelayMs"), + reconnectMaxDelayMs: positiveInteger( + options.reconnectMaxDelayMs ?? 5000, + "Redis reconnectMaxDelayMs", + ), + }; + if (resolved.reconnectMaxDelayMs < resolved.reconnectDelayMs) { + throw new RangeError("Redis reconnectMaxDelayMs must be >= reconnectDelayMs"); + } const subs = new Map>(); const redisSubscribed = new Set(); @@ -136,13 +202,22 @@ export function redisDriver(url?: string): PubSubDriver & { close(): void } { for (const handler of handlers) void handler(message, topic); }; - const subConn = connect(cfg, (value) => { - if (!Array.isArray(value)) return; - const kind = value[0]; - if (kind === "message") dispatch(String(value[1]), String(value[1]), String(value[2])); - else if (kind === "pmessage") dispatch(String(value[1]), String(value[2]), String(value[3])); - }); - const pubConn = connect(cfg); + const subConn = connect( + cfg, + resolved, + (value) => { + if (!Array.isArray(value)) return; + const kind = value[0]; + if (kind === "message") dispatch(String(value[1]), String(value[1]), String(value[2])); + else if (kind === "pmessage") dispatch(String(value[1]), String(value[2]), String(value[3])); + }, + () => { + for (const pattern of redisSubscribed) { + subConn.send(encodeCommand([isPattern(pattern) ? "PSUBSCRIBE" : "SUBSCRIBE", pattern])); + } + }, + ); + const pubConn = connect(cfg, resolved); return { publish(topic, message) { @@ -175,6 +250,13 @@ export function redisDriver(url?: string): PubSubDriver & { close(): void } { }; } +function positiveInteger(value: number, label: string): number { + if (!Number.isInteger(value) || value < 1) { + throw new RangeError(`${label} must be a positive integer`); + } + return value; +} + function getEnv(key: string): string | undefined { return (globalThis as { process?: { env?: Record } }).process?.env?.[ key diff --git a/packages/pubsub/src/resilient.ts b/packages/pubsub/src/resilient.ts index f246b526..31a6e85c 100644 --- a/packages/pubsub/src/resilient.ts +++ b/packages/pubsub/src/resilient.ts @@ -26,9 +26,11 @@ export function createResilientPubSub( if (!Number.isFinite(retryDelayMs) || retryDelayMs < 0) { throw new RangeError("pubsub retryDelayMs must be a non-negative number"); } + let closed = false; return { async publish(topic, message) { + if (closed) throw new Error("WRN-PUBSUB-CLOSED: pubsub is closed"); if (!topic.trim()) throw new TypeError("pubsub topic cannot be empty"); const envelope: MessageEnvelope = { id: crypto.randomUUID(), @@ -55,6 +57,7 @@ export function createResilientPubSub( }, subscribe(pattern: string, handler: Handler) { + if (closed) throw new Error("WRN-PUBSUB-CLOSED: pubsub is closed"); if (!pattern.trim()) throw new TypeError("pubsub pattern cannot be empty"); return driver.subscribe(pattern, async (value, topic) => { const envelope = value as MessageEnvelope; @@ -65,6 +68,11 @@ export function createResilientPubSub( } }); }, + async close() { + if (closed) return; + closed = true; + await driver.close?.(); + }, }; } diff --git a/packages/pubsub/test/brokers.test.ts b/packages/pubsub/test/brokers.test.ts new file mode 100644 index 00000000..b1e53d25 --- /dev/null +++ b/packages/pubsub/test/brokers.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from "bun:test"; +import { createPubSub, kafkaDriver, natsDriver } from "../src/index.ts"; + +test("NATS adapter serializes messages and maps namespace wildcards", async () => { + let subscription = ""; + let receive: ((data: Uint8Array, subject: string) => void) | undefined; + const bus = createPubSub( + natsDriver({ + async publish(subject, data) { + receive?.(data, subject); + }, + subscribe(subject, handler) { + subscription = subject; + receive = handler; + return () => { + receive = undefined; + }; + }, + }), + ); + const values: unknown[] = []; + bus.subscribe("room:*", (value) => { + values.push(value); + }); + await bus.publish("room:one", { online: 2 }); + expect(subscription).toBe("room:>"); + expect(values).toEqual([{ online: 2 }]); +}); + +test("Kafka adapter preserves topic and JSON payload contracts", async () => { + let receive: ((value: string, topic: string) => void) | undefined; + const bus = createPubSub( + kafkaDriver({ + async publish(topic, value) { + receive?.(value, topic); + }, + subscribe(_pattern, handler) { + receive = handler; + return () => { + receive = undefined; + }; + }, + }), + ); + let received = ""; + bus.subscribe("events", (value, topic) => { + received = `${topic}:${(value as { id: number }).id}`; + }); + await bus.publish("events", { id: 7 }); + expect(received).toBe("events:7"); +}); diff --git a/packages/pubsub/test/pubsub.test.ts b/packages/pubsub/test/pubsub.test.ts index cd3f0df1..ff8410e4 100644 --- a/packages/pubsub/test/pubsub.test.ts +++ b/packages/pubsub/test/pubsub.test.ts @@ -36,6 +36,21 @@ test("unsubscribe stops delivery", async () => { expect(n).toBe(1); }); +test("publish awaits async handlers and close rejects new work", async () => { + const bus = createPubSub(); + let completed = false; + bus.subscribe("task", async () => { + await Promise.resolve(); + completed = true; + }); + await bus.publish("task", {}); + expect(completed).toBe(true); + await bus.close(); + await bus.close(); + await expect(bus.publish("task", {})).rejects.toThrow("WRN-PUBSUB-CLOSED"); + expect(() => bus.subscribe("task", () => {})).toThrow("WRN-PUBSUB-CLOSED"); +}); + test("resilient pubsub validates retry and presence settings", () => { const driver = { publish: async () => {}, diff --git a/packages/pubsub/test/redis-driver.test.ts b/packages/pubsub/test/redis-driver.test.ts index 4cd322cb..fd83f197 100644 --- a/packages/pubsub/test/redis-driver.test.ts +++ b/packages/pubsub/test/redis-driver.test.ts @@ -31,3 +31,43 @@ test("bounds writes queued while Redis is unavailable", () => { expect(() => driver.publish("topic", "overflow")).toThrow("queue is full"); driver.close(); }); + +test("validates reconnect and backpressure options", () => { + bun.connect = (() => new Promise(() => {})) as typeof bun.connect; + expect(() => redisDriver(undefined, { maxPending: 0 })).toThrow("maxPending"); + expect(() => redisDriver(undefined, { reconnectDelayMs: 20, reconnectMaxDelayMs: 10 })).toThrow( + "reconnectMaxDelayMs", + ); +}); + +test("reconnects and replays subscriptions after a socket closes", async () => { + const connections: Array> = []; + bun.connect = ((options: Record) => { + connections.push(options); + return Promise.resolve({}); + }) as typeof bun.connect; + const driver = redisDriver("redis://localhost:6379", { + reconnectDelayMs: 1, + reconnectMaxDelayMs: 1, + }); + expect(connections).toHaveLength(2); + + const firstWrites: string[] = []; + connections[0].socket.open({ + write: (bytes: Uint8Array) => firstWrites.push(new TextDecoder().decode(bytes)), + end() {}, + }); + driver.subscribe("order:*", () => {}); + expect(firstWrites.some((write) => write.includes("PSUBSCRIBE"))).toBe(true); + + connections[0].socket.close(); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(connections.length).toBeGreaterThanOrEqual(3); + const replayed: string[] = []; + connections[2].socket.open({ + write: (bytes: Uint8Array) => replayed.push(new TextDecoder().decode(bytes)), + end() {}, + }); + expect(replayed.some((write) => write.includes("PSUBSCRIBE"))).toBe(true); + driver.close(); +}); diff --git a/packages/pwa/README.md b/packages/pwa/README.md new file mode 100644 index 00000000..a11b6fda --- /dev/null +++ b/packages/pwa/README.md @@ -0,0 +1,6 @@ +# @wrnexus/pwa + +Official PWA primitives for manifests, service workers, offline pages and precaching, runtime +caching, background synchronization, push notifications, install/update events, offline mutation +stores, and conflict resolution. `createOfflineQueue()` accepts a durable IndexedDB-style store and +retries requests with stable idempotency headers. diff --git a/packages/pwa/package.json b/packages/pwa/package.json new file mode 100644 index 00000000..b9e70cb2 --- /dev/null +++ b/packages/pwa/package.json @@ -0,0 +1,19 @@ +{ + "name": "@wrnexus/pwa", + "version": "0.8.0", + "type": "module", + "description": "Service workers, manifests, offline queues, background sync, push, and conflict resolution for WRNexusJS.", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src", + "README.md" + ], + "devDependencies": { + "@types/bun": "^1.3.14", + "typescript": "^5.9.2" + } +} diff --git a/packages/pwa/src/advanced.ts b/packages/pwa/src/advanced.ts new file mode 100644 index 00000000..60851043 --- /dev/null +++ b/packages/pwa/src/advanced.ts @@ -0,0 +1,181 @@ +import type { OfflineMutation, OfflineQueueStore } from "./index.ts"; + +export interface IndexedDbMigration { + version: number; + migrate(db: IDBDatabase, transaction: IDBTransaction): void; +} +export function openPwaDatabase( + name: string, + migrations: IndexedDbMigration[], + factory: IDBFactory = indexedDB, +): Promise { + if (!name.trim()) throw new Error("IndexedDB name is required"); + const ordered = [...migrations].sort((a, b) => a.version - b.version); + if (ordered.some((migration, index) => migration.version !== index + 1)) + throw new Error("WRN-PWA-IDB-MIGRATIONS: versions must be contiguous from 1"); + return new Promise((resolve, reject) => { + const request = factory.open(name, ordered.at(-1)?.version ?? 1); + request.onerror = () => reject(request.error); + request.onblocked = () => reject(new Error("WRN-PWA-IDB-BLOCKED")); + request.onupgradeneeded = (event) => { + const transaction = request.transaction!; + for (const migration of ordered) + if (migration.version > event.oldVersion && migration.version <= event.newVersion!) + migration.migrate(request.result, transaction); + }; + request.onsuccess = () => resolve(request.result); + }); +} + +function idbRequest(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} +export function indexedDbOfflineQueueStore( + db: IDBDatabase, + storeName = "mutations", +): OfflineQueueStore { + return { + async list() { + return ( + (await idbRequest( + db.transaction(storeName, "readonly").objectStore(storeName).getAll(), + )) as OfflineMutation[] + ).sort((a, b) => a.createdAt - b.createdAt); + }, + async put(item) { + await idbRequest( + db.transaction(storeName, "readwrite").objectStore(storeName).put(structuredClone(item)), + ); + }, + async remove(id) { + await idbRequest(db.transaction(storeName, "readwrite").objectStore(storeName).delete(id)); + }, + }; +} +export const offlineQueueMigration: IndexedDbMigration = { + version: 1, + migrate(db) { + if (!db.objectStoreNames.contains("mutations")) + db.createObjectStore("mutations", { keyPath: "id" }); + }, +}; + +export interface StoredPushSubscription { + id: string; + userId: string; + endpoint: string; + expirationTime?: number | null; + keys: { p256dh: string; auth: string }; + createdAt: number; +} +export interface PushSubscriptionStore { + put(value: StoredPushSubscription): Promise; + remove(id: string): Promise; + list(userId: string): Promise; +} +export function memoryPushSubscriptionStore(): PushSubscriptionStore { + const values = new Map(); + return { + async put(value) { + values.set(value.id, structuredClone(value)); + }, + async remove(id) { + values.delete(id); + }, + async list(userId) { + return [...values.values()] + .filter((value) => value.userId === userId) + .map((value) => structuredClone(value)); + }, + }; +} +export interface PushSqlClient { + query>(sql: string, parameters?: unknown[]): Promise<{ rows: T[] }>; +} +export function postgresPushSubscriptionStore(db: PushSqlClient): PushSubscriptionStore { + return { + async put(value) { + await db.query( + `INSERT INTO wrnexus_push_subscriptions (id,user_id,endpoint,expiration_time,p256dh,auth,created_at) VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (id) DO UPDATE SET user_id=$2,endpoint=$3,expiration_time=$4,p256dh=$5,auth=$6`, + [ + value.id, + value.userId, + value.endpoint, + value.expirationTime ?? null, + value.keys.p256dh, + value.keys.auth, + value.createdAt, + ], + ); + }, + async remove(id) { + await db.query(`DELETE FROM wrnexus_push_subscriptions WHERE id=$1`, [id]); + }, + async list(userId) { + const result = await db.query( + `SELECT id,user_id AS "userId",endpoint,expiration_time AS "expirationTime",json_build_object('p256dh',p256dh,'auth',auth) AS keys,created_at AS "createdAt" FROM wrnexus_push_subscriptions WHERE user_id=$1 ORDER BY created_at`, + [userId], + ); + return result.rows; + }, + }; +} +export const POSTGRES_PUSH_SUBSCRIPTION_SCHEMA = `CREATE TABLE IF NOT EXISTS wrnexus_push_subscriptions (id text PRIMARY KEY,user_id text NOT NULL,endpoint text NOT NULL,expiration_time bigint,p256dh text NOT NULL,auth text NOT NULL,created_at bigint NOT NULL); CREATE INDEX IF NOT EXISTS wrnexus_push_user ON wrnexus_push_subscriptions (user_id);`; +export function createPushSubscriptionService( + store: PushSubscriptionStore, + options: { now?: () => number; maxPerUser?: number } = {}, +) { + const now = options.now ?? Date.now; + const max = options.maxPerUser ?? 20; + return { + async subscribe( + userId: string, + value: { + endpoint: string; + expirationTime?: number | null; + keys: { p256dh: string; auth: string }; + }, + ) { + if (!userId.trim()) throw new Error("Push subscription user is required"); + const endpoint = new URL(value.endpoint); + if (endpoint.protocol !== "https:") throw new Error("Push endpoint must use HTTPS"); + if ( + !value.keys?.p256dh || + !value.keys.auth || + value.keys.p256dh.length > 1024 || + value.keys.auth.length > 1024 + ) + throw new Error("Invalid push subscription keys"); + const existing = await store.list(userId); + const id = await crypto.subtle + .digest("SHA-256", new TextEncoder().encode(value.endpoint)) + .then((bytes) => + [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join(""), + ); + if (!existing.some((entry) => entry.id === id) && existing.length >= max) + throw new Error("WRN-PWA-PUSH-CAPACITY"); + const record = { id, userId, ...value, keys: { ...value.keys }, createdAt: now() }; + await store.put(record); + return record; + }, + unsubscribe: (id: string) => store.remove(id), + list: (userId: string) => store.list(userId), + }; +} + +const esc = (value: unknown) => + String(value ?? "").replace( + /[&<>"']/g, + (character) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]!, + ); +export function renderOfflineQueueReview( + items: OfflineMutation[], + conflicts: Array<{ id: string; message: string }> = [], +): string { + return `

    Offline changes

      ${items.map((item) => `
    • ${esc(item.method)} ${esc(item.endpoint)} · ${item.attempts} attempts
    • `).join("") || "
    • No pending changes
    • "}

    Conflicts

      ${conflicts.map((item) => `
    • ${esc(item.message)}
    • `).join("") || "
    • No conflicts
    • "}
    `; +} +export const PWA_REVIEW_RUNTIME = `document.addEventListener("click",event=>{const button=event.target.closest("[data-pwa-retry],[data-pwa-remove],[data-pwa-client],[data-pwa-server]");if(!button)return;const action=button.hasAttribute("data-pwa-retry")?"retry":button.hasAttribute("data-pwa-remove")?"remove":button.hasAttribute("data-pwa-client")?"client":"server";const id=button.getAttribute("data-pwa-"+action);dispatchEvent(new CustomEvent("wrnexus:pwa-review",{detail:{action,id}}))});`; diff --git a/packages/pwa/src/index.ts b/packages/pwa/src/index.ts new file mode 100644 index 00000000..da7ea2d3 --- /dev/null +++ b/packages/pwa/src/index.ts @@ -0,0 +1,190 @@ +export type RuntimeCacheStrategy = "network-first" | "cache-first" | "stale-while-revalidate"; +export interface RuntimeCacheRule { + pattern: string; + strategy: RuntimeCacheStrategy; + cacheName?: string; + methods?: string[]; +} +export interface ServiceWorkerOptions { + cacheName?: string; + offlineUrl?: string; + startUrl?: string; + cacheUrls?: string[]; + runtimeCaching?: RuntimeCacheRule[]; + backgroundSyncTag?: string; +} +export interface WebManifestOptions { + name: string; + shortName?: string; + description?: string; + id?: string; + startUrl?: string; + scope?: string; + display?: "standalone" | "fullscreen" | "minimal-ui" | "browser"; + themeColor?: string; + backgroundColor?: string; + icons?: Array<{ src: string; sizes: string; type?: string; purpose?: string }>; + shortcuts?: unknown[]; + screenshots?: unknown[]; + categories?: string[]; + lang?: string; +} +export function createWebManifest(options: WebManifestOptions) { + return { + id: options.id ?? options.startUrl ?? "/", + name: options.name, + short_name: options.shortName ?? options.name, + description: options.description, + start_url: options.startUrl ?? "/", + scope: options.scope ?? "/", + display: options.display ?? "standalone", + theme_color: options.themeColor ?? "#0f172a", + background_color: options.backgroundColor ?? "#0f172a", + icons: options.icons ?? [], + shortcuts: options.shortcuts ?? [], + screenshots: options.screenshots ?? [], + categories: options.categories ?? [], + lang: options.lang ?? "en", + }; +} +export function generateServiceWorker(options: ServiceWorkerOptions = {}): string { + const offline = options.offlineUrl ?? options.startUrl ?? "/"; + const urls = [...new Set([offline, ...(options.cacheUrls ?? [])])]; + const rules = options.runtimeCaching ?? [ + { pattern: "^https?://", strategy: "network-first" as const, methods: ["GET"] }, + ]; + return `const CACHE=${JSON.stringify(options.cacheName ?? "wrnexus-pwa-v1")};const OFFLINE=${JSON.stringify(offline)};const PRECACHE=${JSON.stringify(urls)};const RULES=${JSON.stringify(rules)};self.addEventListener("install",event=>{event.waitUntil(caches.open(CACHE).then(cache=>cache.addAll(PRECACHE)));self.skipWaiting()});self.addEventListener("activate",event=>event.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(key=>key.startsWith("wrnexus-pwa-")&&key!==CACHE).map(key=>caches.delete(key)))).then(()=>self.clients.claim())));const networkFirst=async request=>{try{const response=await fetch(request);if(response.ok){const cache=await caches.open(CACHE);await cache.put(request,response.clone())}return response}catch{return(await caches.match(request))||(request.mode==="navigate"?await caches.match(OFFLINE):Response.error())}};const cacheFirst=async request=>(await caches.match(request))||networkFirst(request);const stale=async request=>{const hit=await caches.match(request);const update=networkFirst(request);return hit||(await update)};self.addEventListener("fetch",event=>{const rule=RULES.find(item=>(item.methods||["GET"]).includes(event.request.method)&&new RegExp(item.pattern).test(event.request.url));if(!rule)return;event.respondWith(rule.strategy==="cache-first"?cacheFirst(event.request):rule.strategy==="stale-while-revalidate"?stale(event.request):networkFirst(event.request))});self.addEventListener("sync",event=>{if(event.tag===${JSON.stringify(options.backgroundSyncTag ?? "wrnexus-offline-sync")})event.waitUntil(self.clients.matchAll().then(clients=>clients.forEach(client=>client.postMessage({type:"wrnexus:background-sync"}))))});self.addEventListener("push",event=>{const data=event.data?.json?.()||{};event.waitUntil(self.registration.showNotification(data.title||"Notification",{body:data.body,icon:data.icon,data:{url:data.url||"/"}}))});self.addEventListener("notificationclick",event=>{event.notification.close();event.waitUntil(clients.openWindow(event.notification.data?.url||"/"))});`; +} +export interface OfflineMutation { + id: string; + createdAt: number; + updatedAt: number; + endpoint: string; + method: string; + payload: T; + attempts: number; +} +export interface OfflineQueueStore { + list(): Promise; + put(item: OfflineMutation): Promise; + remove(id: string): Promise; +} +export function memoryOfflineQueueStore(): OfflineQueueStore { + const values = new Map(); + return { + async list() { + return [...values.values()] + .sort((a, b) => a.createdAt - b.createdAt) + .map((value) => structuredClone(value)); + }, + async put(value) { + values.set(value.id, structuredClone(value)); + }, + async remove(id) { + values.delete(id); + }, + }; +} +export type ConflictResolution = { action: "client" | "server" | "merge"; value: T }; +export function resolveOfflineConflict( + client: T, + server: T, + strategy: + | "client-wins" + | "server-wins" + | "last-write-wins" + | ((client: T, server: T) => T) = "last-write-wins", +): ConflictResolution { + if (typeof strategy === "function") return { action: "merge", value: strategy(client, server) }; + if (strategy === "client-wins") return { action: "client", value: client }; + if (strategy === "server-wins") return { action: "server", value: server }; + const clientUpdatedAt = Number((client as { updatedAt?: number }).updatedAt ?? 0); + const serverUpdatedAt = Number((server as { updatedAt?: number }).updatedAt ?? 0); + return clientUpdatedAt >= serverUpdatedAt + ? { action: "client", value: client } + : { action: "server", value: server }; +} +export function createOfflineQueue( + options: { + store?: OfflineQueueStore; + fetch?: (input: string | URL | Request, init?: RequestInit) => Promise; + maxItems?: number; + now?: () => number; + } = {}, +) { + const store = options.store ?? memoryOfflineQueueStore(); + const request = options.fetch ?? globalThis.fetch; + const now = options.now ?? Date.now; + const maxItems = options.maxItems ?? 1000; + return { + async enqueue( + input: Omit, "id" | "createdAt" | "updatedAt" | "attempts">, + ) { + if ((await store.list()).length >= maxItems) throw new Error("WRN-PWA-OFFLINE-CAPACITY"); + const timestamp = now(); + const item: OfflineMutation = { + ...input, + id: crypto.randomUUID(), + createdAt: timestamp, + updatedAt: timestamp, + attempts: 0, + }; + await store.put(item); + return item; + }, + list: () => store.list(), + async sync() { + const results: Array<{ id: string; ok: boolean; status?: number }> = []; + for (const item of await store.list()) { + item.attempts += 1; + item.updatedAt = now(); + try { + const response = await request(item.endpoint, { + method: item.method, + headers: { "content-type": "application/json", "x-wrnexus-offline-id": item.id }, + body: JSON.stringify(item.payload), + }); + if (response.ok) await store.remove(item.id); + else await store.put(item); + results.push({ id: item.id, ok: response.ok, status: response.status }); + } catch { + await store.put(item); + results.push({ id: item.id, ok: false }); + } + } + return results; + }, + remove: (id: string) => store.remove(id), + }; +} +export function pwaClientRuntime(serviceWorkerUrl = "/sw.js"): string { + return `if("serviceWorker"in navigator){addEventListener("load",async()=>{const registration=await navigator.serviceWorker.register(${JSON.stringify(serviceWorkerUrl)});if(registration.waiting)dispatchEvent(new CustomEvent("wrnexus:pwa-update",{detail:{registration}}));registration.addEventListener("updatefound",()=>dispatchEvent(new CustomEvent("wrnexus:pwa-update-found",{detail:{registration}})))})}let wrnexusInstallPrompt;addEventListener("beforeinstallprompt",event=>{event.preventDefault();wrnexusInstallPrompt=event;dispatchEvent(new CustomEvent("wrnexus:pwa-installable"))});window.WrNexusPwa={install:async()=>{if(!wrnexusInstallPrompt)return false;await wrnexusInstallPrompt.prompt();const result=await wrnexusInstallPrompt.userChoice;wrnexusInstallPrompt=null;return result.outcome==="accepted"}};`; +} +export async function subscribeToPush( + registration: ServiceWorkerRegistration, + publicKey: Uint8Array, +): Promise { + const permission = await Notification.requestPermission(); + if (permission !== "granted") throw new Error("WRN-PWA-PUSH-DENIED"); + return registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: Uint8Array.from(publicKey).buffer, + }); +} +export { + openPwaDatabase, + indexedDbOfflineQueueStore, + offlineQueueMigration, + memoryPushSubscriptionStore, + postgresPushSubscriptionStore, + POSTGRES_PUSH_SUBSCRIPTION_SCHEMA, + createPushSubscriptionService, + renderOfflineQueueReview, + PWA_REVIEW_RUNTIME, +} from "./advanced.ts"; +export type { + IndexedDbMigration, + StoredPushSubscription, + PushSubscriptionStore, + PushSqlClient, +} from "./advanced.ts"; diff --git a/packages/pwa/test/advanced.test.ts b/packages/pwa/test/advanced.test.ts new file mode 100644 index 00000000..ab31c61c --- /dev/null +++ b/packages/pwa/test/advanced.test.ts @@ -0,0 +1,62 @@ +import { expect, test } from "bun:test"; +import { + createPushSubscriptionService, + memoryPushSubscriptionStore, + postgresPushSubscriptionStore, + renderOfflineQueueReview, +} from "../src/index.ts"; + +test("push subscriptions validate, deduplicate and persist per user", async () => { + const service = createPushSubscriptionService(memoryPushSubscriptionStore(), { + maxPerUser: 1, + now: () => 10, + }); + const value = { endpoint: "https://push.test/sub", keys: { p256dh: "public", auth: "secret" } }; + const first = await service.subscribe("user", value); + expect((await service.list("user"))[0]).toEqual(first); + expect((await service.subscribe("user", value)).id).toBe(first.id); + await expect( + service.subscribe("user", { ...value, endpoint: "https://push.test/other" }), + ).rejects.toThrow("CAPACITY"); + await expect( + service.subscribe("user", { ...value, endpoint: "http://push.test/insecure" }), + ).rejects.toThrow("HTTPS"); +}); + +test("PostgreSQL subscriptions parameterize endpoint and user", async () => { + const calls: unknown[][] = []; + const store = postgresPushSubscriptionStore({ + async query(_sql: string, params?: unknown[]) { + calls.push(params ?? []); + return { rows: [] as T[] }; + }, + }); + await store.put({ + id: "id", + userId: "user", + endpoint: "https://push.test", + keys: { p256dh: "p", auth: "a" }, + createdAt: 1, + }); + expect(calls[0]?.slice(0, 3)).toEqual(["id", "user", "https://push.test"]); +}); + +test("offline review UI escapes payload-derived identifiers and conflicts", () => { + const html = renderOfflineQueueReview( + [ + { + id: `">`, + ), + ); + } + controller.close(); + }, + }); +} + export interface RenderOptions { /** Page metadata for the document head. */ meta: PageMeta; diff --git a/packages/ssr/src/store-context.ts b/packages/ssr/src/store-context.ts index 7255f278..ade5fa07 100644 --- a/packages/ssr/src/store-context.ts +++ b/packages/ssr/src/store-context.ts @@ -1,4 +1,5 @@ import { serializeForHtml } from "@wrnexus/security"; +import { escapeHtml } from "@wrnexus/core"; import { createRequestStoreContainer } from "@wrnexus/store/server"; import type { StoreContainer } from "@wrnexus/store"; @@ -21,5 +22,5 @@ export async function disposeRequestStores(request: object): Promise { export function renderStoreHydration(container: StoreContainer, nonce?: string): string { const json = serializeForHtml(container.serialize()); - return ``; + return ``; } diff --git a/packages/ssr/test/partial.test.ts b/packages/ssr/test/partial.test.ts new file mode 100644 index 00000000..a59dce0b --- /dev/null +++ b/packages/ssr/test/partial.test.ts @@ -0,0 +1,12 @@ +import { expect, test } from "bun:test"; +import { partialPrerender, streamPartialDocument } from "../src/index.ts"; + +test("partial prerender extracts and streams dynamic regions after the shell", async () => { + const result = partialPrerender( + '
    Static
    User
    Static
    ', + ); + expect(result.shell).toContain("data-wrn-dynamic-placeholder"); + expect(result.regions).toEqual([{ id: "wrn-region-0", html: "User" }]); + const output = await new Response(streamPartialDocument(result)).text(); + expect(output.indexOf("
    Static
    ")).toBeLessThan(output.indexOf("User")); +}); diff --git a/packages/ssr/test/ssr.test.ts b/packages/ssr/test/ssr.test.ts index 9d64a58e..505eab7b 100644 --- a/packages/ssr/test/ssr.test.ts +++ b/packages/ssr/test/ssr.test.ts @@ -1,5 +1,30 @@ import { expect, test } from "bun:test"; -import { renderDocument, renderDocumentStream } from "../src/index.ts"; +import { renderDocument, renderDocumentStream, renderStoreHydration } from "../src/index.ts"; +import type { StoreContainer } from "@wrnexus/store"; + +test("store hydration is HTML-safe, bounded, redacted, and JSON-compatible", () => { + const container = { + serialize: () => ({ + ProfileStore: { + display: "", + accessToken: "never-render-this", + }, + }), + } as unknown as StoreContainer; + const html = renderStoreHydration(container, 'safe" onload="bad'); + expect(html).not.toContain("", accessToken: "[REDACTED]" }, + }); + + const oversized = { + serialize: () => ({ value: "x".repeat(300_000) }), + } as unknown as StoreContainer; + expect(() => renderStoreHydration(oversized)).toThrow("exceeds 262144 bytes"); +}); test("escapes metadata and script URLs while preserving trusted rendered body", () => { const html = renderDocument({ diff --git a/packages/store/README.md b/packages/store/README.md new file mode 100644 index 00000000..b3836c04 --- /dev/null +++ b/packages/store/README.md @@ -0,0 +1,5 @@ +# @wrnexus/store + +Typed global and page-scoped WRNexusJS stores with runtime-specific state, computed values, actions, lifecycle hooks, persistence, SSR isolation, and HMR support. + +Use `defineStore()` to declare a store and `createStoreContainer()` to create an isolated request or browser container. Store definitions are framework helpers and do not require UI components. diff --git a/packages/store/package.json b/packages/store/package.json index 9be4f6d6..27e15245 100644 --- a/packages/store/package.json +++ b/packages/store/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/store", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/styles/README.md b/packages/styles/README.md index 4037b990..bb7709ab 100644 --- a/packages/styles/README.md +++ b/packages/styles/README.md @@ -1,5 +1,26 @@ # @wrnexus/styles +## Reusable layers and presets + +Compose local or package foundations in order; later layers override earlier +ones and the application has final base-config precedence: + +```ts +export default defineConfig({ + extends: ["@workroot/wrnexus-enterprise", "./layers/company"], + profiles: { production: { port: 8080 } }, +}); +``` + +A directory layer exports `wrnexus.layer.ts` (JavaScript/MJS are supported). +A package can provide that conventional file or declare +`wrnexus.layer` in its `package.json`. Layers may extend other layers and carry +the complete app configuration, including plugins that contribute layouts, +components, routes, middleware, and migrations. `plugins` and `head` compose; +other arrays intentionally replace earlier values. Cycles and missing/invalid +entries fail with stable `WRN-CONFIG-LAYER-*` diagnostics. `wrnexus config +--explain` lists every resolved layer source. + > Global CSS bundling, the `--wire-*` design-token theme system, and the `wrnexus.config.ts` app-config loader for WrNexus apps. Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework. diff --git a/packages/styles/package.json b/packages/styles/package.json index 5edf3c49..ef6bf09e 100644 --- a/packages/styles/package.json +++ b/packages/styles/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/styles", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/styles/src/compatibility.ts b/packages/styles/src/compatibility.ts new file mode 100644 index 00000000..0722fb6c --- /dev/null +++ b/packages/styles/src/compatibility.ts @@ -0,0 +1,59 @@ +export const CURRENT_COMPATIBILITY_DATE = "2026-08-02"; +export const CURRENT_FRAMEWORK_BEHAVIOUR = 1; + +export interface CompatibilityPolicy { + compatibilityDate?: string; + frameworkBehaviour?: number; +} + +export interface CompatibilityReport { + configuredDate?: string; + effectiveDate: string; + currentDate: string; + configuredBehaviour?: number; + effectiveBehaviour: number; + currentBehaviour: number; + needsUpgrade: boolean; + future: boolean; + messages: string[]; +} + +export function isCompatibilityDate(value: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + const date = new Date(`${value}T00:00:00.000Z`); + return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value; +} + +export function resolveCompatibility(policy: CompatibilityPolicy): CompatibilityReport { + const configuredDate = policy.compatibilityDate; + const configuredBehaviour = policy.frameworkBehaviour; + const effectiveDate = configuredDate ?? "1970-01-01"; + const effectiveBehaviour = configuredBehaviour ?? 0; + const future = + (configuredDate !== undefined && configuredDate > CURRENT_COMPATIBILITY_DATE) || + (configuredBehaviour !== undefined && configuredBehaviour > CURRENT_FRAMEWORK_BEHAVIOUR); + const needsUpgrade = + !future && + (effectiveDate < CURRENT_COMPATIBILITY_DATE || + effectiveBehaviour < CURRENT_FRAMEWORK_BEHAVIOUR); + const messages: string[] = []; + if (!configuredDate) messages.push("compatibilityDate is not configured; legacy defaults apply."); + if (!configuredBehaviour) + messages.push("frameworkBehaviour is not configured; behaviour version 0 applies."); + if (future) + messages.push("Configuration targets framework behavior newer than this CLI supports."); + else if (needsUpgrade) + messages.push("A newer compatibility policy is available; review it before upgrading."); + else messages.push("Compatibility policy matches the current framework behavior."); + return { + configuredDate, + effectiveDate, + currentDate: CURRENT_COMPATIBILITY_DATE, + configuredBehaviour, + effectiveBehaviour, + currentBehaviour: CURRENT_FRAMEWORK_BEHAVIOUR, + needsUpgrade, + future, + messages, + }; +} diff --git a/packages/styles/src/config.ts b/packages/styles/src/config.ts index a898cfb2..5ad0f4f1 100644 --- a/packages/styles/src/config.ts +++ b/packages/styles/src/config.ts @@ -8,14 +8,20 @@ */ import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, extname, join, resolve } from "node:path"; +import { createRequire } from "node:module"; import { pathToFileURL } from "node:url"; import type { PerformanceBudgets, SecurityConfig, SeoConfig } from "@wrnexus/core"; -import type { PluginInput } from "@wrnexus/plugin"; +import type { PluginInput, PluginPermission } from "@wrnexus/plugin"; import type { StorageConfig } from "@wrnexus/uploader"; import type { ThemeConfig } from "./theme.ts"; import type { FontConfig } from "./fonts.ts"; import { fontCspSources } from "./fonts.ts"; +import { + isCompatibilityDate, + resolveCompatibility, + type CompatibilityPolicy, +} from "./compatibility.ts"; export type Mode = "development" | "production"; @@ -120,6 +126,15 @@ export interface PwaConfig { cacheUrls?: string[]; /** Service-worker cache key. Change it to invalidate existing PWA caches. */ cacheName?: string; + /** Ordered URL rules for runtime caching. Patterns are regular-expression source strings. */ + runtimeCaching?: Array<{ + pattern: string; + strategy: "network-first" | "cache-first" | "stale-while-revalidate"; + cacheName?: string; + methods?: string[]; + }>; + /** Background Sync tag used by the offline mutation queue. */ + backgroundSyncTag?: string; } export type DevToolbarPosition = "bottom-center" | "bottom-left" | "bottom-right"; @@ -229,9 +244,16 @@ export interface CompatibilityConfig { stringLayouts?: boolean; } -export interface AppConfig { +export interface AppConfig extends CompatibilityPolicy { + /** Ordered reusable configuration layers; the application always has final precedence. */ + extends?: string | string[]; /** Compiler/dev/build plugins, resolved in deterministic pre/normal/post order. */ plugins?: PluginInput; + /** Optional least-privilege enforcement for automatically discovered packages. */ + pluginPermissions?: { + enforce?: boolean; + grants?: Record; + }; /** WRN v0.6 explicit import and compatibility resolution. */ imports?: ImportsConfig; /** TypeScript-backed .wrn type checking and declaration generation. */ @@ -276,7 +298,21 @@ export interface AppConfig { /** Design-token themes (deep-merged over the built-in light/dark). */ theme?: ThemeConfig; /** i18n: default language + supported locales (strings live in app/locales/*.json). */ - i18n?: { default?: string; locales?: string[] }; + i18n?: { + default?: string; + locales?: string[]; + labels?: Record; + fallbacks?: Record; + direction?: Record; + cookie?: { + name?: string; + maxAge?: number; + path?: string; + sameSite?: "Strict" | "Lax" | "None"; + secure?: boolean; + }; + strict?: boolean; + }; /** Default database connection (driver + url); reached with `getDb()`. */ db?: { driver: "sqlite" | "postgres" | "mysql" | "mongo"; url: string }; /** @@ -330,15 +366,116 @@ function isPlainObject(value: unknown): value is Record { } /** Deep-merge `override` onto `base` (objects merge; arrays/scalars replace). */ -function deepMerge(base: T, override: unknown): T { +function deepMerge(base: T, override: unknown, path = ""): T { if (!isPlainObject(base) || !isPlainObject(override)) return (override ?? base) as T; const out: Record = { ...base }; for (const [key, value] of Object.entries(override)) { - out[key] = key in out ? deepMerge(out[key], value) : value; + const currentPath = path ? `${path}.${key}` : key; + if ( + key in out && + ["plugins", "head"].includes(currentPath) && + (Array.isArray(out[key]) || Array.isArray(value)) + ) { + const before = Array.isArray(out[key]) ? out[key] : out[key] == null ? [] : [out[key]]; + const after = Array.isArray(value) ? value : value == null ? [] : [value]; + out[key] = [...before, ...after]; + } else { + out[key] = key in out ? deepMerge(out[key], value, currentPath) : value; + } } return out as T; } +const LAYER_CONFIG_NAMES = [ + "wrnexus.layer.ts", + "wrnexus.layer.js", + "wrnexus.layer.mjs", + ...CONFIG_NAMES, +]; + +function layerSpecifiers(config: AppConfig): string[] { + return config.extends ? (Array.isArray(config.extends) ? config.extends : [config.extends]) : []; +} + +function resolveLayerFile(specifier: string, declaringRoot: string, appRoot: string): string { + const local = + specifier.startsWith(".") || specifier.startsWith("/") || /^[A-Za-z]:[\\/]/.test(specifier); + if (local) { + const candidate = resolve(declaringRoot, specifier); + if (extname(candidate) && existsSync(candidate)) return candidate; + for (const name of LAYER_CONFIG_NAMES) { + const file = join(candidate, name); + if (existsSync(file)) return file; + } + throw new Error(`WRN-CONFIG-LAYER-NOT-FOUND: ${specifier} from ${declaringRoot}.`); + } + const require = createRequire(join(appRoot, "package.json")); + try { + const packageFile = require.resolve(`${specifier}/package.json`); + const packageRoot = dirname(packageFile); + const pkg = JSON.parse(readFileSync(packageFile, "utf8")) as { + wrnexus?: { layer?: string }; + }; + if (pkg.wrnexus?.layer) { + const file = resolve(packageRoot, pkg.wrnexus.layer); + if (existsSync(file)) return file; + } + for (const name of LAYER_CONFIG_NAMES) { + const file = join(packageRoot, name); + if (existsSync(file)) return file; + } + } catch (error) { + throw new Error(`WRN-CONFIG-LAYER-NOT-FOUND: package ${specifier} from ${appRoot}.`, { + cause: error, + }); + } + throw new Error(`WRN-CONFIG-LAYER-ENTRY: ${specifier} has no wrnexus layer entry.`); +} + +async function importConfigFile(file: string): Promise { + const mod = (await import(pathToFileURL(file).href)) as { default?: AppConfig }; + if (!mod.default || !isPlainObject(mod.default)) { + throw new Error(`WRN-CONFIG-LAYER-SHAPE: ${file} must export a configuration object.`); + } + return mod.default; +} + +export interface ResolvedConfigLayers { + config: AppConfig; + sources: string[]; +} + +export async function resolveConfigLayers( + appRoot: string, + application: AppConfig, +): Promise { + const sources: string[] = []; + const visiting: string[] = []; + const resolvedFiles = new Set(); + const visit = async (config: AppConfig, declaringRoot: string): Promise => { + let merged: AppConfig = {}; + for (const specifier of layerSpecifiers(config)) { + const file = resolveLayerFile(specifier, declaringRoot, appRoot); + if (visiting.includes(file)) { + throw new Error(`WRN-CONFIG-LAYER-CYCLE: ${[...visiting, file].join(" -> ")}`); + } + visiting.push(file); + const layer = await importConfigFile(file); + const resolvedLayer = await visit(layer, dirname(file)); + visiting.pop(); + merged = deepMerge(merged, resolvedLayer); + if (!resolvedFiles.has(file)) { + resolvedFiles.add(file); + sources.push(file); + } + } + const own = { ...config }; + delete own.extends; + return deepMerge(merged, own); + }; + return { config: await visit(application, appRoot), sources }; +} + /** Load the raw `wrnexus.config.*` (with the `profiles` map intact), or `{}`. */ export async function loadRawConfig(appRoot: string): Promise { for (const name of CONFIG_NAMES) { @@ -358,10 +495,12 @@ export async function loadAppConfig(appRoot: string, profile?: string): Promise< // module evaluation. Load the profile cascade before importing the module so // development and production behave consistently. loadEnv(appRoot, active); - const base = await loadRawConfig(appRoot); + const raw = await loadRawConfig(appRoot); + const { config: base } = await resolveConfigLayers(appRoot, raw); const override = base.profiles?.[active]; const merged: AppConfig = override ? deepMerge(base, override) : { ...base }; delete merged.profiles; + delete merged.extends; applyFontCsp(merged); const issues = validateAppConfig(merged); const errors = issues.filter((issue) => issue.severity === "error"); @@ -451,14 +590,66 @@ export function defineConfig(config: AppConfig): AppConfig { export function validateAppConfig(config: AppConfig): ConfigIssue[] { const issues: ConfigIssue[] = []; + if (config.compatibilityDate !== undefined && !isCompatibilityDate(config.compatibilityDate)) { + issues.push({ + path: "compatibilityDate", + severity: "error", + message: "must be a real ISO calendar date in YYYY-MM-DD format", + }); + } + if ( + config.frameworkBehaviour !== undefined && + (!Number.isInteger(config.frameworkBehaviour) || config.frameworkBehaviour < 1) + ) { + issues.push({ + path: "frameworkBehaviour", + severity: "error", + message: "must be a positive integer", + }); + } + const compatibility = resolveCompatibility(config); + if (compatibility.future) { + issues.push({ + path: "compatibilityDate", + severity: "error", + message: "targets framework behavior newer than this version supports", + }); + } const sampleRate = config.observability?.sampleRate; - if (sampleRate !== undefined && (sampleRate < 0 || sampleRate > 1)) { + if ( + sampleRate !== undefined && + (!Number.isFinite(sampleRate) || sampleRate < 0 || sampleRate > 1) + ) { issues.push({ path: "observability.sampleRate", severity: "error", message: "must be between 0 and 1", }); } + if (config.observability?.exporter === "otlp") { + const endpoint = config.observability.endpoint; + const validEndpoint = (() => { + try { + return Boolean(endpoint && ["http:", "https:"].includes(new URL(endpoint).protocol)); + } catch { + return false; + } + })(); + if (!validEndpoint) { + issues.push({ + path: "observability.endpoint", + severity: "error", + message: "must be an absolute HTTP(S) URL when exporter is otlp", + }); + } + } + if (config.observability?.serviceName !== undefined && !config.observability.serviceName.trim()) { + issues.push({ + path: "observability.serviceName", + severity: "error", + message: "must not be empty", + }); + } const budgets = config.performance?.budgets; if (budgets) { for (const [name, value] of Object.entries(budgets)) { @@ -494,7 +685,9 @@ export async function explainAppConfig( ): Promise { const active = profile ?? resolveProfile(); const config = await loadAppConfig(appRoot, active); - const sources = CONFIG_NAMES.filter((name) => existsSync(join(appRoot, name))); + const appSources = CONFIG_NAMES.filter((name) => existsSync(join(appRoot, name))); + const raw = await loadRawConfig(appRoot); + const layerSources = (await resolveConfigLayers(appRoot, raw)).sources; const envSources = [".env", ".env.local", `.env.${active}`, `.env.${active}.local`].filter( (name) => existsSync(join(appRoot, name)), ); @@ -502,7 +695,7 @@ export async function explainAppConfig( profile: active, config, issues: validateAppConfig(config), - sources: [...sources, ...envSources], + sources: [...layerSources, ...appSources, ...envSources], }; } diff --git a/packages/styles/src/index.ts b/packages/styles/src/index.ts index 8715b4b5..d771a07b 100644 --- a/packages/styles/src/index.ts +++ b/packages/styles/src/index.ts @@ -23,6 +23,7 @@ export type { StylesConfig, StyleProcessContext, Mode, + ResolvedConfigLayers, } from "./config.ts"; export { defineConfig, @@ -32,8 +33,16 @@ export { loadEnv, loadRawConfig, resolveProfile, + resolveConfigLayers, validateAppConfig, } from "./config.ts"; +export { + CURRENT_COMPATIBILITY_DATE, + CURRENT_FRAMEWORK_BEHAVIOUR, + isCompatibilityDate, + resolveCompatibility, +} from "./compatibility.ts"; +export type { CompatibilityPolicy, CompatibilityReport } from "./compatibility.ts"; export { findStyleEntry, bundleCss } from "./styles.ts"; export type { FontConfig, GoogleFont, LocalFontFace, FontDisplay } from "./fonts.ts"; export { renderFontHead, renderProductionFontHead, fontCspSources } from "./fonts.ts"; @@ -43,6 +52,8 @@ export type { ResolvedTheme, ThemePaletteName, CustomThemePalette, + ThemeToken, + ThemeSemanticColor, } from "./theme.ts"; export { DEFAULT_THEMES, @@ -55,6 +66,8 @@ export { resolveThemeName, renderThemeCss, renderThemeRuntime, + defineThemeTokens, + themeVar, } from "./theme.ts"; import type { Mode, StyleProcessContext, StylesConfig } from "./config.ts"; diff --git a/packages/styles/src/theme.ts b/packages/styles/src/theme.ts index bb88c5c6..fc9d626d 100644 --- a/packages/styles/src/theme.ts +++ b/packages/styles/src/theme.ts @@ -11,7 +11,52 @@ * emitted as the native CSS property instead of a custom property. */ -export type ThemeTokens = Record; +export type ThemeSemanticColor = + "primary" | "secondary" | "info" | "success" | "warning" | "danger" | "error"; +export type ThemeToken = + | "color-scheme" + | "color-bg" + | "color-background" + | "color-foreground" + | "color-surface" + | "color-surface-2" + | "color-surface-raised" + | "color-surface-muted" + | "color-text" + | "color-text-muted" + | "color-text-subtle" + | "color-muted" + | "color-border" + | "color-border-strong" + | "color-code-background" + | "color-code-surface" + | "color-code-text" + | "color-code-muted" + | "color-code-border" + | `color-${ThemeSemanticColor}` + | `color-${ThemeSemanticColor}-${"hover" | "active" | "contrast" | "soft" | "muted" | "text"}` + | `color-on-${"primary" | "secondary"}` + | "radius" + | "radius-sm" + | "shadow-1" + | "shadow-sm" + | "shadow-md" + | "shadow-lg" + | "space-section" + | "space-section-sm" + | "container-max" + | "font-sans"; + +/** Known tokens get autocomplete while applications may add namespaced custom tokens. */ +export type ThemeTokens = Partial> & Record; + +export function defineThemeTokens(tokens: T): T { + return tokens; +} + +export function themeVar(token: ThemeToken, fallback?: string): string { + return `var(--wire-${token}${fallback ? `, ${fallback}` : ""})`; +} export const THEME_PALETTE_NAMES = [ "blue", diff --git a/packages/styles/test/config.test.ts b/packages/styles/test/config.test.ts index b8962ffb..618714ee 100644 --- a/packages/styles/test/config.test.ts +++ b/packages/styles/test/config.test.ts @@ -2,12 +2,50 @@ import { test, expect, afterEach } from "bun:test"; import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { loadAppConfig, resolveProfile, loadEnv, renderStyles } from "../src/index.ts"; +import { + loadAppConfig, + resolveProfile, + loadEnv, + renderStyles, + resolveCompatibility, + validateAppConfig, + explainAppConfig, +} from "../src/index.ts"; afterEach(() => { delete process.env.WRNEXUS_PROFILE; }); +test("compatibility dates pin behavior and reject invalid or future policies", () => { + expect( + resolveCompatibility({ compatibilityDate: "2026-08-02", frameworkBehaviour: 1 }), + ).toMatchObject({ needsUpgrade: false, future: false, effectiveBehaviour: 1 }); + expect(validateAppConfig({ compatibilityDate: "2026-02-31" })).toContainEqual( + expect.objectContaining({ path: "compatibilityDate", severity: "error" }), + ); + expect(validateAppConfig({ frameworkBehaviour: 2 })).toContainEqual( + expect.objectContaining({ severity: "error" }), + ); +}); + +test("observability config requires bounded sampling and a valid OTLP endpoint", () => { + expect(validateAppConfig({ observability: { sampleRate: Number.NaN } })).toContainEqual( + expect.objectContaining({ path: "observability.sampleRate", severity: "error" }), + ); + expect( + validateAppConfig({ observability: { exporter: "otlp", endpoint: "collector:4318" } }), + ).toContainEqual(expect.objectContaining({ path: "observability.endpoint", severity: "error" })); + expect( + validateAppConfig({ + observability: { + exporter: "otlp", + endpoint: "https://collector.example/v1/traces", + serviceName: "api", + }, + }), + ).toEqual([]); +}); + test("resolveProfile: explicit > WRNEXUS_PROFILE > mode default", () => { delete process.env.WRNEXUS_PROFILE; expect(resolveProfile({ mode: "development" })).toBe("development"); @@ -47,6 +85,77 @@ test("loadAppConfig deep-merges the active profile and strips `profiles`", async expect(uat.db!.driver).toBe("sqlite"); }); +test("config layers compose recursively with deterministic application precedence", async () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-layers-")); + try { + mkdirSync(join(root, "layers", "base"), { recursive: true }); + mkdirSync(join(root, "layers", "company"), { recursive: true }); + writeFileSync( + join(root, "layers", "base", "wrnexus.layer.mjs"), + `export default { + port: 1000, head: [""], seo: { siteName: "Foundation", title: "Base" }, + profiles: { production: { port: 7000 } } + }`, + ); + writeFileSync( + join(root, "layers", "company", "wrnexus.layer.mjs"), + `export default { + extends: ["../base"], head: "", seo: { title: "Company" } + }`, + ); + writeFileSync( + join(root, "wrnexus.config.mjs"), + `export default { + extends: ["./layers/company"], port: 3000, head: [""], seo: { title: "App" }, + profiles: { production: { port: 8000 } } + }`, + ); + + const config = await loadAppConfig(root, "production"); + expect(config.port).toBe(8000); + expect((await loadAppConfig(root, "development")).port).toBe(3000); + expect(config.seo).toEqual({ siteName: "Foundation", title: "App" }); + expect(config.head).toEqual([ + "", + "", + "", + ]); + expect(config.extends).toBeUndefined(); + const explained = await explainAppConfig(root, "production"); + expect(explained.sources.filter((source) => source.includes("wrnexus.layer")).length).toBe(2); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("config layers resolve packages and reject dependency cycles", async () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-package-layer-")); + try { + const pkg = join(root, "node_modules", "company-layer"); + mkdirSync(pkg, { recursive: true }); + writeFileSync(join(root, "package.json"), JSON.stringify({ name: "app" })); + writeFileSync( + join(pkg, "package.json"), + JSON.stringify({ + name: "company-layer", + wrnexus: { layer: "./foundation.mjs" }, + }), + ); + writeFileSync(join(pkg, "foundation.mjs"), `export default { port: 4400 }`); + writeFileSync(join(root, "wrnexus.config.mjs"), `export default { extends: "company-layer" }`); + expect((await loadAppConfig(root)).port).toBe(4400); + + const cycle = join(root, "cycle"); + mkdirSync(cycle); + writeFileSync(join(cycle, "a.mjs"), `export default { extends: "./b.mjs" }`); + writeFileSync(join(cycle, "b.mjs"), `export default { extends: "./a.mjs" }`); + writeFileSync(join(cycle, "wrnexus.config.mjs"), `export default { extends: "./a.mjs" }`); + await expect(loadAppConfig(cycle)).rejects.toThrow("WRN-CONFIG-LAYER-CYCLE"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("loadEnv layers .env files by precedence; real env always wins", () => { const dir = mkdtempSync(join(tmpdir(), "wire-env-")); writeFileSync(join(dir, ".env"), 'BASE=1\nSHARED=base\n# a comment\nQUOTED="hi there"\n'); diff --git a/packages/styles/test/theme-tokens.test.ts b/packages/styles/test/theme-tokens.test.ts new file mode 100644 index 00000000..e22e45f9 --- /dev/null +++ b/packages/styles/test/theme-tokens.test.ts @@ -0,0 +1,12 @@ +import { expect, test } from "bun:test"; +import { defineThemeTokens, themeVar } from "../src/index.ts"; + +test("typed theme token helpers preserve custom tokens and emit CSS variables", () => { + const tokens = defineThemeTokens({ + "color-primary": "#2563eb", + "space-product-card": "1rem", + }); + expect(tokens["space-product-card"]).toBe("1rem"); + expect(themeVar("color-primary")).toBe("var(--wire-color-primary)"); + expect(themeVar("color-text", "#111")).toBe("var(--wire-color-text, #111)"); +}); diff --git a/packages/syntax/package.json b/packages/syntax/package.json index 56fa28c0..6e02a930 100644 --- a/packages/syntax/package.json +++ b/packages/syntax/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/syntax", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "main": "src/index.ts", "exports": { @@ -9,6 +9,7 @@ "./tokenizer": "./src/tokenizer.ts", "./types": "./src/types.ts", "./diagnostics": "./src/diagnostics.ts", - "./spec": "./src/spec.ts" + "./spec": "./src/spec.ts", + "./formatter": "./src/formatter.ts" } } diff --git a/packages/syntax/src/formatter.ts b/packages/syntax/src/formatter.ts new file mode 100644 index 00000000..470a5518 --- /dev/null +++ b/packages/syntax/src/formatter.ts @@ -0,0 +1,790 @@ +// The formatter intentionally operates on partially written source. Its small +// scanner values are dynamically shaped, while the public API below remains typed. +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck + +export interface FormatWrnOptions { + insertSpaces?: boolean; + tabSize?: number; + printWidth?: number; + multilineAttributes?: boolean; +} + +const VOID_ELEMENTS = new Set([ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "param", + "source", + "track", + "wbr", +]); + +function splitPropDeclarations(value) { + const declarations = []; + let start = 0; + let index = 0; + let quote = null; + let escaped = false; + let square = 0; + let brace = 0; + let paren = 0; + let segmentHasColon = false; + let segmentHasEquals = false; + + const isIdentifierStart = (character) => /[A-Za-z_]/.test(character || ""); + const isIdentifierPart = (character) => /[A-Za-z0-9_]/.test(character || ""); + + const beginsDeclaration = (position) => { + let cursor = position; + while (cursor < value.length && /[ \t]/.test(value[cursor])) cursor += 1; + if (value.slice(cursor).startsWith("@event")) { + cursor += "@event".length; + if (!/\s/.test(value[cursor] || "")) return false; + while (cursor < value.length && /\s/.test(value[cursor])) cursor += 1; + if (!isIdentifierStart(value[cursor])) return false; + cursor += 1; + while (cursor < value.length && isIdentifierPart(value[cursor])) cursor += 1; + while (cursor < value.length && /[ \t]/.test(value[cursor])) cursor += 1; + return value[cursor] === "=" ? "=" : null; + } + if (!isIdentifierStart(value[cursor])) return false; + cursor += 1; + while (cursor < value.length && isIdentifierPart(value[cursor])) cursor += 1; + if (value[cursor] === "?") cursor += 1; + while (cursor < value.length && /[ \t]/.test(value[cursor])) cursor += 1; + return value[cursor] === "=" || value[cursor] === ":" ? value[cursor] : null; + }; + + while (index < value.length) { + const character = value[index]; + + if (quote !== null) { + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === quote) quote = null; + index += 1; + continue; + } + + if (character === '"' || character === "'" || character === "`") { + quote = character; + index += 1; + continue; + } + + if (character === "[") square += 1; + else if (character === "]" && square > 0) square -= 1; + else if (character === "{") brace += 1; + else if (character === "}" && brace > 0) brace -= 1; + else if (character === "(") paren += 1; + else if (character === ")" && paren > 0) paren -= 1; + + const topLevel = square === 0 && brace === 0 && paren === 0; + if (topLevel && character === ":") segmentHasColon = true; + if (topLevel && character === "=") segmentHasEquals = true; + + const candidateDelimiter = topLevel && /\s/.test(character) ? beginsDeclaration(index) : null; + const beginsNext = + candidateDelimiter === ":" || + (candidateDelimiter === "=" && (segmentHasEquals || !segmentHasColon)); + + if ( + topLevel && + /\s/.test(character) && + value.slice(start, index).trim() !== "@event" && + beginsNext + ) { + const declaration = value.slice(start, index).trim(); + if (declaration) declarations.push(declaration); + while (index < value.length && /\s/.test(value[index])) index += 1; + start = index; + segmentHasColon = false; + segmentHasEquals = false; + continue; + } + + index += 1; + } + + const declaration = value.slice(start).trim(); + if (declaration) declarations.push(declaration); + return declarations; +} + +function splitOutputDeclarations(value) { + const declarations = []; + let start = 0; + let paren = 0; + let angle = 0; + let square = 0; + let quote = null; + let escaped = false; + const startsOutput = (position) => { + let cursor = position; + while (cursor < value.length && /\s/.test(value[cursor])) cursor += 1; + if (!/[A-Za-z_$]/.test(value[cursor] || "")) return false; + cursor += 1; + while (cursor < value.length && /[A-Za-z0-9_$]/.test(value[cursor] || "")) cursor += 1; + while (cursor < value.length && /\s/.test(value[cursor])) cursor += 1; + return value[cursor] === "("; + }; + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + if (quote !== null) { + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === quote) quote = null; + continue; + } + if (character === '"' || character === "'" || character === "`") { + quote = character; + continue; + } + if (character === "(") paren += 1; + else if (character === ")" && paren > 0) paren -= 1; + else if (character === "[") square += 1; + else if (character === "]" && square > 0) square -= 1; + else if (character === "<") angle += 1; + else if (character === ">" && angle > 0) angle -= 1; + if (paren === 0 && square === 0 && angle === 0 && /\s/.test(character) && startsOutput(index)) { + const declaration = value.slice(start, index).trim(); + if (declaration) declarations.push(declaration); + while (index < value.length && /\s/.test(value[index])) index += 1; + start = index; + index -= 1; + } + } + const finalDeclaration = value.slice(start).trim(); + if (finalDeclaration) declarations.push(finalDeclaration); + return declarations; +} + +function formatInlineDeclarationBlock(value, unit, depth) { + const match = /^(props|state|computed|outputs)\s*\{([\s\S]*)\}$/.exec(value.trim()); + if (!match) return null; + const declarations = + match[1] === "outputs" + ? splitOutputDeclarations(match[2].trim()) + : splitPropDeclarations(match[2].trim()); + return [ + `${unit.repeat(depth)}${match[1]} {`, + ...declarations.map((declaration) => `${unit.repeat(depth + 1)}${declaration}`), + `${unit.repeat(depth)}}`, + ]; +} + +function formatInlinePropsBlock(value, unit, depth) { + const match = /^props\s*\{([\s\S]*)\}$/.exec(value.trim()); + if (!match) return null; + + const declarations = splitPropDeclarations(match[1].trim()); + if (declarations.length === 0) { + return [`${unit.repeat(depth)}props {`, `${unit.repeat(depth)}}`]; + } + + return [ + `${unit.repeat(depth)}props {`, + ...declarations.map((declaration) => `${unit.repeat(depth + 1)}${declaration}`), + `${unit.repeat(depth)}}`, + ]; +} + +function findOpeningTagEnd(value) { + let quote = null; + let escaped = false; + + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + + if (quote !== null) { + if (escaped) { + escaped = false; + continue; + } + + if (character === "\\") { + escaped = true; + continue; + } + + if (character === quote) { + quote = null; + } + + continue; + } + + if (character === '"' || character === "'") { + quote = character; + continue; + } + + if (character === ">") { + return index; + } + } + + return -1; +} + +function parseAttributes(value) { + const attributes = []; + let index = 0; + + while (index < value.length) { + while (index < value.length && /\s/.test(value[index])) index += 1; + if (index >= value.length) break; + + const start = index; + while (index < value.length && !/[\s=]/.test(value[index])) index += 1; + while (index < value.length && /\s/.test(value[index])) index += 1; + + if (value[index] === "=") { + index += 1; + while (index < value.length && /\s/.test(value[index])) index += 1; + + const quote = value[index]; + if (quote === '"' || quote === "'") { + index += 1; + let escaped = false; + while (index < value.length) { + const character = value[index++]; + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === quote) break; + } + } else if (value[index] === "{") { + let depth = 0; + let expressionQuote = null; + let escaped = false; + while (index < value.length) { + const character = value[index++]; + if (expressionQuote !== null) { + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === expressionQuote) expressionQuote = null; + continue; + } + if (character === '"' || character === "'" || character === "`") { + expressionQuote = character; + } else if (character === "{") { + depth += 1; + } else if (character === "}" && --depth === 0) { + break; + } + } + } else { + while (index < value.length && !/\s/.test(value[index])) index += 1; + } + } + + const attribute = value.slice(start, index).trim(); + if (attribute) attributes.push(attribute); + } + + return attributes; +} + +function parseStructuredAttribute(attribute) { + const match = /^([^\s=]+)\s*=\s*\{([\s\S]*)\}$/.exec(attribute); + if (!match) return null; + + const expression = match[2].trim(); + if (!expression.startsWith("[") && !expression.startsWith("{")) return null; + + try { + return { + name: match[1], + value: JSON.parse(expression), + }; + } catch { + return null; + } +} + +function formatAttribute(attribute, indentation, unit) { + const structured = parseStructuredAttribute(attribute); + if (!structured) return [`${indentation}${attribute}`]; + + const jsonLines = JSON.stringify(structured.value, null, unit).split("\n"); + if (jsonLines.length === 1) { + return [`${indentation}${structured.name}={${jsonLines[0]}}`]; + } + + return [ + `${indentation}${structured.name}={${jsonLines[0]}`, + ...jsonLines.slice(1, -1).map((line) => `${indentation}${line}`), + `${indentation}${jsonLines.at(-1)}}`, + ]; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function parseOpeningTag(value) { + const endIndex = findOpeningTagEnd(value); + + if (endIndex === -1) { + return null; + } + + const openingPart = value.slice(0, endIndex + 1); + const remainder = value.slice(endIndex + 1).trim(); + + const match = /^<([A-Za-z][\w$:.-]*)([\s\S]*?)(\/?)>$/.exec(openingPart); + + if (!match) { + return null; + } + + const tagName = match[1]; + const attributes = parseAttributes(match[2].trim()); + const selfClosing = match[3] === "/"; + + const escapedTagName = escapeRegExp(tagName); + + const immediateClosing = new RegExp(`^<\\/${escapedTagName}\\s*>`, "i").test(remainder); + + const trailingClosingMatch = new RegExp(`^([\\s\\S]*?)<\\/${escapedTagName}\\s*>$`, "i").exec( + remainder, + ); + + const trailingClosing = trailingClosingMatch !== null; + + const inlineContent = trailingClosing ? trailingClosingMatch[1].trim() : ""; + + const inlineClosing = trailingClosing && inlineContent.length === 0; + + const closesInRemainder = immediateClosing || trailingClosing; + + return { + tagName, + attributes, + selfClosing, + inlineClosing, + immediateClosing, + trailingClosing, + inlineContent, + closesInRemainder, + remainder, + }; +} + +function formatOpeningTag(value, unit, depth, printWidth = 100, multilineAttributes = true) { + const parsed = parseOpeningTag(value); + + if (!parsed) { + return { + lines: [`${unit.repeat(depth)}${value.trim()}`], + opensElement: false, + }; + } + + const baseIndent = unit.repeat(depth); + const childIndent = unit.repeat(depth + 1); + + const normalizedOpening = + `<${parsed.tagName}` + + `${parsed.attributes.length ? ` ${parsed.attributes.join(" ")}` : ""}` + + `${parsed.selfClosing ? " /" : ""}>`; + + const normalizedSingleLine = `${normalizedOpening}${parsed.remainder}`; + + const shouldBreak = + value.includes("\n") || + (multilineAttributes && parsed.attributes.length > 0) || + baseIndent.length + normalizedSingleLine.length > printWidth; + + const opensElement = + !parsed.selfClosing && + !parsed.closesInRemainder && + !VOID_ELEMENTS.has(parsed.tagName.toLowerCase()); + + if (!shouldBreak) { + return { + lines: [`${baseIndent}${normalizedSingleLine}`], + opensElement, + }; + } + + const lines = [ + `${baseIndent}<${parsed.tagName}`, + ...parsed.attributes.flatMap((attribute) => formatAttribute(attribute, childIndent, unit)), + ]; + + if (parsed.selfClosing) { + lines.push(`${baseIndent}/>`); + return { + lines, + opensElement, + }; + } + + lines.push(`${baseIndent}>`); + + if (parsed.trailingClosing) { + if (parsed.inlineContent) { + lines.push(`${childIndent}${parsed.inlineContent}`); + } + + lines.push(`${baseIndent}`); + } else if (parsed.remainder) { + lines.push(`${parsed.immediateClosing ? baseIndent : childIndent}${parsed.remainder}`); + } + + return { + lines, + opensElement, + }; +} + +function isMultilineOpeningTagStart(value) { + if (!value.startsWith("<")) { + return false; + } + + if ( + value.startsWith("/.test(value); +} + +function isControlBlockOpen(value) { + return /^\{#(?:if|each)\b[\s\S]*\}$/.test(value); +} + +function isControlBlockMiddle(value) { + return /^\{:(?:else(?:\s+if\b[\s\S]*)?|empty)\}$/.test(value); +} + +function isControlBlockClose(value) { + return /^\{\/(?:if|each)\}$/.test(value); +} + +function countLeadingClosingBraces(value) { + let index = 0; + let count = 0; + + while (index < value.length) { + while (index < value.length && /\s/.test(value[index])) { + index += 1; + } + + if (value[index] !== "}" && value[index] !== "]") { + break; + } + + count += 1; + index += 1; + } + + return count; +} + +/** + * Count braces outside strings and HTML comments. + * + * This supports WRN blocks, function bodies, lifecycle hooks, + * watcher bodies and multiline JavaScript object literals. + */ +function countStructuralBraces(value) { + let openings = 0; + let closings = 0; + + let quote = null; + let escaped = false; + let htmlComment = false; + + for (let index = 0; index < value.length; index += 1) { + if (!quote && !htmlComment && value.startsWith("", index)) { + htmlComment = false; + index += 2; + continue; + } + + if (htmlComment) { + continue; + } + + const character = value[index]; + + if (quote !== null) { + if (escaped) { + escaped = false; + continue; + } + + if (character === "\\") { + escaped = true; + continue; + } + + if (character === quote) { + quote = null; + } + + continue; + } + + if (character === '"' || character === "'" || character === "`") { + quote = character; + continue; + } + + if (character === "{") { + openings += 1; + } else if (character === "}") { + closings += 1; + } else if (character === "[") { + openings += 1; + } else if (character === "]") { + closings += 1; + } + } + + return { + openings, + closings, + }; +} + +function collectOpeningTag(inputLines, startIndex) { + const collected = [inputLines[startIndex].trim()]; + + let index = startIndex; + + while (index + 1 < inputLines.length) { + const joined = collected.join(" "); + + if (findOpeningTagEnd(joined) !== -1) { + break; + } + + index += 1; + collected.push(inputLines[index].trim()); + } + + return { + // Preserve the fact that the opening tag was already multiline so a + // second formatter pass cannot collapse it back to one line. + value: collected.join("\n"), + endIndex: index, + }; +} + +/** + * Put WRN template control markers on their own lines before indentation. + * + * Authors commonly write compact fragments such as + * `{#if loading}{/if}`. Treating that as one line prevents the + * normal HTML and control-block formatters from seeing its structure. + */ +function expandInlineControlBlocks(lines) { + const marker = + /(\{#(?:if|each)\b[^}]*\}|\{:(?:else(?:\s+if\b[^}]*)?|empty)\}|\{\/(?:if|each)\})/g; + + return lines.flatMap((line) => { + if (!marker.test(line)) return [line]; + marker.lastIndex = 0; + + const indentation = line.match(/^\s*/)?.[0] ?? ""; + const segments = line + .split(marker) + .map((segment) => segment.trim()) + .filter(Boolean); + + return segments.map((segment) => `${indentation}${segment}`); + }); +} + +function expandStructuredStateDeclarations(lines, unit) { + return lines.flatMap((line) => { + const match = /^(\s*state\s+[A-Za-z_$][\w$]*\s*=\s*)([\\[{][\s\S]*)$/.exec(line); + if (!match) return [line]; + + try { + const parsed = JSON.parse(match[2].trim()); + const jsonLines = JSON.stringify(parsed, null, unit).split("\n"); + if (jsonLines.length === 1) return [`${match[1]}${jsonLines[0]}`]; + + const leading = match[1].match(/^\s*/)?.[0] ?? ""; + return [ + `${match[1]}${jsonLines[0]}`, + ...jsonLines.slice(1).map((jsonLine) => `${leading}${jsonLine}`), + ]; + } catch { + return [line]; + } + }); +} + +function formatWrnPass(source: string, options: FormatWrnOptions = {}): string { + const unit = options.insertSpaces === false ? "\t" : " ".repeat(options.tabSize ?? 4); + + const printWidth = options.printWidth ?? 100; + + const multilineAttributes = options.multilineAttributes !== false; + + let codeDepth = 0; + let htmlDepth = 0; + let controlDepth = 0; + let index = 0; + + const sourceLines = source.replace(/\r\n/g, "\n").split("\n"); + const inputLines = expandInlineControlBlocks( + expandStructuredStateDeclarations(sourceLines, unit), + ); + + const output = []; + + let previousWasBlank = false; + + while (index < inputLines.length) { + const originalLine = inputLines[index]; + + let value = originalLine.trim(); + + if (value === "") { + if (!previousWasBlank && output.length > 0) { + output.push(""); + } + + previousWasBlank = true; + index += 1; + continue; + } + + previousWasBlank = false; + + if (/^import\b/.test(value)) { + const importLines = [value]; + while ( + !/(?:\bfrom\s+)?["'][^"']+["']\s*;?$/.test(importLines[importLines.length - 1]) && + index + 1 < inputLines.length + ) { + index += 1; + importLines.push(inputLines[index].trim()); + } + output.push(importLines[0], ...importLines.slice(1).map((line) => `${unit}${line}`)); + index += 1; + continue; + } + + if (isMultilineOpeningTagStart(value)) { + const collected = collectOpeningTag(inputLines, index); + + value = collected.value; + index = collected.endIndex; + } + + const inlineDeclaration = formatInlineDeclarationBlock(value, unit, codeDepth + htmlDepth); + const inlineProps = + inlineDeclaration ?? formatInlinePropsBlock(value, unit, codeDepth + htmlDepth); + if (inlineProps) { + output.push(...inlineProps); + index += 1; + continue; + } + + const leadingClosingBraces = countLeadingClosingBraces(value); + + const closesControlBlock = isControlBlockClose(value); + + const continuesControlBlock = isControlBlockMiddle(value); + + const lineControlDepth = + closesControlBlock || continuesControlBlock ? Math.max(0, controlDepth - 1) : controlDepth; + + const lineCodeDepth = Math.max(0, codeDepth - leadingClosingBraces); + + let lineHtmlDepth = htmlDepth; + + if (isClosingTag(value)) { + lineHtmlDepth = Math.max(0, htmlDepth - 1); + } + + const depth = lineCodeDepth + lineHtmlDepth + lineControlDepth; + + if ( + value.startsWith("<") && + !value.startsWith(" 0 && output[output.length - 1] === "") { + output.pop(); + } + + return `${output.join("\n")}\n`; +} + +/** Format to a bounded fixed point so one call is always safe for editor-on-save and migrations. */ +export function formatWrn(source: string, options: FormatWrnOptions = {}): string { + let current = source; + const seen = new Set(); + for (let pass = 0; pass < 8; pass++) { + const formatted = formatWrnPass(current, options); + if (formatted === current) return formatted; + if (seen.has(formatted)) return [...seen, formatted].sort()[0]!; + seen.add(current); + current = formatted; + } + return current; +} diff --git a/packages/syntax/src/index.ts b/packages/syntax/src/index.ts index 36cb6af4..f5f3341b 100644 --- a/packages/syntax/src/index.ts +++ b/packages/syntax/src/index.ts @@ -1,4 +1,6 @@ export { Lexer, LexError } from "./tokenizer.ts"; +export { formatWrn } from "./formatter.ts"; +export type { FormatWrnOptions } from "./formatter.ts"; export { parse, parseHtmlView, ParseError, VOID_ELEMENTS } from "./parser.ts"; export type { ActionBlock, diff --git a/packages/syntax/src/parser.ts b/packages/syntax/src/parser.ts index 47f1e540..7b53b2e8 100644 --- a/packages/syntax/src/parser.ts +++ b/packages/syntax/src/parser.ts @@ -1,3 +1,5 @@ +import { WRN_RUNTIME_TARGETS } from "./spec.ts"; + /** * Recursive-descent parser for `.wrn`, producing a small AST. * @@ -63,12 +65,16 @@ export interface EffectBlock { export interface LoadBlock { mode: "server" | "client"; + name?: string; + dependsOn?: string[]; + deferred?: boolean; body: string; } export interface ActionBlock { name: string; args: string[]; + schema?: string; body: string; } @@ -205,9 +211,13 @@ export interface PageAst { layout?: string; layoutIsSymbol?: boolean; /** Execution boundary metadata. Defaults to universal. */ - runtime?: "server" | "client" | "universal"; + runtime?: "server" | "client" | "universal" | "edge" | "worker" | "service-worker"; + /** Explicit rendering policy; `hybrid` is the default SSR + optional hydration behavior. */ + renderMode?: "static" | "server" | "hybrid" | "client" | "partial-static"; /** Client hydration strategy. Defaults to load when interactivity is present. */ hydrate?: string; + /** Declarative framework cache policy. */ + cache?: Record; /** Declared component props (empty for pages). */ props: PropDecl[]; /** Legacy public events exposed by a reusable component. */ @@ -222,6 +232,7 @@ export interface PageAst { loads: LoadBlock[]; actions: ActionBlock[]; security: Record; + navigation: Record; seo: SeoBlock; view: ViewNode[]; styles: string[]; @@ -343,6 +354,7 @@ export function parse(source: string): PageAst { let layout: string | undefined; let layoutIsSymbol = false; let runtime: PageAst["runtime"]; + let renderMode: PageAst["renderMode"]; let hydrate: string | undefined; const props: PropDecl[] = []; const events: EventDecl[] = []; @@ -354,6 +366,8 @@ export function parse(source: string): PageAst { const loads: LoadBlock[] = []; const actions: ActionBlock[] = []; const security: Record = {}; + const cache: Record = {}; + const navigation: Record = {}; const seo: SeoBlock = {}; const view: ViewNode[] = []; const styles: string[] = []; @@ -393,19 +407,32 @@ export function parse(source: string): PageAst { lx.next(); expect("eq"); const value = expect("string").value; - if (value !== "server" && value !== "client" && value !== "universal") { + if (!WRN_RUNTIME_TARGETS.includes(value as (typeof WRN_RUNTIME_TARGETS)[number])) { throw new ParseError( `Unknown runtime target '${value}' at offset ${kw.pos}`, "WRN-RUNTIME-TARGET", ); } - runtime = value; + runtime = value as PageAst["runtime"]; + break; + } + case "render": { + lx.next(); + expect("eq"); + const value = expect("string").value; + if (!["static", "server", "hybrid", "client", "partial-static"].includes(value)) + throw new ParseError( + `Unknown render mode '${value}' at offset ${kw.pos}`, + "WRN-RENDER-MODE", + ); + renderMode = value as PageAst["renderMode"]; break; } case "hydrate": { lx.next(); expect("eq"); - hydrate = expect("string").value; + const value = expect("string").value; + hydrate = value === "never" ? "none" : value; break; } case "props": { @@ -549,15 +576,49 @@ export function parse(source: string): PageAst { Object.assign(security, parseSeoBlock(lx.readBalancedBraces())); break; } + case "navigation": { + lx.next(); + Object.assign(navigation, parseSeoBlock(lx.readBalancedBraces())); + break; + } + case "cache": { + lx.next(); + Object.assign(cache, parseSeoBlock(lx.readBalancedBraces())); + break; + } case "load": { lx.next(); - const modeToken = expect("ident"); - if (modeToken.value !== "server" && modeToken.value !== "client") { - throw new ParseError( - `Expected 'server' or 'client' after load at offset ${modeToken.pos}`, - ); + const first = expect("ident"); + const mode = first.value === "client" ? "client" : "server"; + const name = + first.value === "server" || first.value === "client" + ? lx.peek().type === "ident" + ? expect("ident").value + : undefined + : first.value; + const dependsOn: string[] = []; + let deferred = false; + while (lx.peek().type === "ident") { + if (lx.peek().value === "defer") { + lx.next(); + deferred = true; + continue; + } + if (lx.peek().value !== "after") break; + lx.next(); + dependsOn.push(expect("ident").value); + while (lx.peek().type === "comma") { + lx.next(); + dependsOn.push(expect("ident").value); + } } - loads.push({ mode: modeToken.value, body: lx.readBalancedBraces() }); + loads.push({ + mode, + name, + ...(dependsOn.length ? { dependsOn } : {}), + ...(deferred ? { deferred: true } : {}), + body: lx.readBalancedBraces(), + }); break; } case "action": { @@ -572,7 +633,12 @@ export function parse(source: string): PageAst { } expect("rparen"); } - actions.push({ name: actionName, args, body: lx.readBalancedBraces() }); + let schema: string | undefined; + if (lx.peek().type === "ident" && lx.peek().value === "using") { + lx.next(); + schema = expect("ident").value; + } + actions.push({ name: actionName, args, schema, body: lx.readBalancedBraces() }); break; } case "api": { @@ -806,6 +872,38 @@ export function parse(source: string): PageAst { ); functionKeys.add(key); } + const namedLoads = new Map(loads.filter((load) => load.name).map((load) => [load.name!, load])); + for (const load of namedLoads.values()) { + for (const dependency of load.dependsOn ?? []) { + const dependencyLoad = namedLoads.get(dependency); + if (!dependencyLoad) + throw new ParseError( + `Load '${load.name}' depends on unknown load '${dependency}'`, + "WRN-LOAD-DEPENDENCY", + ); + if ( + load.mode === "server" && + !load.deferred && + (dependencyLoad.mode !== "server" || dependencyLoad.deferred) + ) + throw new ParseError( + `Server load '${load.name}' cannot depend on deferred/client load '${dependency}'`, + "WRN-LOAD-PHASE", + ); + } + } + const visiting = new Set(); + const visited = new Set(); + const visitLoad = (name: string): void => { + if (visiting.has(name)) + throw new ParseError(`Load dependency cycle includes '${name}'`, "WRN-LOAD-CYCLE"); + if (visited.has(name)) return; + visiting.add(name); + for (const dependency of namedLoads.get(name)?.dependsOn ?? []) visitLoad(dependency); + visiting.delete(name); + visited.add(name); + }; + for (const name of namedLoads.keys()) visitLoad(name); return { type: "page", imports, @@ -816,7 +914,9 @@ export function parse(source: string): PageAst { layout, layoutIsSymbol, runtime, + renderMode, hydrate, + cache, props, events, outputs, @@ -827,6 +927,7 @@ export function parse(source: string): PageAst { loads, actions, security, + navigation, seo, view, styles, diff --git a/packages/syntax/src/spec.ts b/packages/syntax/src/spec.ts index fc241700..f65f5ad0 100644 --- a/packages/syntax/src/spec.ts +++ b/packages/syntax/src/spec.ts @@ -38,7 +38,14 @@ export const WRN_ROOT_MEMBERS = [ export const WRN_HYDRATION_STRATEGIES = ["load", "idle", "visible", "interaction", "none"] as const; -export const WRN_RUNTIME_TARGETS = ["server", "client", "universal"] as const; +export const WRN_RUNTIME_TARGETS = [ + "server", + "client", + "universal", + "edge", + "worker", + "service-worker", +] as const; export type WrnRootKind = (typeof WRN_ROOT_KINDS)[number]; export type WrnRootMember = (typeof WRN_ROOT_MEMBERS)[number]; diff --git a/packages/syntax/test/actions.test.ts b/packages/syntax/test/actions.test.ts new file mode 100644 index 00000000..367098fb --- /dev/null +++ b/packages/syntax/test/actions.test.ts @@ -0,0 +1,10 @@ +import { expect, test } from "bun:test"; +import { parse } from "../src/index.ts"; + +test("parses schema-backed server actions", () => { + const ast = parse(`page Users { + action createUser using CreateUserSchema { return { id: input.name } } + view {
    } + }`); + expect(ast.actions[0]).toMatchObject({ name: "createUser", schema: "CreateUserSchema" }); +}); diff --git a/packages/syntax/test/formatter.test.ts b/packages/syntax/test/formatter.test.ts new file mode 100644 index 00000000..be2344ec --- /dev/null +++ b/packages/syntax/test/formatter.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; +import { formatWrn, parse } from "../src/index.ts"; + +const options = { + insertSpaces: true, + tabSize: 2, + printWidth: 70, + multilineAttributes: true, +} as const; + +describe("canonical WRN formatter", () => { + test("formats declarations, structured values, markup, and imports idempotently", () => { + const source = `import Card from "@/components/Card.wrn" +component Demo { +props { title: string = "Hello" items = [{ id: 1, label: "One" }] } +view { +{title} +} +}`; + const formatted = formatWrn(source, options); + + expect(formatted).toContain('import Card from "@/components/Card.wrn"'); + expect(formatted).toContain('props {\n title: string = "Hello"'); + expect(formatted).toContain('items = [{ id: 1, label: "One" }]'); + expect(formatted).toContain(" parse(formatted)).not.toThrow(); + }); + + test("preserves comments and normalizes the final newline", () => { + const source = `// leading comment\r\npage Home {\r\nview {

    Home

    }\r\n}`; + const formatted = formatWrn(source, options); + + expect(formatted.startsWith("// leading comment\n")).toBe(true); + expect(formatted).toContain(""); + expect(formatted.endsWith("\n")).toBe(true); + expect(formatted).not.toContain("\r"); + }); +}); diff --git a/packages/syntax/test/navigation.test.ts b/packages/syntax/test/navigation.test.ts new file mode 100644 index 00000000..88f94852 --- /dev/null +++ b/packages/syntax/test/navigation.test.ts @@ -0,0 +1,9 @@ +import { expect, test } from "bun:test"; +import { parse } from "../src/index.ts"; + +test("parses declarative navigation preservation", () => { + const ast = parse( + `page Users { navigation { preserve = ["filters", "pagination", "scroll"] } view {
    } }`, + ); + expect(ast.navigation.preserve).toBe('["filters", "pagination", "scroll"]'); +}); diff --git a/packages/syntax/test/syntax.test.ts b/packages/syntax/test/syntax.test.ts index f798e6e8..0ef4bff5 100644 --- a/packages/syntax/test/syntax.test.ts +++ b/packages/syntax/test/syntax.test.ts @@ -1,6 +1,37 @@ import { expect, test } from "bun:test"; import { diagnose, formatDiagnostic, parse } from "../src/index.ts"; +test("parses explicit rendering modes and the never hydration alias", () => { + const ast = parse(`page Marketing { + render = "static" + hydrate = "never" + view {

    Fast

    } + }`); + expect(ast.renderMode).toBe("static"); + expect(ast.hydrate).toBe("none"); + expect(() => parse('page Invalid { render = "sometimes" view {

    No

    } }')).toThrow( + "Unknown render mode", + ); +}); + +test("parses a declarative cache policy", () => { + const ast = parse(`page Dashboard { + cache { + strategy = "stale-while-revalidate" + ttl = "5m" + tags = ["users", "dashboard"] + vary = ["tenant", "language"] + } + view {

    Dashboard

    } + }`); + expect(ast.cache).toEqual({ + strategy: "stale-while-revalidate", + ttl: "5m", + tags: '["users", "dashboard"]', + vary: '["tenant", "language"]', + }); +}); + test("parses native array and object literals in state and component props", () => { const ast = parse(` component Navigation { @@ -111,7 +142,7 @@ test("diagnoses server-only interactive roots and accessibility issues", () => { }); test("formats parser diagnostics with stable codes and source locations", () => { - const source = `page Broken { runtime = "worker"\n view {
    } }`; + const source = `page Broken { runtime = "quantum"\n view {
    } }`; const [diagnostic] = diagnose(source, { file: "Broken.wrn" }); expect(diagnostic?.code).toBe("WRN-RUNTIME-TARGET"); @@ -161,3 +192,11 @@ test("parses public component event declarations inside props", () => { expect(ast.props.map((prop) => prop.name)).toEqual(["value"]); expect(ast.events).toEqual([{ name: "search" }, { name: "clear" }]); }); + +test("parses edge and worker execution targets", () => { + for (const runtime of ["edge", "worker", "service-worker"] as const) { + expect(parse(`page Runtime { runtime = "${runtime}" view {

    Hi

    } }`).runtime).toBe( + runtime, + ); + } +}); diff --git a/packages/test/README.md b/packages/test/README.md index 829fb1a8..21ba022a 100644 --- a/packages/test/README.md +++ b/packages/test/README.md @@ -103,6 +103,22 @@ Remember to `await app.close()` when done. ## Usage +The CLI supports focused suites by file or directory convention: + +```bash +wrnexus test unit # *.unit.test.ts or test/unit/** +wrnexus test component # *.component.test.ts or test/component/** +wrnexus test api # *.api.test.ts or test/api/** +wrnexus test accessibility # *.a11y.test.ts / *.accessibility.test.ts +wrnexus test performance # *.performance.test.ts / *.benchmark.test.ts +wrnexus test browser # Playwright project when configured +wrnexus test visual # Playwright tests tagged @visual +``` + +Pass the application directory after the level, for example +`wrnexus test component examples/basic-app`. A focused command fails clearly when no matching +suite exists instead of silently running unrelated tests. + ```ts import { test, expect, renderComponent, mountHtml, createHarness } from "@wrnexus/test"; diff --git a/packages/test/package.json b/packages/test/package.json index a684f6e1..3f0543e6 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/test", - "version": "0.7.0", + "version": "0.8.0", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/test/src/index.ts b/packages/test/src/index.ts index 84374473..df41a378 100644 --- a/packages/test/src/index.ts +++ b/packages/test/src/index.ts @@ -190,3 +190,5 @@ export { MemoryCookieJar, } from "./advanced.ts"; export type { TestRequestOptions, JsonResponse, Deferred, WaitForOptions } from "./advanced.ts"; +export { withDatabaseRollback, createFactory, captureBrowserArtifacts } from "./platform.ts"; +export type { TransactionalDatabase, BrowserArtifactPage } from "./platform.ts"; diff --git a/packages/test/src/platform.ts b/packages/test/src/platform.ts new file mode 100644 index 00000000..52324b53 --- /dev/null +++ b/packages/test/src/platform.ts @@ -0,0 +1,67 @@ +import { mkdirSync } from "node:fs"; +import { join, resolve } from "node:path"; + +export interface TransactionalDatabase { + tx(callback: (transaction: TransactionalDatabase) => Promise): Promise; +} + +const ROLLBACK = Symbol("wrnexus-test-rollback"); + +/** Run test work in a real transaction and always force rollback. */ +export async function withDatabaseRollback( + db: TransactionalDatabase, + run: (transaction: TransactionalDatabase) => T | Promise, +): Promise { + let output!: T; + try { + await db.tx(async (transaction) => { + output = await run(transaction); + throw ROLLBACK; + }); + } catch (error) { + if (error !== ROLLBACK) throw error; + } + return output; +} + +export function createFactory>(build: (sequence: number) => T) { + let sequence = 0; + return { + build(overrides: Partial = {}): T { + return { ...build(++sequence), ...overrides }; + }, + buildMany(count: number, overrides: Partial = {}): T[] { + if (!Number.isInteger(count) || count < 0 || count > 10_000) + throw new RangeError("Factory count must be between 0 and 10000"); + return Array.from({ length: count }, () => ({ ...build(++sequence), ...overrides })); + }, + reset(): void { + sequence = 0; + }, + }; +} + +export interface BrowserArtifactPage { + screenshot(options: { path: string; fullPage?: boolean }): Promise; + context(): { tracing?: { stop(options: { path: string }): Promise } }; +} + +export async function captureBrowserArtifacts( + page: BrowserArtifactPage, + testName: string, + options: { root?: string; screenshot?: boolean; trace?: boolean } = {}, +): Promise<{ screenshot?: string; trace?: string }> { + const safe = testName.replace(/[^A-Za-z0-9_.-]+/g, "-").slice(0, 120) || "test"; + const root = resolve(options.root ?? join("test-results", "wrnexus")); + mkdirSync(root, { recursive: true }); + const output: { screenshot?: string; trace?: string } = {}; + if (options.screenshot !== false) { + output.screenshot = join(root, `${safe}.png`); + await page.screenshot({ path: output.screenshot, fullPage: true }); + } + if (options.trace && page.context().tracing) { + output.trace = join(root, `${safe}.zip`); + await page.context().tracing!.stop({ path: output.trace }); + } + return output; +} diff --git a/packages/test/test/platform.test.ts b/packages/test/test/platform.test.ts new file mode 100644 index 00000000..15bb49e5 --- /dev/null +++ b/packages/test/test/platform.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from "bun:test"; +import { captureBrowserArtifacts, createFactory, withDatabaseRollback } from "../src/index.ts"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +test("factories are deterministic, bounded and override-safe", () => { + const users = createFactory((sequence) => ({ + id: sequence, + email: `user${sequence}@test.local`, + })); + expect(users.build().id).toBe(1); + expect(users.build({ email: "custom@test.local" }).email).toBe("custom@test.local"); + users.reset(); + expect(users.buildMany(2).map((user) => user.id)).toEqual([1, 2]); + expect(() => users.buildMany(10_001)).toThrow(); +}); + +test("database isolation always rolls back while returning the test value", async () => { + let rolledBack = false; + const db = { + async tx(run: (transaction: any) => Promise): Promise { + try { + return await run(db); + } catch (error) { + rolledBack = true; + throw error; + } + }, + }; + expect(await withDatabaseRollback(db, async () => 42)).toBe(42); + expect(rolledBack).toBeTrue(); +}); + +test("browser artifact helper writes safe screenshot and trace paths", async () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-browser-artifacts-")); + const calls: string[] = []; + try { + const result = await captureBrowserArtifacts( + { + async screenshot({ path }) { + calls.push(path); + }, + context() { + return { + tracing: { + async stop({ path }) { + calls.push(path); + }, + }, + }; + }, + }, + "user / unsafe name", + { root, trace: true }, + ); + expect(result.screenshot).toEndWith("user-unsafe-name.png"); + expect(result.trace).toEndWith("user-unsafe-name.zip"); + expect(calls).toHaveLength(2); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/tracking/package.json b/packages/tracking/package.json index 59d0478b..d240d92c 100644 --- a/packages/tracking/package.json +++ b/packages/tracking/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/tracking", - "version": "0.7.0", + "version": "0.8.0", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/typecheck/README.md b/packages/typecheck/README.md new file mode 100644 index 00000000..bae62bfe --- /dev/null +++ b/packages/typecheck/README.md @@ -0,0 +1,5 @@ +# @wrnexus/typecheck + +Static type checking for `.wrn` declarations, props, state, outputs, functions, stores, and generated virtual TypeScript files. + +The package is compiler tooling rather than a browser UI package, so its public kit consists of programmatic typecheck helpers and diagnostics. diff --git a/packages/typecheck/package.json b/packages/typecheck/package.json index 6ab46eb4..5c6dfbfe 100644 --- a/packages/typecheck/package.json +++ b/packages/typecheck/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/typecheck", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/typecheck/src/index.ts b/packages/typecheck/src/index.ts index 95c0bf17..510e718e 100644 --- a/packages/typecheck/src/index.ts +++ b/packages/typecheck/src/index.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; import { dirname, join, normalize, resolve } from "node:path"; import ts from "typescript"; import { @@ -51,6 +51,39 @@ function resolveImportedWrn(source: string, filePath: string, appRoot: string): return null; } +function workspacePackagePaths(appRoot: string): Record { + let current = resolve(appRoot); + while (true) { + const manifestPath = join(current, "package.json"); + if (existsSync(manifestPath)) { + try { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + if (Array.isArray(manifest.workspaces) && existsSync(join(current, "packages"))) { + const paths: Record = {}; + for (const entry of readdirSync(join(current, "packages"), { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const packageRoot = join(current, "packages", entry.name); + const packageManifestPath = join(packageRoot, "package.json"); + if (!existsSync(packageManifestPath)) continue; + const packageManifest = JSON.parse(readFileSync(packageManifestPath, "utf8")); + if (typeof packageManifest.name !== "string") continue; + const main = + typeof packageManifest.main === "string" ? packageManifest.main : "src/index.ts"; + paths[packageManifest.name] = [join(packageRoot, main)]; + paths[`${packageManifest.name}/*`] = [join(packageRoot, "src", "*.ts")]; + } + return paths; + } + } catch { + /* continue toward a parent workspace */ + } + } + const parent = dirname(current); + if (parent === current) return {}; + current = parent; + } +} + function importedWrnDeclarations(ast: PageAst, filePath: string, appRoot: string): string { const declarations: string[] = []; for (const entry of ast.structuredImports) { @@ -106,7 +139,7 @@ function functionDeclaration(fn: RuntimeFunctionDecl): string { const params = fn.parameters .map( (param) => - `${param.name}${param.optional ? "?" : ""}${param.valueType ? `: ${param.valueType}` : ": unknown"}${param.default ? ` = ${param.default}` : ""}`, + `${param.name}${param.optional ? "?" : ""}${param.valueType ? `: ${param.valueType}` : param.name === "event" ? ": CustomEvent & { target: HTMLElement }" : ": unknown"}${param.default ? ` = ${param.default}` : ""}`, ) .join(", "); return `export ${fn.async ? "async " : ""}function ${fn.name}(${params})${fn.returnType ? `: ${fn.returnType}` : ""} {${fn.body}}`; @@ -186,6 +219,12 @@ export function virtualTypeScriptModule( ) .join("; "); append(`declare const output: { ${outputType} };`); + for (const output of ast.outputs) + append( + `declare const ${output.name}: (${output.payload ? `${output.payload.name}${output.payload.optional ? "?" : ""}: ${output.payload.valueType}` : ""}) => void;`, + ); + for (const match of source.matchAll(/@event\s+([A-Za-z_$][\w$]*)\s*=\s*function/g)) + append(`declare const ${match[1]}: ((...args: unknown[]) => void) | undefined;`); append(`declare const server: { ${serverType} };`); append(`declare const props: Readonly<${ast.name}Props>;`); append("declare const refs: Record;"); @@ -601,8 +640,8 @@ export function checkWrnSource( allowArbitraryExtensions: true, noEmit: true, baseUrl: appRoot, - paths: { "@/*": ["app/*"] }, - lib: ["lib.esnext.d.ts", "lib.dom.d.ts"], + paths: { "@/*": ["app/*"], ...workspacePackagePaths(appRoot) }, + lib: ["lib.esnext.d.ts", "lib.dom.d.ts", "lib.dom.iterable.d.ts"], }; const rootNames = [virtual.fileName, ...appTypes.files.keys()]; const program = ts.createProgram( diff --git a/packages/typecheck/test/imports-and-components.test.ts b/packages/typecheck/test/imports-and-components.test.ts index c8bcd0e0..68638fd2 100644 --- a/packages/typecheck/test/imports-and-components.test.ts +++ b/packages/typecheck/test/imports-and-components.test.ts @@ -45,7 +45,7 @@ page Home { expect(diagnostics.some((item) => item.code === "WRN-COMPONENT-UNKNOWN-PROP")).toBe(true); expect(diagnostics.some((item) => item.code === "WRN-COMPONENT-PROP-LITERAL")).toBe(true); expect(diagnostics.some((item) => item.code === "WRN-OUTPUT-UNKNOWN-HANDLER")).toBe(true); -}); +}, 15_000); test("types imported store actions", () => { const root = fixture(); @@ -59,4 +59,4 @@ page Home { { appRoot: root, filePath }, ); expect(diagnostics.some((item) => item.code === "WRN-TYPE-2345")).toBe(true); -}); +}, 15_000); diff --git a/packages/ui/package.json b/packages/ui/package.json index e8762a38..4f3c40d8 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ui", - "version": "0.7.0", + "version": "0.8.0", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/ui/test/accessibility-contract.test.ts b/packages/ui/test/accessibility-contract.test.ts new file mode 100644 index 00000000..5c9c85eb --- /dev/null +++ b/packages/ui/test/accessibility-contract.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from "bun:test"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { diagnose } from "@wrnexus/syntax"; + +test("every shipped UI component passes compiler accessibility diagnostics", () => { + const directory = join(import.meta.dir, "..", "components"); + const failures: string[] = []; + const files = readdirSync(directory).filter((file) => file.endsWith(".wrn")); + for (const file of files) { + const diagnostics = diagnose(readFileSync(join(directory, file), "utf8"), { + file, + accessibility: true, + }); + for (const diagnostic of diagnostics) { + if (diagnostic.severity === "error" || diagnostic.code.startsWith("WRN-A11Y")) { + failures.push(`${file}: ${diagnostic.code} ${diagnostic.message}`); + } + } + } + expect(files.length).toBeGreaterThan(100); + expect(failures).toEqual([]); +}); diff --git a/packages/uploader/README.md b/packages/uploader/README.md index b89645b8..287c221a 100644 --- a/packages/uploader/README.md +++ b/packages/uploader/README.md @@ -108,6 +108,11 @@ export const GET = serveFromStore("docs"); // your middleware decides who gets i ## API +Uploads can participate in security and media pipelines without changing storage drivers. Pass a +`scan` hook to reject malware/DLP findings before storage, and `afterStore` to enqueue image/video +processing or indexing. If post-processing throws, WRNexus deletes the newly written object so a +partially accepted upload is never left behind. + | Export | What | | --------------------------------------- | --------------------------------------------------------------- | | `handleUpload(opts)` | POST route handler → JSON `{ ok, files }` | @@ -123,3 +128,36 @@ export const GET = serveFromStore("docs"); // your middleware decides who gets i - SigV4 signing is implemented from scratch (no `@aws-sdk`); tested against local S3 semantics. Live AWS/R2 connectivity depends on your credentials + bucket policy. - v1 buffers each file in memory up to its size cap (fine for images/docs up to tens of MB). + +## Helper and component kit + +Use `formatFileSize`, `uploadAccept`, `uploadedFileMap`, `uploaderAttributes`, and `assertUploadedFiles` to keep upload forms and server validation consistent. + +Enable `uploaderPlugin()` for: + +- `` +- `` + +The complete blocks compose `Card`, `Alert`, and `Badge` from `@wrnexus/ui`; the specialized upload runtime remains responsible for the native file input and secure transport behavior. +Large files can use `createResumableUploadManager`. Sessions are bounded and +expiring; chunks may arrive out of order, carry SHA-256 checksums, and are +idempotent when retried. Conflicting retries reject, and the object is assembled +only after every exact-sized chunk is present. + +```ts +const uploads = createResumableUploadManager({ + driver: getStore("documents").driver, + sessions: redisUploadSessionStore, + chunkSize: 5 * 1024 * 1024, + maxBytes: 500 * 1024 * 1024, + accept: ["application/pdf"], +}); + +const session = await uploads.create({ name: "report.pdf", size, type }); +await uploads.uploadChunk(session.id, index, bytes, sha256); +``` + +The included memory session store is intended for one-process apps and tests. +Multi-instance production deployments should implement `ResumableSessionStore` +with shared durable storage and atomic session updates, and periodically call +`prune()` for abandoned uploads. diff --git a/packages/uploader/components/UploadDropzone.wrn b/packages/uploader/components/UploadDropzone.wrn new file mode 100644 index 00000000..1ff625b6 --- /dev/null +++ b/packages/uploader/components/UploadDropzone.wrn @@ -0,0 +1,21 @@ +component UploadDropzone { + props { + store: string = "default" + endpoint: string = "/api/upload" + accept: string = "" + maxBytes: number = 0 + multiple: boolean = false + field: string = "file" + label: string = "Drag files here or click to browse" + title: string = "Upload files" + description: string = "Files are validated and uploaded securely." + color: string = "primary" + size: string = "md" + class: string = "" + } + view { + +
    +
    + } +} diff --git a/packages/uploader/components/UploadStatus.wrn b/packages/uploader/components/UploadStatus.wrn new file mode 100644 index 00000000..d945112a --- /dev/null +++ b/packages/uploader/components/UploadStatus.wrn @@ -0,0 +1,24 @@ +component UploadStatus { + props { + files: unknown[] = [] + title: string = "Uploaded files" + emptyMessage: string = "No files uploaded yet." + color: string = "primary" + size: string = "sm" + class: string = "" + } + view { + + {#if files.length == 0}{/if} +
    + {#each files as file} +
    + +

    {file.name}

    {file.type || "File"} · {file.size || 0} bytes

    + +
    + {/each} +
    +
    + } +} diff --git a/packages/uploader/package.json b/packages/uploader/package.json index bf4678d5..3424ad2e 100644 --- a/packages/uploader/package.json +++ b/packages/uploader/package.json @@ -1,13 +1,36 @@ { "name": "@wrnexus/uploader", - "version": "0.7.0", + "version": "0.8.0", "private": true, "type": "module", - "main": "src/index.ts", + "main": "./src/index.ts", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./plugin": "./src/plugin.ts", + "./components/*": "./components/*" }, "dependencies": { - "@wrnexus/core": "workspace:*" + "@wrnexus/core": "workspace:*", + "@wrnexus/plugin": "workspace:*", + "@wrnexus/ui": "workspace:*" + }, + "description": "Secure upload drivers, policies, client runtime, helper functions, and reusable upload components.", + "types": "./src/index.ts", + "files": [ + "src", + "components", + "README.md" + ], + "devDependencies": { + "@types/bun": "^1.3.14", + "typescript": "^5.9.2", + "@wrnexus/syntax": "workspace:*" + }, + "wrnexus": { + "plugin": { + "plugin": "./src/plugin.ts", + "export": "default", + "factory": true + } } } diff --git a/packages/uploader/src/helpers.ts b/packages/uploader/src/helpers.ts new file mode 100644 index 00000000..62dcc82d --- /dev/null +++ b/packages/uploader/src/helpers.ts @@ -0,0 +1,58 @@ +import type { UploadedFile } from "./upload.ts"; + +export function formatFileSize(bytes: number, locale = "en"): string { + const value = Math.max(0, Number(bytes) || 0); + if (value < 1_024) return `${value} B`; + const units = ["KB", "MB", "GB", "TB"]; + let current = value / 1_024; + let unit = units[0]!; + for (let index = 1; index < units.length && current >= 1_024; index++) { + current /= 1_024; + unit = units[index]!; + } + return `${new Intl.NumberFormat(locale, { maximumFractionDigits: current < 10 ? 1 : 0 }).format(current)} ${unit}`; +} + +export function uploadAccept(value: string | readonly string[]): string { + return (typeof value === "string" ? value.split(",") : [...value]) + .map((entry) => entry.trim()) + .filter(Boolean) + .join(","); +} + +export function uploadedFileMap(files: readonly UploadedFile[]): Record { + return Object.fromEntries(files.map((file) => [file.key, file])); +} + +export function uploaderAttributes( + options: { + store?: string; + endpoint?: string; + accept?: string | readonly string[]; + maxBytes?: number; + multiple?: boolean; + field?: string; + label?: string; + } = {}, +): Record { + return { + "data-uploader": options.store ?? "default", + "data-endpoint": options.endpoint ?? "/api/upload", + ...(options.accept ? { "data-accept": uploadAccept(options.accept) } : {}), + ...(options.maxBytes ? { "data-max": String(options.maxBytes) } : {}), + ...(options.multiple ? { "data-multiple": true } : {}), + ...(options.field ? { "data-field": options.field } : {}), + ...(options.label ? { "data-label": options.label } : {}), + }; +} + +export function assertUploadedFiles( + files: readonly UploadedFile[], + options: { min?: number; max?: number } = {}, +): readonly UploadedFile[] { + const min = Math.max(0, options.min ?? 0); + const max = Math.max(min, options.max ?? Number.POSITIVE_INFINITY); + if (files.length < min) throw new Error(`WRN-UPLOAD-MIN-FILES: expected at least ${min}`); + if (files.length > max) throw new Error(`WRN-UPLOAD-MAX-FILES: expected at most ${max}`); + return files; +} diff --git a/packages/uploader/src/index.ts b/packages/uploader/src/index.ts index 9e461a82..02f6f143 100644 --- a/packages/uploader/src/index.ts +++ b/packages/uploader/src/index.ts @@ -38,7 +38,7 @@ export { UploadError, UPLOADS_PREFIX, } from "./upload.ts"; -export type { UploadedFile, UploadOptions } from "./upload.ts"; +export type { UploadedFile, UploadOptions, UploadScanInput, UploadScanResult } from "./upload.ts"; export { configureStorage, getStore, hasStorage, storeNames } from "./client.ts"; export type { Store } from "./client.ts"; @@ -70,3 +70,37 @@ export { UploadPolicyError, } from "./security.ts"; export type { UploadPolicy, UploadInspection, SignedFileToken } from "./security.ts"; +export { + formatFileSize, + uploadAccept, + uploadedFileMap, + uploaderAttributes, + assertUploadedFiles, +} from "./helpers.ts"; +export { uploaderPlugin, uploaderComponentsDir } from "./plugin.ts"; +export type { UploaderPluginOptions } from "./plugin.ts"; +export { createResumableUploadManager, memoryResumableSessionStore } from "./resumable.ts"; +export type { + ResumableUploadManager, + ResumableUploadManagerOptions, + ResumableUploadSession, + ResumableSessionStore, + CreateResumableUpload, + ResumableChunkResult, +} from "./resumable.ts"; +export { + memoryQuotaStore, + postgresQuotaStore, + POSTGRES_QUOTA_SCHEMA, + multipartUpload, + createTemporaryObjectCleaner, + ffmpegVideoTranscoder, +} from "./operations.ts"; +export type { + QuotaUsage, + QuotaStore, + QuotaSqlClient, + MultipartObjectClient, + TemporaryObject, + VideoTranscodeOptions, +} from "./operations.ts"; diff --git a/packages/uploader/src/operations.ts b/packages/uploader/src/operations.ts new file mode 100644 index 00000000..82d23bc7 --- /dev/null +++ b/packages/uploader/src/operations.ts @@ -0,0 +1,200 @@ +import type { PutMeta, StorageDriver } from "./driver.ts"; + +export interface QuotaUsage { + owner: string; + bytes: number; + objects: number; + updatedAt: number; +} +export interface QuotaStore { + get(owner: string): Promise; + reserve( + owner: string, + bytes: number, + limits: { bytes: number; objects?: number }, + ): Promise; + release(owner: string, bytes: number): Promise; +} + +export function memoryQuotaStore(): QuotaStore { + const values = new Map(); + return { + async get(owner) { + return structuredClone( + values.get(owner) ?? { owner, bytes: 0, objects: 0, updatedAt: Date.now() }, + ); + }, + async reserve(owner, bytes, limits) { + if (!Number.isInteger(bytes) || bytes < 0) + throw new RangeError("Quota bytes must be non-negative"); + const current = values.get(owner) ?? { owner, bytes: 0, objects: 0, updatedAt: Date.now() }; + if ( + current.bytes + bytes > limits.bytes || + current.objects + 1 > (limits.objects ?? Number.MAX_SAFE_INTEGER) + ) + return false; + values.set(owner, { + owner, + bytes: current.bytes + bytes, + objects: current.objects + 1, + updatedAt: Date.now(), + }); + return true; + }, + async release(owner, bytes) { + const current = values.get(owner); + if (!current) return; + values.set(owner, { + ...current, + bytes: Math.max(0, current.bytes - Math.max(0, bytes)), + objects: Math.max(0, current.objects - 1), + updatedAt: Date.now(), + }); + }, + }; +} + +export interface QuotaSqlClient { + query(sql: string, parameters?: unknown[]): Promise<{ rows: T[] }>; +} + +/** PostgreSQL quota accounting using a single atomic conditional upsert. */ +export function postgresQuotaStore( + db: QuotaSqlClient, + table = "wrnexus_storage_quota", +): QuotaStore { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) throw new Error("Invalid quota table name"); + return { + async get(owner) { + const result = await db.query( + `SELECT owner,bytes,objects,updated_at AS "updatedAt" FROM ${table} WHERE owner=$1`, + [owner], + ); + return result.rows[0] ?? { owner, bytes: 0, objects: 0, updatedAt: Date.now() }; + }, + async reserve(owner, bytes, limits) { + const result = await db.query( + `INSERT INTO ${table} (owner,bytes,objects,updated_at) VALUES ($1,$2,1,$5) ON CONFLICT (owner) DO UPDATE SET bytes=${table}.bytes+$2,objects=${table}.objects+1,updated_at=$5 WHERE ${table}.bytes+$2 <= $3 AND ${table}.objects+1 <= $4 RETURNING owner`, + [owner, bytes, limits.bytes, limits.objects ?? 2_147_483_647, Date.now()], + ); + return result.rows.length === 1; + }, + async release(owner, bytes) { + await db.query( + `UPDATE ${table} SET bytes=GREATEST(0,bytes-$2),objects=GREATEST(0,objects-1),updated_at=$3 WHERE owner=$1`, + [owner, bytes, Date.now()], + ); + }, + }; +} + +export const POSTGRES_QUOTA_SCHEMA = `CREATE TABLE IF NOT EXISTS wrnexus_storage_quota (owner text PRIMARY KEY, bytes bigint NOT NULL DEFAULT 0, objects integer NOT NULL DEFAULT 0, updated_at bigint NOT NULL);`; + +export interface MultipartObjectClient { + create(key: string, meta: PutMeta): Promise; + uploadPart(uploadId: string, key: string, part: number, bytes: Uint8Array): Promise; + complete( + uploadId: string, + key: string, + parts: Array<{ part: number; etag: string }>, + ): Promise; + abort(uploadId: string, key: string): Promise; +} + +export async function multipartUpload( + client: MultipartObjectClient, + key: string, + bytes: Uint8Array, + meta: PutMeta, + options: { partBytes?: number; concurrency?: number } = {}, +): Promise { + const partBytes = options.partBytes ?? 8 * 1024 * 1024; + const concurrency = options.concurrency ?? 4; + if (partBytes < 5 * 1024 * 1024) throw new RangeError("Multipart parts must be at least 5 MiB"); + if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 32) + throw new RangeError("Multipart concurrency must be between 1 and 32"); + const uploadId = await client.create(key, meta); + const chunks = Array.from({ length: Math.ceil(bytes.length / partBytes) }, (_, index) => ({ + part: index + 1, + bytes: bytes.slice(index * partBytes, (index + 1) * partBytes), + })); + const completed: Array<{ part: number; etag: string }> = []; + try { + for (let offset = 0; offset < chunks.length; offset += concurrency) { + completed.push( + ...(await Promise.all( + chunks.slice(offset, offset + concurrency).map(async (chunk) => ({ + part: chunk.part, + etag: await client.uploadPart(uploadId, key, chunk.part, chunk.bytes), + })), + )), + ); + } + await client.complete(uploadId, key, completed); + } catch (error) { + await client.abort(uploadId, key); + throw error; + } +} + +export interface TemporaryObject { + key: string; + expiresAt: number; +} +export function createTemporaryObjectCleaner( + driver: StorageDriver, + options: { now?: () => number; limit?: number } = {}, +) { + const objects = new Map(); + const now = options.now ?? Date.now; + const limit = options.limit ?? 10_000; + return { + track(key: string, ttlMs: number) { + if (objects.size >= limit) throw new Error("WRN-UPLOAD-TEMP-CAPACITY"); + if (ttlMs < 1) throw new RangeError("Temporary TTL must be positive"); + objects.set(key, now() + ttlMs); + }, + async cleanup(at = now()) { + const due = [...objects].filter(([, expiry]) => expiry <= at); + for (const [key] of due) { + await driver.delete(key); + objects.delete(key); + } + return due.length; + }, + snapshot: () => ({ + tracked: objects.size, + nextExpiry: objects.size ? Math.min(...objects.values()) : undefined, + }), + }; +} + +export interface VideoTranscodeOptions { + format: "mp4" | "webm"; + width?: number; + height?: number; + videoBitrateKbps?: number; +} +export function ffmpegVideoTranscoder( + options: { executable?: string; spawn?: (args: string[]) => { exited: Promise } } = {}, +) { + return async (input: string, output: string, config: VideoTranscodeOptions): Promise => { + if (!/^[\w .:\\/-]+$/.test(input) || !/^[\w .:\\/-]+$/.test(output)) + throw new Error("Invalid video path"); + const args = [ + options.executable ?? "ffmpeg", + "-y", + "-i", + input, + ...(config.width || config.height + ? ["-vf", `scale=${config.width ?? -2}:${config.height ?? -2}`] + : []), + ...(config.videoBitrateKbps ? ["-b:v", `${config.videoBitrateKbps}k`] : []), + "-f", + config.format, + output, + ]; + const child = options.spawn?.(args) ?? Bun.spawn(args, { stdout: "ignore", stderr: "ignore" }); + if ((await child.exited) !== 0) throw new Error("WRN-VIDEO-TRANSCODE-FAILED"); + }; +} diff --git a/packages/uploader/src/plugin.ts b/packages/uploader/src/plugin.ts new file mode 100644 index 00000000..7b394de0 --- /dev/null +++ b/packages/uploader/src/plugin.ts @@ -0,0 +1,20 @@ +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { definePlugin } from "@wrnexus/plugin"; +export interface UploaderPluginOptions { + components?: boolean; + componentDir?: string; +} +const packageRoot = dirname(dirname(fileURLToPath(import.meta.url))); +export function uploaderComponentsDir(): string { + return join(packageRoot, "components"); +} +export function uploaderPlugin(options: UploaderPluginOptions = {}) { + return definePlugin({ + name: "@wrnexus/uploader", + version: "0.8.0", + componentDirs: + options.components === false ? [] : [options.componentDir ?? uploaderComponentsDir()], + }); +} +export default uploaderPlugin; diff --git a/packages/uploader/src/resumable.ts b/packages/uploader/src/resumable.ts new file mode 100644 index 00000000..83abef1d --- /dev/null +++ b/packages/uploader/src/resumable.ts @@ -0,0 +1,246 @@ +import type { StorageDriver } from "./driver.ts"; +import { accepts, contentTypeOf, extForType, extOf } from "./mime.ts"; +import { UploadError, type UploadedFile } from "./upload.ts"; + +export interface ResumableUploadSession { + id: string; + key: string; + name: string; + type: string; + size: number; + chunkSize: number; + totalChunks: number; + createdAt: number; + expiresAt: number; + chunks: Record; + digests: Record; +} + +export interface ResumableSessionStore { + get(id: string): Promise; + put(session: ResumableUploadSession): Promise; + delete(id: string): Promise; + list(): Promise; +} + +export function memoryResumableSessionStore(): ResumableSessionStore { + const sessions = new Map(); + return { + async get(id) { + return sessions.get(id) ?? null; + }, + async put(session) { + sessions.set(session.id, session); + }, + async delete(id) { + sessions.delete(id); + }, + async list() { + return [...sessions.values()]; + }, + }; +} + +export interface ResumableUploadManagerOptions { + driver: StorageDriver; + sessions?: ResumableSessionStore; + maxBytes?: number; + chunkSize?: number; + maxSessions?: number; + ttlMs?: number; + accept?: string[]; + prefix?: string; + publicUrl?: (key: string) => string | null; + now?: () => number; +} + +export interface CreateResumableUpload { + name: string; + type?: string; + size: number; + chunkSize?: number; +} + +export interface ResumableChunkResult { + receivedChunks: number; + totalChunks: number; + complete: boolean; + file?: UploadedFile; +} + +export interface ResumableUploadManager { + create(input: CreateResumableUpload): Promise; + uploadChunk( + id: string, + index: number, + data: Uint8Array, + sha256?: string, + ): Promise; + status( + id: string, + ): Promise<{ received: number[]; totalChunks: number; expiresAt: number } | null>; + cancel(id: string): Promise; + prune(): Promise; +} + +function safePrefix(prefix: string): string { + const value = prefix.replace(/^\/+|\/+$/g, ""); + if ( + value && + (!/^[A-Za-z0-9._/-]+$/.test(value) || + value.split("/").some((part) => !part || part === "." || part === "..")) + ) { + throw new UploadError("unsafe upload prefix", 400); + } + return value; +} + +async function digest(data: Uint8Array): Promise { + return Array.from( + new Uint8Array(await crypto.subtle.digest("SHA-256", data as BufferSource)), + (byte) => byte.toString(16).padStart(2, "0"), + ).join(""); +} + +export function createResumableUploadManager( + options: ResumableUploadManagerOptions, +): ResumableUploadManager { + const sessions = options.sessions ?? memoryResumableSessionStore(); + const maxBytes = options.maxBytes ?? 100 * 1024 * 1024; + const defaultChunkSize = options.chunkSize ?? 1024 * 1024; + const maxSessions = options.maxSessions ?? 1000; + const ttlMs = options.ttlMs ?? 24 * 60 * 60_000; + if (!Number.isInteger(maxBytes) || maxBytes < 1) + throw new RangeError("resumable maxBytes must be positive"); + if (!Number.isInteger(defaultChunkSize) || defaultChunkSize < 1) + throw new RangeError("resumable chunkSize must be positive"); + if (!Number.isInteger(maxSessions) || maxSessions < 1) + throw new RangeError("resumable maxSessions must be positive"); + if (!Number.isFinite(ttlMs) || ttlMs <= 0) + throw new RangeError("resumable ttlMs must be positive"); + const prefix = safePrefix(options.prefix ?? "resumable"); + const now = options.now ?? Date.now; + const finalizing = new Map>(); + + const manager: ResumableUploadManager = { + async create(input) { + await manager.prune(); + if (!Number.isInteger(input.size) || input.size < 1 || input.size > maxBytes) + throw new UploadError(`invalid upload size (max ${maxBytes} bytes)`, 413); + const type = input.type || contentTypeOf(input.name); + if (!accepts(options.accept, { type, name: input.name })) + throw new UploadError(`file type not allowed: ${type}`, 415); + if ((await sessions.list()).length >= maxSessions) + throw new UploadError("too many active resumable uploads", 429); + const chunkSize = input.chunkSize ?? defaultChunkSize; + if (!Number.isInteger(chunkSize) || chunkSize < 1 || chunkSize > maxBytes) + throw new UploadError("invalid resumable chunk size", 400); + const id = crypto.randomUUID(); + const extension = extOf(input.name) || extForType(type); + const filename = extension ? `${id}.${extension}` : id; + const timestamp = now(); + const session: ResumableUploadSession = { + id, + key: prefix ? `${prefix}/${filename}` : filename, + name: input.name.split(/[\\/]/).pop()?.slice(0, 255) || "file", + type, + size: input.size, + chunkSize, + totalChunks: Math.ceil(input.size / chunkSize), + createdAt: timestamp, + expiresAt: timestamp + ttlMs, + chunks: {}, + digests: {}, + }; + await sessions.put(session); + return session; + }, + + async uploadChunk(id, index, data, expectedDigest) { + const session = await sessions.get(id); + if (!session || session.expiresAt <= now()) + throw new UploadError("upload session not found or expired", 404); + if (!Number.isInteger(index) || index < 0 || index >= session.totalChunks) + throw new UploadError("invalid chunk index", 400); + const expectedSize = + index === session.totalChunks - 1 + ? session.size - session.chunkSize * (session.totalChunks - 1) + : session.chunkSize; + if (data.byteLength !== expectedSize) + throw new UploadError(`invalid chunk size (expected ${expectedSize})`, 400); + const actualDigest = await digest(data); + if (expectedDigest && expectedDigest.toLowerCase() !== actualDigest) + throw new UploadError("chunk checksum mismatch", 422); + if (session.digests[index] && session.digests[index] !== actualDigest) + throw new UploadError("chunk already uploaded with different content", 409); + session.chunks[index] ??= data.slice(); + session.digests[index] = actualDigest; + await sessions.put(session); + const receivedChunks = Object.keys(session.chunks).length; + if (receivedChunks !== session.totalChunks) { + return { receivedChunks, totalChunks: session.totalChunks, complete: false }; + } + let completion = finalizing.get(id); + if (!completion) { + completion = (async () => { + const output = new Uint8Array(session.size); + let offset = 0; + for (let chunk = 0; chunk < session.totalChunks; chunk++) { + const value = session.chunks[chunk]; + if (!value) throw new UploadError("upload is missing a chunk", 409); + output.set(value, offset); + offset += value.byteLength; + } + await options.driver.put(session.key, output, { + contentType: session.type, + filename: session.name, + }); + await sessions.delete(id); + return { + key: session.key, + url: options.publicUrl?.(session.key) ?? null, + name: session.name, + type: session.type, + size: session.size, + }; + })().finally(() => finalizing.delete(id)); + finalizing.set(id, completion); + } + return { + receivedChunks, + totalChunks: session.totalChunks, + complete: true, + file: await completion, + }; + }, + + async status(id) { + const session = await sessions.get(id); + return session + ? { + received: Object.keys(session.chunks) + .map(Number) + .sort((a, b) => a - b), + totalChunks: session.totalChunks, + expiresAt: session.expiresAt, + } + : null; + }, + async cancel(id) { + if (!(await sessions.get(id))) return false; + await sessions.delete(id); + return true; + }, + async prune() { + let removed = 0; + for (const session of await sessions.list()) { + if (session.expiresAt <= now()) { + await sessions.delete(session.id); + removed++; + } + } + return removed; + }, + }; + return manager; +} diff --git a/packages/uploader/src/upload.ts b/packages/uploader/src/upload.ts index 70cd5074..c8407d48 100644 --- a/packages/uploader/src/upload.ts +++ b/packages/uploader/src/upload.ts @@ -31,6 +31,23 @@ export interface UploadOptions { accept?: string[]; /** Key prefix, e.g. `"avatars"` → keys become `avatars///.`. */ prefix?: string; + /** Virus/DLP/content scanner invoked before bytes enter storage. Throw or return unsafe to reject. */ + scan?: (file: UploadScanInput) => UploadScanResult | Promise; + /** Image/video/indexing hook invoked after storage. Failure removes the just-written object. */ + afterStore?: (file: UploadedFile & { bytes: Uint8Array; store: Store }) => void | Promise; +} + +export interface UploadScanInput { + name: string; + type: string; + size: number; + bytes: Uint8Array; + store: Store; +} +export interface UploadScanResult { + safe: boolean; + reason?: string; + scanner?: string; } /** A 4xx-carrying error so `handleUpload` can map it to a status. */ @@ -123,14 +140,38 @@ export async function upload( } const key = makeKey({ name: file.name, type }, opts.prefix); const data = new Uint8Array(await file.arrayBuffer()); + if (opts.scan) { + const result = await opts.scan({ + name: displayName(file.name), + type, + size: file.size, + bytes: data, + store, + }); + if (!result.safe) + throw new UploadError( + `file rejected by ${result.scanner ?? "security scanner"}${result.reason ? `: ${result.reason}` : ""}`, + 422, + ); + } await store.driver.put(key, data, { contentType: type, filename: displayName(file.name) }); - files.push({ + const uploaded: UploadedFile = { key, url: storedUrl(store, key), name: displayName(file.name), type, size: file.size, - }); + }; + try { + await opts.afterStore?.({ ...uploaded, bytes: data, store }); + } catch (error) { + await store.driver.delete(key); + throw new UploadError( + `post-upload processing failed: ${error instanceof Error ? error.message : String(error)}`, + 422, + ); + } + files.push(uploaded); } return { files }; } diff --git a/packages/uploader/test/helpers.test.ts b/packages/uploader/test/helpers.test.ts new file mode 100644 index 00000000..2389c438 --- /dev/null +++ b/packages/uploader/test/helpers.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; +import { + assertUploadedFiles, + formatFileSize, + uploadAccept, + uploaderAttributes, +} from "../src/index.ts"; + +describe("uploader helper kit", () => { + test("formats sizes and builds uploader attributes", () => { + expect(formatFileSize(1024)).toContain("KB"); + expect(uploadAccept(["image/png", ".jpg"])).toBe("image/png,.jpg"); + expect(uploaderAttributes({ store: "public", multiple: true })["data-uploader"]).toBe("public"); + }); + + test("asserts uploaded file count and status", () => { + const files = [{ name: "a.png", size: 10, type: "image/png", key: "a", url: "/a" }]; + expect(assertUploadedFiles(files, { min: 1, max: 1 })).toEqual(files); + expect(() => assertUploadedFiles([], { min: 1 })).toThrow(); + }); +}); diff --git a/packages/uploader/test/operations.test.ts b/packages/uploader/test/operations.test.ts new file mode 100644 index 00000000..93242d7e --- /dev/null +++ b/packages/uploader/test/operations.test.ts @@ -0,0 +1,89 @@ +import { expect, test } from "bun:test"; +import { + createTemporaryObjectCleaner, + ffmpegVideoTranscoder, + memoryQuotaStore, + multipartUpload, + postgresQuotaStore, +} from "../src/index.ts"; + +test("quota stores enforce durable byte and object limits", async () => { + const quota = memoryQuotaStore(); + expect(await quota.reserve("tenant", 60, { bytes: 100, objects: 2 })).toBeTrue(); + expect(await quota.reserve("tenant", 50, { bytes: 100, objects: 2 })).toBeFalse(); + await quota.release("tenant", 60); + expect(await quota.get("tenant")).toMatchObject({ bytes: 0, objects: 0 }); + const calls: unknown[][] = []; + const sql = postgresQuotaStore({ + async query(_sql: string, parameters?: unknown[]) { + calls.push(parameters ?? []); + return { rows: [{ owner: "tenant" } as T] }; + }, + }); + expect(await sql.reserve("tenant", 10, { bytes: 20 })).toBeTrue(); + expect(calls[0]?.[0]).toBe("tenant"); +}); + +test("multipart uploader limits concurrency, completes and aborts failures", async () => { + const uploaded: number[] = []; + let completed = false; + let aborted = false; + const client = { + async create() { + return "upload"; + }, + async uploadPart(_id: string, _key: string, part: number) { + uploaded.push(part); + return `etag-${part}`; + }, + async complete() { + completed = true; + }, + async abort() { + aborted = true; + }, + }; + await multipartUpload( + client, + "video", + new Uint8Array(11 * 1024 * 1024), + { contentType: "video/mp4" }, + { partBytes: 5 * 1024 * 1024, concurrency: 2 }, + ); + expect(uploaded).toEqual([1, 2, 3]); + expect(completed).toBeTrue(); + expect(aborted).toBeFalse(); +}); + +test("temporary cleanup and video transcoding are bounded and injectable", async () => { + let now = 10; + const deleted: string[] = []; + const cleaner = createTemporaryObjectCleaner( + { + async put() {}, + async get() { + return null; + }, + async delete(key) { + deleted.push(key); + }, + publicUrl() { + return null; + }, + }, + { now: () => now }, + ); + cleaner.track("tmp/a", 5); + expect(await cleaner.cleanup()).toBe(0); + now = 15; + expect(await cleaner.cleanup()).toBe(1); + expect(deleted).toEqual(["tmp/a"]); + let command: string[] = []; + await ffmpegVideoTranscoder({ + spawn(args) { + command = args; + return { exited: Promise.resolve(0) }; + }, + })("input.mov", "output.mp4", { format: "mp4", width: 1280, videoBitrateKbps: 2000 }); + expect(command).toContain("scale=1280:-2"); +}); diff --git a/packages/uploader/test/resumable.test.ts b/packages/uploader/test/resumable.test.ts new file mode 100644 index 00000000..09a50a10 --- /dev/null +++ b/packages/uploader/test/resumable.test.ts @@ -0,0 +1,73 @@ +import { expect, test } from "bun:test"; +import { createResumableUploadManager, memoryResumableSessionStore } from "../src/index.ts"; +import type { StorageDriver } from "../src/index.ts"; + +function driver() { + const objects = new Map(); + const storage: StorageDriver = { + async put(key, data) { + objects.set(key, data.slice()); + }, + async get(key) { + const body = objects.get(key); + return body ? { body, contentType: "application/octet-stream", size: body.length } : null; + }, + async delete(key) { + objects.delete(key); + }, + publicUrl: () => null, + }; + return { storage, objects }; +} + +test("resumable uploads accept out-of-order idempotent chunks and assemble once", async () => { + const { storage, objects } = driver(); + const manager = createResumableUploadManager({ + driver: storage, + chunkSize: 3, + maxBytes: 20, + accept: ["text/plain"], + }); + const session = await manager.create({ name: "hello.txt", type: "text/plain", size: 8 }); + expect(session.totalChunks).toBe(3); + expect((await manager.uploadChunk(session.id, 1, new TextEncoder().encode("lo "))).complete).toBe( + false, + ); + await manager.uploadChunk(session.id, 0, new TextEncoder().encode("hel")); + await manager.uploadChunk(session.id, 0, new TextEncoder().encode("hel")); + const result = await manager.uploadChunk(session.id, 2, new TextEncoder().encode("!!")); + expect(result.complete).toBe(true); + expect(new TextDecoder().decode(objects.get(result.file!.key))).toBe("hello !!"); + expect(await manager.status(session.id)).toBeNull(); +}); + +test("resumable uploads enforce checksums, conflicts, limits, expiry, and cancellation", async () => { + let now = 0; + const { storage } = driver(); + const sessions = memoryResumableSessionStore(); + const manager = createResumableUploadManager({ + driver: storage, + sessions, + chunkSize: 2, + maxBytes: 4, + maxSessions: 1, + ttlMs: 10, + now: () => now, + }); + const session = await manager.create({ name: "data.bin", size: 4 }); + await expect(manager.create({ name: "other.bin", size: 1 })).rejects.toThrow("too many"); + await expect(manager.uploadChunk(session.id, 0, new Uint8Array([1, 2]), "bad")).rejects.toThrow( + "checksum", + ); + await manager.uploadChunk(session.id, 0, new Uint8Array([1, 2])); + await expect(manager.uploadChunk(session.id, 0, new Uint8Array([2, 1]))).rejects.toThrow( + "different content", + ); + expect(await manager.cancel(session.id)).toBe(true); + const expiring = await manager.create({ name: "expire.bin", size: 2 }); + now = 11; + expect(await manager.prune()).toBe(1); + await expect(manager.uploadChunk(expiring.id, 0, new Uint8Array([1, 2]))).rejects.toThrow( + "expired", + ); +}); diff --git a/packages/uploader/test/uploader.test.ts b/packages/uploader/test/uploader.test.ts index 9bdb780a..2abd0420 100644 --- a/packages/uploader/test/uploader.test.ts +++ b/packages/uploader/test/uploader.test.ts @@ -61,3 +61,30 @@ test("signed file tokens reject tampering and expired payloads", async () => { expect(await verifySignedFileToken(`${token}x`, secret, 1_000)).toBeNull(); expect(await verifySignedFileToken(token, secret, 2_000)).toBeNull(); }); + +test("upload scanning rejects unsafe bytes before storage", async () => { + const form = new FormData(); + form.set("file", new File(["virus"], "bad.txt", { type: "text/plain" })); + await expect( + upload("public", new Request("http://test/upload", { method: "POST", body: form }), { + scan: async () => ({ safe: false, scanner: "test-av", reason: "signature" }), + }), + ).rejects.toMatchObject({ status: 422 }); + expect(await getStore("public").driver.get("bad.txt")).toBeNull(); +}); + +test("post-storage processor failure rolls back the object", async () => { + const form = new FormData(); + form.set("file", new File(["image"], "photo.png", { type: "image/png" })); + let key = ""; + await expect( + upload("public", new Request("http://test/upload", { method: "POST", body: form }), { + afterStore(file) { + key = file.key; + throw new Error("transform failed"); + }, + }), + ).rejects.toMatchObject({ status: 422 }); + expect(key).not.toBe(""); + expect(await getStore("public").driver.get(key)).toBeNull(); +}); diff --git a/packages/validation/README.md b/packages/validation/README.md index fdbfacf5..8c1d688d 100644 --- a/packages/validation/README.md +++ b/packages/validation/README.md @@ -4,6 +4,31 @@ Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework. +## Boundary contracts + +Use `ContractRegistry` with `defineContract` or `defineEvent` to publish the +same schema descriptors for APIs, actions, webhooks, realtime, queues, cron, +pub/sub, plugins, configuration, and environment variables. + +```ts +import { ContractRegistry, defineEvent, v } from "@wrnexus/validation"; + +export const contracts = new ContractRegistry().register( + defineEvent({ + name: "user.created", + version: 1, + consumers: ["notification-worker", "audit-service"], + payload: v.object({ userId: v.string().uuid(), createdAt: v.string().date() }), + }), +); +``` + +Export the registry from `app/contracts.ts`, then accept a baseline with +`wrnexus contracts snapshot`. CI can run `wrnexus contracts check`; removed +contracts/fields, required-field additions, type changes, narrowed enums, and +tighter validation fail with stable `WRN-CONTRACT-*` diagnostics and list known +consumers. A generated `wrnexus.contracts.json` can be used instead of a module. + ## Overview Define a schema once with the fluent `v` builder, then reuse it in three places: `.parse()` runs server-side and returns coerced values plus per-field errors; `.describe()` emits a plain-JSON `SchemaDescriptor` that the browser runtime interprets (no `eval`, no bundled validator); and helpers like `parseBody` and `parseEnv` wire schemas straight into API routes and startup config. The server rule logic (`applyRule`/`checkField`) and the client runtime (`VALIDATE_RUNTIME`) mirror each other exactly, so a form validates identically in both places. Schemas are conventionally kept in `app/schemas/`. @@ -178,3 +203,55 @@ const head = `