From eabe6a167bf60c7ab343f750154f11a34959c89f Mon Sep 17 00:00:00 2001 From: t Date: Wed, 3 Jun 2026 12:55:08 +0530 Subject: [PATCH 1/3] feat: type the package.json webjs.* config block webjs reads a webjs.* object from package.json (elide, headers, redirects, csp, trailingSlash, and the body-limit / timeout knobs), but there was no type, schema, or validation, so a typo'd key was silently dropped and the feature it toggled stayed at default with no diagnostic. The config equivalent of an untyped API that fails open. Ship a standard draft-07 JSON Schema (packages/server/webjs-config.schema.json, additionalProperties:false so an unknown key is flagged) plus an exported WebjsConfig type (packages/core/src/webjs-config.d.ts), both mirroring exactly what the server readers accept. The scaffold's .vscode/settings.json associates the schema with package.json's webjs property so editors validate it natively. A drift test asserts the schema covers every key the readers consume, and a tsc fixture proves a wrong-typed or unknown key is a compile error. webjs check stays errors-only (an unknown-key error would be a forward-compat hazard), so the editor schema is the diagnostic path. Closes #259 --- AGENTS.md | 1 + agent-docs/typescript.md | 35 +++ packages/cli/lib/create.js | 4 + packages/cli/templates/AGENTS.md | 9 + packages/core/AGENTS.md | 8 +- packages/core/index.d.ts | 13 ++ packages/core/src/webjs-config.d.ts | 163 +++++++++++++ packages/server/AGENTS.md | 33 ++- packages/server/package.json | 4 +- .../test/config/webjs-config-schema.test.js | 214 ++++++++++++++++++ packages/server/webjs-config.schema.json | 142 ++++++++++++ test/types/webjs-config.test-d.ts | 97 ++++++++ 12 files changed, 720 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/webjs-config.d.ts create mode 100644 packages/server/test/config/webjs-config-schema.test.js create mode 100644 packages/server/webjs-config.schema.json create mode 100644 test/types/webjs-config.test-d.ts diff --git a/AGENTS.md b/AGENTS.md index bca22b4fa..372db8b96 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -291,6 +291,7 @@ The bare `@webjsdev/core` specifier resolves to a BROWSER bundle that drops serv | `WebjsFrame` (``) | Escape-hatch partial-swap region. A frame nav whose response lacks the frame fires a cancelable, bubbling `webjs:frame-missing` event (detail `{ frameId, url, document }`) and leaves the frame unchanged instead of full-swapping; `preventDefault()` hands the outcome to the listener. | | `Metadata` / `MetadataContext` (type-only) | Types the `metadata` / `generateMetadata(ctx)` return + context. `import type { Metadata } from '@webjsdev/core'`. | | `PageProps` / `LayoutProps` / `RouteHandlerContext` (type-only) | Types the page / layout / route-handler args (`{ params, searchParams, url, actionData }`; layouts add `children`). `R` is an optional route literal that narrows `params` against the generated route union. `Route` / `RouteParams` are the href + params helpers. Run `webjs types` to generate the union (see CLI reference). `import type { PageProps } from '@webjsdev/core'`. | +| `WebjsConfig` (type-only) | Types the `webjs` package.json config block (`elide`, `headers`, `redirects`, `trailingSlash`, `csp`, the ingress body-size + timeout caps), with `WebjsHeaderRule` / `WebjsRedirectRule` / `WebjsCspConfig` / `WebjsTrailingSlash` for the nested shapes. A companion JSON Schema (`@webjsdev/server/webjs-config.schema.json`, associated in the scaffold's `.vscode/settings.json`) flags an unknown key in the editor. `import type { WebjsConfig } from '@webjsdev/core'`. | ### Directives, from `import { … } from '@webjsdev/core/directives'` diff --git a/agent-docs/typescript.md b/agent-docs/typescript.md index 287d705ea..4c7e467ff 100644 --- a/agent-docs/typescript.md +++ b/agent-docs/typescript.md @@ -254,6 +254,41 @@ bundler. The mechanism is `generateRouteTypes(appDir)` in (`buildRouteTable`). Output is deterministic (sorted keys), so re-running yields a byte-identical file. +### The `webjs` package.json config block: `WebjsConfig` + JSON Schema + +The `webjs` object in `package.json` (the `elide` / `headers` / `redirects` / +`trailingSlash` / `csp` knobs plus the ingress body-size and timeout caps) has +two typed references, so a typo'd key is diagnosed instead of silently dropped: + +- **A JSON Schema**, `packages/server/webjs-config.schema.json` (shipped in the + `@webjsdev/server` package). The scaffold's `.vscode/settings.json` associates + it with the `webjs` property of `package.json`, so VS Code flags an unknown + key natively while you edit the JSON. `additionalProperties: false` on the + block is what turns a typo into an editor warning. +- **The `WebjsConfig` type**, exported from `@webjsdev/core`, a typed reference + for an agent or human authoring the block (with `WebjsHeaderRule`, + `WebjsRedirectRule`, `WebjsCspConfig`, `WebjsTrailingSlash` for the nested + shapes). + +```ts +import type { WebjsConfig } from '@webjsdev/core'; + +const config: WebjsConfig = { + trailingSlash: 'never', + csp: true, + redirects: [{ source: '/old', destination: '/new' }], +}; +``` + +The schema and the type mirror what the server readers actually consume +(`readElideEnabled`, `compileHeaderRules`, `compileRedirectRules` / +`readTrailingSlashPolicy`, `readCspConfig`, `readBodyLimits` / +`computeServerTimeouts`). Adding a `webjs.*` key means updating the schema, the +type, AND the reader in lockstep, the one procedure documented in +`packages/server/AGENTS.md`. A drift test +(`packages/server/test/config/webjs-config-schema.test.js`) fails if the schema +and the reader key set diverge. + ### TypeScript is not required JS + JSDoc gets the same call-site type safety. The TypeScript language diff --git a/packages/cli/lib/create.js b/packages/cli/lib/create.js index d5520ca03..ccc69de9a 100644 --- a/packages/cli/lib/create.js +++ b/packages/cli/lib/create.js @@ -413,6 +413,10 @@ export async function scaffoldApp(name, cwd, opts = {}) { // to main, mirroring the webjs framework's own CI. '.github/workflows/ci.yml', '.editorconfig', + // VS Code: associate the published webjs-config JSON Schema with the + // package.json `webjs` block, so an unknown / typo'd key (#259) is + // flagged natively in the editor instead of silently dropped. + '.vscode/settings.json', // Production / deploy scaffolding. `docker compose up --build` runs // the app locally with the same Dockerfile production builds from. 'Dockerfile', diff --git a/packages/cli/templates/AGENTS.md b/packages/cli/templates/AGENTS.md index e82131f45..37b8d4009 100644 --- a/packages/cli/templates/AGENTS.md +++ b/packages/cli/templates/AGENTS.md @@ -112,6 +112,15 @@ layered on top: See [docs.webjs.com → Editor setup](https://docs.webjs.com/docs/editor-setup) for the full walkthrough. +**Config validation in `package.json`.** The scaffold ships +`.vscode/settings.json`, which associates the published webjs-config JSON +Schema (`@webjsdev/server/webjs-config.schema.json`) with the `webjs` block +of `package.json`. In VS Code an unknown / typo'd `webjs.*` key (`redirect` +for `redirects`, say) is then flagged inline instead of silently dropped to +the default. The same shape is typed by the `WebjsConfig` type from +`@webjsdev/core` (`import type { WebjsConfig } from '@webjsdev/core'`) for a +typed reference. + ## UI components: Webjs UI (preinstalled) This scaffold ships with the standard Webjs UI component kit diff --git a/packages/core/AGENTS.md b/packages/core/AGENTS.md index af256405f..8695d445b 100644 --- a/packages/core/AGENTS.md +++ b/packages/core/AGENTS.md @@ -64,7 +64,13 @@ typing lives in `src/component.d.ts`; the page-metadata typing `src/metadata.d.ts`; the typed page / layout / route-handler props plus the opt-in route union (`PageProps`, `LayoutProps`, `RouteHandlerContext`, `Route`, `RouteParams`, and the `WebjsRoutes` / `RouteParamMap` augmentation targets, -#258) live in `src/routes.d.ts`. All are pure declaration files (erased at +#258) live in `src/routes.d.ts`. The `webjs` package.json config-block typing +(`WebjsConfig` plus the nested `WebjsHeaderRule` / `WebjsRedirectRule` / +`WebjsCspConfig` / `WebjsTrailingSlash`, #259) lives in +`src/webjs-config.d.ts`; it mirrors the `@webjsdev/server` config readers and +the companion JSON Schema (`packages/server/webjs-config.schema.json`), and +those three MUST stay in lockstep (the procedure is documented in +`packages/server/AGENTS.md`). All are pure declaration files (erased at runtime, zero build cost). A page imports them with `import type { Metadata, PageProps } from '@webjsdev/core'`. The `Metadata` and `PageProps` / `LayoutProps` shapes MUST stay in lockstep with what diff --git a/packages/core/index.d.ts b/packages/core/index.d.ts index c486bf8ad..50c155140 100644 --- a/packages/core/index.d.ts +++ b/packages/core/index.d.ts @@ -40,6 +40,19 @@ export type { RouteParamMap, } from './src/routes.d.ts'; +// The package.json `webjs` config block (#259). Typed reference for the +// elide / headers / redirects / trailingSlash / csp / ingress-limit knobs; +// the companion JSON Schema (packages/server/webjs-config.schema.json) gives +// editors native validation of package.json itself. +export type { + WebjsConfig, + WebjsHeaderRule, + WebjsHeaderDirective, + WebjsRedirectRule, + WebjsTrailingSlash, + WebjsCspConfig, +} from './src/webjs-config.d.ts'; + export { html, isTemplate, MARKER } from './src/html.js'; export { css, isCSS, adoptStyles, stylesToString } from './src/css.js'; export { register, lookup, lookupModuleUrl, isLazy, allTags, primeModuleUrl, tagOf } from './src/registry.js'; diff --git a/packages/core/src/webjs-config.d.ts b/packages/core/src/webjs-config.d.ts new file mode 100644 index 000000000..eebcca4fa --- /dev/null +++ b/packages/core/src/webjs-config.d.ts @@ -0,0 +1,163 @@ +/** + * TypeScript overlay for the `webjs` config block in a webjs app's + * package.json. + * + * // package.json is JSON, so author it there, but a typed reference + * // helps an agent or a human author the block correctly: + * import type { WebjsConfig } from '@webjsdev/core'; + * const config: WebjsConfig = { trailingSlash: 'never', csp: true }; + * + * The server reads this object key by key. Without a type or schema a + * typo'd key (e.g. `redirect` for `redirects`) was silently dropped and + * the feature stayed at its default with no diagnostic. This type plus + * the published JSON Schema close that gap. + * + * LOCKSTEP: this file, the JSON Schema at + * packages/server/webjs-config.schema.json, and the server reader + * functions MUST stay in sync. The readers are: readElideEnabled + * (dev.js, elide), compileHeaderRules (headers.js, headers), + * compileRedirectRules / readTrailingSlashPolicy (redirects.js, + * redirects / trailingSlash), readCspConfig (csp.js, csp), and + * readBodyLimits / computeServerTimeouts (body-limit.js, the byte caps + * and timeouts). Adding a `webjs.*` key means updating all three places. + * See packages/server/AGENTS.md for the one documented procedure. + * + * Every key is optional (the whole block is optional and every key has a + * default). Zero runtime cost: nothing in this file ships to the browser. + */ + +/** One header directive in a `webjs.headers` rule. */ +export interface WebjsHeaderDirective { + /** Header name, e.g. `X-Frame-Options`. */ + key: string; + /** + * Header value. A `null` (or `false`) value REMOVES the header on a + * match, the escape hatch that drops a secure default on a path. + */ + value?: string | null | boolean; +} + +/** One per-path response-header rule in `webjs.headers`. */ +export interface WebjsHeaderRule { + /** + * Path pattern matched with the native URLPattern API, so `:param` and + * `:rest*` syntax works. + */ + source: string; + /** Header directives applied on a match. */ + headers: WebjsHeaderDirective[]; +} + +/** One declarative redirect rule in `webjs.redirects`. */ +export interface WebjsRedirectRule { + /** + * Path pattern matched with the native URLPattern API, so `:param` and + * `:rest*` syntax works. + */ + source: string; + /** + * Target path, a path referencing named groups captured by `source`, + * or an absolute URL. The incoming query string is preserved and + * merged onto the destination. + */ + destination: string; + /** + * `true` (the default) is a 308 Permanent Redirect, `false` is a 307 + * Temporary Redirect. Both preserve the request method and body. + * `statusCode` wins over this when set. + */ + permanent?: boolean; + /** + * Explicit redirect status, for a tool needing a legacy code. Wins + * over `permanent`. One of 301, 302, 303, 307, 308. + */ + statusCode?: 301 | 302 | 303 | 307 | 308; +} + +/** The trailing-slash canonicalization policy in `webjs.trailingSlash`. */ +export type WebjsTrailingSlash = 'never' | 'always' | 'ignore'; + +/** The object form of `webjs.csp` (the non-boolean shape). */ +export interface WebjsCspConfig { + /** + * Directive map merged over the strict defaults, e.g. + * `{ 'connect-src': "'self' https://api.example.com" }`. A `null` / + * `false` / `''` value drops a default directive. A `__NONCE__` token + * in a value is replaced with the per-request nonce. + */ + directives?: Record; + /** + * `true` emits `Content-Security-Policy-Report-Only` instead of the + * enforcing header (the staged-rollout path). + */ + reportOnly?: boolean; +} + +/** + * The `webjs` object in a webjs app's package.json. Every key is + * optional. Mirrors what the server readers actually consume, NOT a + * Next.js superset. + */ +export interface WebjsConfig { + /** + * Display-only and inert-route dead-JS elision switch. Default `true`. + * Set to `false` to ship every module's JS app-wide. The `WEBJS_ELIDE` + * env override wins over this. + */ + elide?: boolean; + + /** Per-path response-header rules, shaped like Next's. */ + headers?: WebjsHeaderRule[]; + + /** Declarative permanent / temporary redirects for moved URLs. */ + redirects?: WebjsRedirectRule[]; + + /** + * Trailing-slash canonicalization policy. Default `'ignore'` (no-op). + * `'never'` strips a trailing slash, `'always'` adds one (both via a + * 308 redirect). + */ + trailingSlash?: WebjsTrailingSlash; + + /** + * Content-Security-Policy config. Off by default. `true` enables a + * strict nonce-based default policy. An object customizes directives + * and report-only mode. + */ + csp?: boolean | WebjsCspConfig; + + /** + * JSON / RPC request body cap in bytes. Default 1048576 (1 MiB). `0` + * disables the cap. The `WEBJS_MAX_BODY_BYTES` env override wins. + */ + maxBodyBytes?: number; + + /** + * Form / multipart request body cap in bytes. Default 10485760 (10 + * MiB). `0` disables the cap. The `WEBJS_MAX_MULTIPART_BYTES` env + * override wins. + */ + maxMultipartBytes?: number; + + /** + * Max time in ms to receive the ENTIRE request (headers plus body). + * Default 30000. `0` disables the timeout. The + * `WEBJS_REQUEST_TIMEOUT_MS` env override wins. + */ + requestTimeoutMs?: number; + + /** + * Max time in ms to receive just the request headers. Default 20000. + * Clamped strictly under `requestTimeoutMs` per node semantics. `0` + * disables the timeout. The `WEBJS_HEADERS_TIMEOUT_MS` env override + * wins. + */ + headersTimeoutMs?: number; + + /** + * Idle time in ms before a kept-alive socket is closed. Default 5000. + * `0` disables the timeout. The `WEBJS_KEEP_ALIVE_TIMEOUT_MS` env + * override wins. + */ + keepAliveTimeoutMs?: number; +} diff --git a/packages/server/AGENTS.md b/packages/server/AGENTS.md index 4c11fd456..3e062bbe0 100644 --- a/packages/server/AGENTS.md +++ b/packages/server/AGENTS.md @@ -72,7 +72,38 @@ with metadata, Suspense, streaming) for HTML, or `api.js` / See [`index.js`](./index.js) and [`package.json` exports](./package.json). The `./check` subpath is exported separately so the CLI's `webjs check` -can load it without booting the full server. +can load it without booting the full server. The +`./webjs-config.schema.json` subpath publishes the config JSON Schema +(below) so an editor can resolve it from +`node_modules/@webjsdev/server/webjs-config.schema.json`. + +## The `webjs` package.json config block (typed surface, #259) + +The `webjs.*` keys an app sets in `package.json` are typed and validated +in THREE co-located places that MUST stay in lockstep: + +1. **The JSON Schema**, [`webjs-config.schema.json`](./webjs-config.schema.json) + (in this package's `files` allowlist + `exports`). `additionalProperties: + false` flags an unknown / typo'd key; the scaffold's `.vscode/settings.json` + associates it with the `webjs` property of `package.json` so VS Code + validates the block natively. This is the primary "fails-open -> diagnosed" + fix: a typo used to be silently dropped to the default. +2. **The TS type** `WebjsConfig` in + `packages/core/src/webjs-config.d.ts` (re-exported from + `@webjsdev/core`), the typed reference an agent or human authors against. +3. **The reader functions** that consume each key: `readElideEnabled` + (`dev.js`, `elide`), `compileHeaderRules` (`headers.js`, `headers`), + `compileRedirectRules` / `readTrailingSlashPolicy` (`redirects.js`, + `redirects` / `trailingSlash`), `readCspConfig` (`csp.js`, `csp`), and + `readBodyLimits` / `computeServerTimeouts` (`body-limit.js`, the byte + caps + timeouts). + +**To add or change a `webjs.*` key, update all three (schema + type + +reader), and the `KNOWN_KEYS` list in the drift test.** The drift test +`test/config/webjs-config-schema.test.js` asserts the schema property set +and the reader key set never diverge (a counterfactual unknown key proves +`additionalProperties:false` would flag it); the type fixture +`test/types/webjs-config.test-d.ts` asserts the type matches. ## Package-specific invariants diff --git a/packages/server/package.json b/packages/server/package.json index 990923570..59ae5b904 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -6,11 +6,13 @@ "main": "index.js", "exports": { ".": "./index.js", - "./check": "./src/check.js" + "./check": "./src/check.js", + "./webjs-config.schema.json": "./webjs-config.schema.json" }, "files": [ "index.js", "src", + "webjs-config.schema.json", "README.md" ], "dependencies": { diff --git a/packages/server/test/config/webjs-config-schema.test.js b/packages/server/test/config/webjs-config-schema.test.js new file mode 100644 index 000000000..7766f8c2d --- /dev/null +++ b/packages/server/test/config/webjs-config-schema.test.js @@ -0,0 +1,214 @@ +/** + * Drift guard for the `webjs` config JSON Schema (#259). + * + * The schema at packages/server/webjs-config.schema.json describes the + * `webjs.*` package.json block that the server readers consume. This test + * keeps the schema honest against the CODE: every key a reader actually + * accepts must be a property in the schema, and a deliberately-unknown key + * must NOT be (so `additionalProperties: false` genuinely flags a typo). + * + * Counterfactual: if a reader gains a new `webjs.*` key and the schema is + * not updated, `KNOWN_KEYS` (kept in sync by the author) drives the + * "every known key is in the schema" assertion to fail; and the + * `notARealKey` assertion proves the schema rejects an unknown key, which + * is the whole point of the additionalProperties guard. + * + * Dependency-free: webjs ships no JSON-Schema validator (no ajv), so the + * structural assertions plus a tiny hand-rolled checker for the handful of + * constraints we care about stand in for full validation. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const schemaPath = fileURLToPath( + new URL('../../webjs-config.schema.json', import.meta.url), +); + +/** + * The complete set of `webjs.*` config keys the server readers accept. + * Hand-maintained alongside the readers (the lockstep procedure in + * packages/server/AGENTS.md). The drift assertions below cross-check this + * list against the schema in BOTH directions. + */ +const KNOWN_KEYS = [ + 'elide', // readElideEnabled (dev.js) + 'headers', // compileHeaderRules (headers.js) + 'redirects', // compileRedirectRules (redirects.js) + 'trailingSlash', // readTrailingSlashPolicy (redirects.js) + 'csp', // readCspConfig (csp.js) + 'maxBodyBytes', // readBodyLimits (body-limit.js) + 'maxMultipartBytes', // readBodyLimits (body-limit.js) + 'requestTimeoutMs', // computeServerTimeouts (body-limit.js) + 'headersTimeoutMs', // computeServerTimeouts (body-limit.js) + 'keepAliveTimeoutMs', // computeServerTimeouts (body-limit.js) +]; + +test('schema file is valid JSON and parses', () => { + const raw = readFileSync(schemaPath, 'utf8'); + const schema = JSON.parse(raw); + assert.equal(typeof schema, 'object'); + assert.equal(schema.$schema, 'http://json-schema.org/draft-07/schema#'); + assert.equal(schema.type, 'object'); + assert.ok(schema.title, 'has a title'); + assert.ok(schema.description, 'has a description'); +}); + +test('schema seals the block so an unknown key is flagged', () => { + const schema = JSON.parse(readFileSync(schemaPath, 'utf8')); + assert.equal( + schema.additionalProperties, + false, + 'additionalProperties must be false so a typo is diagnosed', + ); +}); + +test('every known reader key is a schema property', () => { + const schema = JSON.parse(readFileSync(schemaPath, 'utf8')); + const props = schema.properties || {}; + for (const key of KNOWN_KEYS) { + assert.ok( + Object.prototype.hasOwnProperty.call(props, key), + `schema is missing the "${key}" property (a reader accepts it)`, + ); + assert.ok(props[key].description, `"${key}" must carry a description`); + } +}); + +test('schema declares no key the readers do not consume', () => { + const schema = JSON.parse(readFileSync(schemaPath, 'utf8')); + const props = Object.keys(schema.properties || {}); + for (const key of props) { + assert.ok( + KNOWN_KEYS.includes(key), + `schema declares "${key}" but no reader consumes it (stale schema or stale KNOWN_KEYS)`, + ); + } + assert.equal(props.length, KNOWN_KEYS.length, 'no extra / missing keys'); +}); + +test('a deliberately-unknown key is NOT in the schema (additionalProperties would flag it)', () => { + const schema = JSON.parse(readFileSync(schemaPath, 'utf8')); + const props = schema.properties || {}; + assert.equal( + Object.prototype.hasOwnProperty.call(props, 'notARealKey'), + false, + 'an unknown key must not be a property; additionalProperties:false then flags it', + ); +}); + +test('key shapes match the reader contracts', () => { + const schema = JSON.parse(readFileSync(schemaPath, 'utf8')); + const p = schema.properties; + + // elide is a boolean (readElideEnabled checks `=== false`). + assert.equal(p.elide.type, 'boolean'); + + // trailingSlash is the three-value enum readTrailingSlashPolicy accepts. + assert.deepEqual(p.trailingSlash.enum, ['never', 'always', 'ignore']); + + // The numeric ingress knobs are integers with a 0 floor (0 disables). + for (const k of [ + 'maxBodyBytes', + 'maxMultipartBytes', + 'requestTimeoutMs', + 'headersTimeoutMs', + 'keepAliveTimeoutMs', + ]) { + assert.equal(p[k].type, 'integer', `${k} is an integer`); + assert.equal(p[k].minimum, 0, `${k} floors at 0`); + } + + // headers items require source + a headers directive array. + assert.deepEqual(p.headers.items.required, ['source', 'headers']); + + // redirects items require source + destination; statusCode is the + // redirect-code enum resolveStatus() allows. + assert.deepEqual(p.redirects.items.required, ['source', 'destination']); + assert.deepEqual( + p.redirects.items.properties.statusCode.enum, + [301, 302, 303, 307, 308], + ); + + // csp is boolean | object (readCspConfig accepts true / object). + assert.ok(Array.isArray(p.csp.oneOf), 'csp is a oneOf(boolean, object)'); +}); + +/** + * A tiny structural validator standing in for ajv (which the repo does not + * ship). It only checks the constraints this schema relies on: known-key + * membership, `additionalProperties: false`, a top-level `type`, and the + * `enum` on a scalar leaf. Enough to prove a few example configs pass and a + * typo'd / bad-enum config fails, without adding a dependency. + * + * @param {Record} schema the webjs-block schema + * @param {Record} value a candidate `webjs` object + * @returns {string[]} a list of validation errors (empty = valid) + */ +function validateWebjsBlock(schema, value) { + /** @type {string[]} */ + const errors = []; + const props = schema.properties || {}; + for (const [key, raw] of Object.entries(value)) { + if (schema.additionalProperties === false && !(key in props)) { + errors.push(`unknown key "${key}"`); + continue; + } + const def = /** @type {any} */ (props[key]); + if (!def) continue; + if (def.enum && !def.enum.includes(raw)) { + errors.push(`"${key}" must be one of ${JSON.stringify(def.enum)}`); + } + if (def.type === 'boolean' && typeof raw !== 'boolean') { + errors.push(`"${key}" must be a boolean`); + } + if (def.type === 'integer' && !Number.isInteger(raw)) { + errors.push(`"${key}" must be an integer`); + } + } + return errors; +} + +test('representative valid configs pass the structural validator', () => { + const schema = JSON.parse(readFileSync(schemaPath, 'utf8')); + const valids = [ + { elide: false }, + { trailingSlash: 'never' }, + { csp: true }, + { csp: { directives: { 'connect-src': "'self'" }, reportOnly: true } }, + { + headers: [{ source: '/embed/:p*', headers: [{ key: 'X-Frame-Options', value: null }] }], + redirects: [{ source: '/old', destination: '/new', permanent: false }], + maxBodyBytes: 262144, + requestTimeoutMs: 0, + }, + ]; + for (const v of valids) { + assert.deepEqual( + validateWebjsBlock(schema, v), + [], + `expected ${JSON.stringify(v)} to validate`, + ); + } +}); + +test('typo and bad-enum configs are rejected by the validator', () => { + const schema = JSON.parse(readFileSync(schemaPath, 'utf8')); + // A typo'd key (the exact "silently dropped" failure #259 fixes). + assert.ok( + validateWebjsBlock(schema, { redirect: [] }).length > 0, + 'a typo key must be rejected', + ); + // A bad enum value. + assert.ok( + validateWebjsBlock(schema, { trailingSlash: 'sometimes' }).length > 0, + 'a bad trailingSlash enum must be rejected', + ); + // A wrong-typed elide. + assert.ok( + validateWebjsBlock(schema, { elide: 'yes' }).length > 0, + 'a non-boolean elide must be rejected', + ); +}); diff --git a/packages/server/webjs-config.schema.json b/packages/server/webjs-config.schema.json new file mode 100644 index 000000000..99811104a --- /dev/null +++ b/packages/server/webjs-config.schema.json @@ -0,0 +1,142 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://webjs.dev/schemas/webjs-config.schema.json", + "title": "webjs config block", + "description": "Schema for the `webjs` object in a webjs app's package.json. Each key maps to a documented server reader. An unknown key is flagged (additionalProperties is false) so a typo no longer silently falls back to the default.", + "$comment": "Single source of truth lives in THREE co-located places that must stay in lockstep: this schema, the TS type at packages/core/src/webjs-config.d.ts, and the server reader functions (readElideEnabled in dev.js, compileHeaderRules in headers.js, compileRedirectRules / readTrailingSlashPolicy in redirects.js, readCspConfig in csp.js, readBodyLimits / computeServerTimeouts in body-limit.js). Adding a webjs.* key means updating all three. See packages/server/AGENTS.md for the one documented procedure.", + "type": "object", + "additionalProperties": false, + "properties": { + "elide": { + "description": "Display-only and inert-route dead-JS elision switch. Default true. Set to false to ship every module's JS app-wide, as before the feature existed. Read by readElideEnabled in dev.js; the WEBJS_ELIDE env override wins over this.", + "type": "boolean", + "default": true + }, + "headers": { + "description": "Per-path response-header rules, shaped like Next's. Each rule pairs a URLPattern source against header directives. Read by compileHeaderRules in headers.js.", + "type": "array", + "items": { + "type": "object", + "required": ["source", "headers"], + "properties": { + "source": { + "description": "Path pattern matched with the native URLPattern API (so :param and :rest* syntax works).", + "type": "string" + }, + "headers": { + "description": "Header directives applied on a match.", + "type": "array", + "items": { + "type": "object", + "required": ["key"], + "properties": { + "key": { + "description": "Header name, e.g. X-Frame-Options.", + "type": "string" + }, + "value": { + "description": "Header value. A null (or false) value REMOVES the header on a match, the escape hatch that drops a secure default on a path.", + "type": ["string", "null", "boolean"] + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + }, + "redirects": { + "description": "Declarative permanent / temporary redirects for moved URLs. Compiled once at boot by compileRedirectRules in redirects.js. A malformed entry is dropped with a warning.", + "type": "array", + "items": { + "type": "object", + "required": ["source", "destination"], + "properties": { + "source": { + "description": "Path pattern matched with the native URLPattern API (so :param and :rest* syntax works).", + "type": "string" + }, + "destination": { + "description": "Target path, a path referencing named groups captured by source, or an absolute URL. The incoming query string is preserved and merged onto the destination.", + "type": "string" + }, + "permanent": { + "description": "true (the default) is a 308 Permanent Redirect, false is a 307 Temporary Redirect. Both preserve the request method and body. statusCode wins over this when set.", + "type": "boolean", + "default": true + }, + "statusCode": { + "description": "Explicit redirect status, for a tool needing a legacy code. Wins over permanent. One of 301, 302, 303, 307, 308.", + "type": "integer", + "enum": [301, 302, 303, 307, 308] + } + }, + "additionalProperties": false + } + }, + "trailingSlash": { + "description": "Trailing-slash canonicalization policy. 'never' strips a trailing slash, 'always' adds one (both via a 308 redirect), 'ignore' (the default) does nothing. An unrecognized value is treated as 'ignore'. Read by readTrailingSlashPolicy in redirects.js.", + "type": "string", + "enum": ["never", "always", "ignore"], + "default": "ignore" + }, + "csp": { + "description": "Content-Security-Policy config. Off by default. true enables a strict nonce-based default policy. An object customizes directives and report-only mode. Read by readCspConfig in csp.js.", + "oneOf": [ + { + "description": "true enables the strict default policy, false (or absence) disables CSP.", + "type": "boolean" + }, + { + "description": "Custom config. directives merges over the strict defaults (a null / false / '' value drops a default directive); reportOnly emits the Content-Security-Policy-Report-Only header. A bare directive map (without a directives key) is also accepted.", + "type": "object", + "properties": { + "directives": { + "description": "Directive map merged over the strict defaults, e.g. { \"connect-src\": \"'self' https://api.example.com\" }. A __NONCE__ token in a value is replaced with the per-request nonce.", + "type": "object", + "additionalProperties": { + "type": ["string", "null", "boolean"] + } + }, + "reportOnly": { + "description": "true emits Content-Security-Policy-Report-Only instead of the enforcing header (the staged-rollout path).", + "type": "boolean", + "default": false + } + } + } + ] + }, + "maxBodyBytes": { + "description": "JSON / RPC request body cap in bytes. Default 1048576 (1 MiB). A value of 0 disables the cap. The WEBJS_MAX_BODY_BYTES env override wins. Read by readBodyLimits in body-limit.js.", + "type": "integer", + "minimum": 0, + "default": 1048576 + }, + "maxMultipartBytes": { + "description": "Form / multipart request body cap in bytes. Default 10485760 (10 MiB). A value of 0 disables the cap. The WEBJS_MAX_MULTIPART_BYTES env override wins. Read by readBodyLimits in body-limit.js.", + "type": "integer", + "minimum": 0, + "default": 10485760 + }, + "requestTimeoutMs": { + "description": "Max time in ms to receive the ENTIRE request (headers plus body). Default 30000. A value of 0 disables the timeout. The WEBJS_REQUEST_TIMEOUT_MS env override wins. Read by computeServerTimeouts in body-limit.js.", + "type": "integer", + "minimum": 0, + "default": 30000 + }, + "headersTimeoutMs": { + "description": "Max time in ms to receive just the request headers. Default 20000. Clamped strictly under requestTimeoutMs per node semantics. A value of 0 disables the timeout. The WEBJS_HEADERS_TIMEOUT_MS env override wins. Read by computeServerTimeouts in body-limit.js.", + "type": "integer", + "minimum": 0, + "default": 20000 + }, + "keepAliveTimeoutMs": { + "description": "Idle time in ms before a kept-alive socket is closed. Default 5000. A value of 0 disables the timeout. The WEBJS_KEEP_ALIVE_TIMEOUT_MS env override wins. Read by computeServerTimeouts in body-limit.js.", + "type": "integer", + "minimum": 0, + "default": 5000 + } + } +} diff --git a/test/types/webjs-config.test-d.ts b/test/types/webjs-config.test-d.ts new file mode 100644 index 000000000..e8750337b --- /dev/null +++ b/test/types/webjs-config.test-d.ts @@ -0,0 +1,97 @@ +/** + * Compile-time type tests for the `WebjsConfig` type (#259). + * + * NOT executed by node:test. tsserver (your editor) and the + * `type-fixtures.test.mjs` runner consume it via `tsc --noEmit`. A valid + * config must type-check clean; every `// @ts-expect-error` line asserts + * that a typo or wrong-typed value is REJECTED. tsc fails with an + * "unused @ts-expect-error" if the type ever widens to accept the bad + * value, so each one is a self-checking counterfactual: widening the type + * to accept a typo, or dropping a field, breaks the build here. + */ + +import type { + WebjsConfig, + WebjsHeaderRule, + WebjsRedirectRule, + WebjsCspConfig, + WebjsTrailingSlash, +} from '@webjsdev/core'; + +/* ------------- A fully-populated, valid config ------------- */ + +const full: WebjsConfig = { + elide: false, + headers: [ + { source: '/embed/:path*', headers: [{ key: 'X-Frame-Options', value: null }] }, + { source: '/app/:path*', headers: [{ key: 'X-Frame-Options', value: 'DENY' }] }, + ], + redirects: [ + { source: '/old', destination: '/new' }, + { source: '/blog/:slug', destination: '/posts/:slug', permanent: false }, + { source: '/legacy', destination: '/', statusCode: 301 }, + ], + trailingSlash: 'never', + csp: { directives: { 'connect-src': "'self' https://api.example.com" }, reportOnly: true }, + maxBodyBytes: 262144, + maxMultipartBytes: 5242880, + requestTimeoutMs: 30000, + headersTimeoutMs: 20000, + keepAliveTimeoutMs: 5000, +}; +void full; + +/* ------------- The minimal / boolean-csp forms ------------- */ + +const minimal: WebjsConfig = {}; +void minimal; + +const cspBoolean: WebjsConfig = { csp: true }; +void cspBoolean; + +/* ------------- Nested type aliases are usable directly ------------- */ + +const headerRule: WebjsHeaderRule = { + source: '/x', + headers: [{ key: 'X-Test' }, { key: 'X-Drop', value: null }], +}; +void headerRule; + +const redirectRule: WebjsRedirectRule = { source: '/a', destination: '/b' }; +void redirectRule; + +const cspConfig: WebjsCspConfig = { reportOnly: false }; +void cspConfig; + +const slash: WebjsTrailingSlash = 'always'; +void slash; + +/* ------------- Counterfactuals (each MUST be an error) ------------- */ + +// @ts-expect-error elide is a boolean, not a string. +const badElide: WebjsConfig = { elide: 'yes' }; +void badElide; + +// @ts-expect-error trailingSlash is a fixed enum; 'sometimes' is not a member. +const badEnum: WebjsConfig = { trailingSlash: 'sometimes' }; +void badEnum; + +// @ts-expect-error an unknown key is rejected under excess-property checking. +const unknownKey: WebjsConfig = { notAKey: 1 }; +void unknownKey; + +// @ts-expect-error a typo'd key (`redirect` for `redirects`) is rejected. +const typoKey: WebjsConfig = { redirect: [] }; +void typoKey; + +// @ts-expect-error statusCode is a fixed redirect-code union; 200 is not allowed. +const badStatus: WebjsConfig = { redirects: [{ source: '/a', destination: '/b', statusCode: 200 }] }; +void badStatus; + +// @ts-expect-error a redirect rule requires a destination. +const missingDest: WebjsConfig = { redirects: [{ source: '/a' }] }; +void missingDest; + +// @ts-expect-error a numeric knob is a number, not a string. +const badNumber: WebjsConfig = { maxBodyBytes: '1mb' }; +void badNumber; From 3500fbd7ff91558638ed02b4f7417adcbfa923a1 Mon Sep 17 00:00:00 2001 From: t Date: Wed, 3 Jun 2026 13:01:50 +0530 Subject: [PATCH 2/3] fix: ship the scaffold .vscode/settings.json (was gitignored) The #259 editor-association feature added packages/cli/templates/.vscode/settings.json (the webjs-config JSON Schema association for package.json), but both the root and template .gitignore exclude .vscode/, so the file was never committed and the scaffold copy step (guarded by existsSync) silently skipped it on any clean checkout or published package. The schema-association half of the feature reached zero scaffolded apps. Re-include the file in both .gitignore files (the same .vscode/* plus !.vscode/settings.json shape the .webjs/ vendor exception uses, since a bare .vscode/ dir exclusion blocks any child negation), stage the template file, and assert in scaffold-integration that a generated app contains .vscode/settings.json with the schema $ref so this cannot regress silently. --- .gitignore | 5 +++++ packages/cli/templates/.gitignore | 8 +++++++- packages/cli/templates/.vscode/settings.json | 15 +++++++++++++++ test/scaffolds/scaffold-integration.test.js | 13 +++++++++++++ 4 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 packages/cli/templates/.vscode/settings.json diff --git a/.gitignore b/.gitignore index e7f498476..e9319dfc9 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,11 @@ Thumbs.db # editors .vscode/ .idea/ +# Exception: the scaffold ships a VS Code settings file (the webjs-config +# JSON Schema association, #259) that MUST be committed so `webjs create` +# copies it into new apps. The repo's own top-level .vscode/ stays ignored. +!packages/cli/templates/.vscode/ +!packages/cli/templates/.vscode/settings.json # AI assistants - local session state, scheduled-task locks, etc. # Keep repo-shared config (settings.json + hooks scripts) tracked so diff --git a/packages/cli/templates/.gitignore b/packages/cli/templates/.gitignore index 9a160ffc2..ba61b2d4e 100644 --- a/packages/cli/templates/.gitignore +++ b/packages/cli/templates/.gitignore @@ -40,7 +40,13 @@ npm-debug.log* Thumbs.db # editors -.vscode/ +# `.vscode/*` ignored, but `.vscode/settings.json` (the webjs-config JSON +# Schema association, #259) is committed so the editor validates package.json's +# webjs block out of the box. Same gitignore shape as `.webjs/*` above: a bare +# `.vscode/` would exclude the directory and no negation could re-include a +# child, so the settings file would silently never ship. +.vscode/* +!.vscode/settings.json .idea/ # test artifacts diff --git a/packages/cli/templates/.vscode/settings.json b/packages/cli/templates/.vscode/settings.json new file mode 100644 index 000000000..c123bf934 --- /dev/null +++ b/packages/cli/templates/.vscode/settings.json @@ -0,0 +1,15 @@ +{ + "json.schemas": [ + { + "fileMatch": ["/package.json"], + "schema": { + "type": "object", + "properties": { + "webjs": { + "$ref": "./node_modules/@webjsdev/server/webjs-config.schema.json" + } + } + } + } + ] +} diff --git a/test/scaffolds/scaffold-integration.test.js b/test/scaffolds/scaffold-integration.test.js index d9e5e2db6..85f842cc7 100644 --- a/test/scaffolds/scaffold-integration.test.js +++ b/test/scaffolds/scaffold-integration.test.js @@ -58,6 +58,19 @@ test('scaffoldApp full-stack: writes the canonical full-stack app layout', async assert.ok(existsSync(join(appDir, f)), `${f} should exist`); } + // #259: the VS Code settings that associate the webjs-config JSON Schema + // with package.json's `webjs` block must reach the scaffolded app. This + // file is under a `.vscode/` dir that .gitignore would normally exclude, so + // it is a regression guard against the template silently not shipping. + const vscodePath = join(appDir, '.vscode', 'settings.json'); + assert.ok(existsSync(vscodePath), '.vscode/settings.json should exist'); + const vscode = JSON.parse(readFileSync(vscodePath, 'utf8')); + const webjsSchema = vscode['json.schemas']?.[0]?.schema?.properties?.webjs; + assert.ok( + webjsSchema && String(webjsSchema.$ref || '').includes('webjs-config.schema.json'), + '.vscode/settings.json should $ref the webjs-config schema for the webjs block' + ); + // Full-stack template-specific assert.ok(existsSync(join(appDir, 'app', 'layout.ts')), 'layout.ts written'); assert.ok(existsSync(join(appDir, 'app', 'page.ts')), 'page.ts written'); From 0d6cf89ecefa1b9705a2eb87e425b3109d9f35ca Mon Sep 17 00:00:00 2001 From: t Date: Wed, 3 Jun 2026 13:05:16 +0530 Subject: [PATCH 3/3] test: guard schema/type lockstep and tighten the header value type Two review follow-ups for the webjs-config typing. Add a drift test asserting the WebjsConfig type's top-level keys equal the reader keys (KNOWN_KEYS), closing the third edge of the lockstep triangle: the schema was already cross-checked against the readers, but the type could silently fall out of sync with both. Tighten a header rule's value from string|null|boolean to string|null|false in both the schema and the type. A value of true is never meaningful (the runtime stringifies it to the literal "true"); only a string sets a header and null/false remove it. A new fixture counterfactual proves value:true is now a compile error. --- packages/core/src/webjs-config.d.ts | 6 +++-- .../test/config/webjs-config-schema.test.js | 26 +++++++++++++++++++ packages/server/webjs-config.schema.json | 4 +-- test/types/webjs-config.test-d.ts | 4 +++ 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/core/src/webjs-config.d.ts b/packages/core/src/webjs-config.d.ts index eebcca4fa..846b90c1c 100644 --- a/packages/core/src/webjs-config.d.ts +++ b/packages/core/src/webjs-config.d.ts @@ -31,10 +31,12 @@ export interface WebjsHeaderDirective { /** Header name, e.g. `X-Frame-Options`. */ key: string; /** - * Header value. A `null` (or `false`) value REMOVES the header on a + * Header value. A `null` or `false` value REMOVES the header on a * match, the escape hatch that drops a secure default on a path. + * `true` is intentionally not allowed (the runtime would stringify it + * to the literal `"true"`, which is never a useful header value). */ - value?: string | null | boolean; + value?: string | null | false; } /** One per-path response-header rule in `webjs.headers`. */ diff --git a/packages/server/test/config/webjs-config-schema.test.js b/packages/server/test/config/webjs-config-schema.test.js index 7766f8c2d..dff2b0182 100644 --- a/packages/server/test/config/webjs-config-schema.test.js +++ b/packages/server/test/config/webjs-config-schema.test.js @@ -136,6 +136,32 @@ test('key shapes match the reader contracts', () => { assert.ok(Array.isArray(p.csp.oneOf), 'csp is a oneOf(boolean, object)'); }); +// The schema and the exported WebjsConfig type are two artifacts that must +// stay in lockstep (the procedure in packages/server/AGENTS.md). The schema is +// already cross-checked against KNOWN_KEYS above; this closes the third edge so +// adding a key to the schema + readers while forgetting the .d.ts (or vice +// versa) fails a test instead of silently drifting. +test('the WebjsConfig type top-level keys match the reader keys', () => { + const dtsPath = fileURLToPath( + new URL('../../../core/src/webjs-config.d.ts', import.meta.url), + ); + const src = readFileSync(dtsPath, 'utf8'); + const start = src.indexOf('interface WebjsConfig'); + assert.ok(start >= 0, 'webjs-config.d.ts declares interface WebjsConfig'); + const open = src.indexOf('{', start); + const close = src.indexOf('\n}', open); + const body = src.slice(open + 1, close); + // Top-level members are indented one level (two spaces) inside the interface. + // Nested object literals (none today, the type references named shapes) would + // sit deeper, so anchoring at the two-space indent captures only the keys. + const keys = [...body.matchAll(/^ {2}(\w+)\??:/gm)].map((m) => m[1]).sort(); + assert.deepEqual( + keys, + [...KNOWN_KEYS].sort(), + 'WebjsConfig keys must equal the reader keys (schema and type out of lockstep)', + ); +}); + /** * A tiny structural validator standing in for ajv (which the repo does not * ship). It only checks the constraints this schema relies on: known-key diff --git a/packages/server/webjs-config.schema.json b/packages/server/webjs-config.schema.json index 99811104a..e9f85efde 100644 --- a/packages/server/webjs-config.schema.json +++ b/packages/server/webjs-config.schema.json @@ -35,8 +35,8 @@ "type": "string" }, "value": { - "description": "Header value. A null (or false) value REMOVES the header on a match, the escape hatch that drops a secure default on a path.", - "type": ["string", "null", "boolean"] + "description": "Header value. A null or false value REMOVES the header on a match, the escape hatch that drops a secure default on a path. true is intentionally not allowed (it would stringify to the literal 'true').", + "oneOf": [{ "type": ["string", "null"] }, { "const": false }] } }, "additionalProperties": false diff --git a/test/types/webjs-config.test-d.ts b/test/types/webjs-config.test-d.ts index e8750337b..c22e6195c 100644 --- a/test/types/webjs-config.test-d.ts +++ b/test/types/webjs-config.test-d.ts @@ -95,3 +95,7 @@ void missingDest; // @ts-expect-error a numeric knob is a number, not a string. const badNumber: WebjsConfig = { maxBodyBytes: '1mb' }; void badNumber; + +// @ts-expect-error a header value of true is rejected (only string, null, or false). +const badHeaderValue: WebjsConfig = { headers: [{ source: '/a', headers: [{ key: 'X-Test', value: true }] }] }; +void badHeaderValue;