From ede9213a6f54f86bba12b16deb037e2e5d001bf2 Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 21 Aug 2026 18:44:47 +0530 Subject: [PATCH 1/5] fix: type the core exports an app resolves as any `@webjsdev/core`'s overlay re-exported seven modules from their JSDoc `.js` with no `.d.ts` sibling. An app has `allowJs` off, so `html`, `css`, `TemplateResult`, `Suspense`, `repeat`, `connectWS`, `richFetch` and the escape helpers all resolved to `any` there, silenced by `skipLibCheck`. That took a component's `render()` return, its `static styles` and a page's return type with them, so a scaffolded app type-checked almost none of its templates. `@webjsdev/server` had the same class of break in one spot: `RequestHandler` referenced `Handle` on the strength of an `export *`, which re-exports a name without binding it locally, so `handle` was an error type. Fixing it exposed a real too-narrow signature in the gallery's rate-limit test, which now derives the type from `Handle` instead of restating it. The two existing drift guards could not see any of this: both run tsc with `--allowJs`, which reads the JSDoc the app never gets. The new guard inverts that flag and `skipLibCheck` so it grades the packages the way an app does. --- gallery/test/rate-limit/rate-limit.test.ts | 3 +- packages/core/index.d.ts | 2 +- packages/core/src/css.d.ts | 9 + packages/core/src/escape.d.ts | 2 + packages/core/src/html.d.ts | 9 + packages/core/src/repeat.d.ts | 15 ++ packages/core/src/rich-fetch.d.ts | 4 + packages/core/src/suspense.d.ts | 9 + packages/core/src/websocket-client.d.ts | 17 ++ packages/server/index.d.ts | 9 +- test/types/dts-no-any-exports.test.mjs | 254 +++++++++++++++++++++ 11 files changed, 328 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/css.d.ts create mode 100644 packages/core/src/escape.d.ts create mode 100644 packages/core/src/html.d.ts create mode 100644 packages/core/src/repeat.d.ts create mode 100644 packages/core/src/rich-fetch.d.ts create mode 100644 packages/core/src/suspense.d.ts create mode 100644 packages/core/src/websocket-client.d.ts create mode 100644 test/types/dts-no-any-exports.test.mjs diff --git a/gallery/test/rate-limit/rate-limit.test.ts b/gallery/test/rate-limit/rate-limit.test.ts index 82e5124d1..9a1cbfda3 100644 --- a/gallery/test/rate-limit/rate-limit.test.ts +++ b/gallery/test/rate-limit/rate-limit.test.ts @@ -5,6 +5,7 @@ import { dirname, resolve } from 'node:path'; import { createRequestHandler } from '@webjsdev/server'; import { testRequest } from '@webjsdev/server/testing'; +import type { Handle } from '@webjsdev/server/testing'; const appDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); @@ -22,7 +23,7 @@ const MAX = 5; // X-Forwarded-For that DISAGREES, standing in for the CDN egress address the // real deploy puts there, so a test that passes only because the two agree // cannot exist. -function ping(handle: (req: Request) => Promise, visitor: string, cdnEgress = '172.68.1.9') { +function ping(handle: Handle, visitor: string, cdnEgress = '172.68.1.9') { return testRequest(handle, PING, { headers: { 'cf-connecting-ip': visitor, 'x-forwarded-for': cdnEgress }, }); diff --git a/packages/core/index.d.ts b/packages/core/index.d.ts index 59d3a0151..9dd4bf8cc 100644 --- a/packages/core/index.d.ts +++ b/packages/core/index.d.ts @@ -8,7 +8,7 @@ * inference helpers. Zero runtime cost. */ -export * from './src/component.d.ts'; +export * from './src/component.js'; export type { Metadata, MetadataContext, diff --git a/packages/core/src/css.d.ts b/packages/core/src/css.d.ts new file mode 100644 index 000000000..c250e9a80 --- /dev/null +++ b/packages/core/src/css.d.ts @@ -0,0 +1,9 @@ +export interface CSSResult { + _$webjsCss: true; + text: string; +} + +export function css(strings: TemplateStringsArray | string[], ...values: unknown[]): CSSResult; +export function isCSS(x: unknown): x is CSSResult; +export function adoptStyles(root: ShadowRoot | Document, styles: CSSResult[]): void; +export function stylesToString(styles: CSSResult[]): string; diff --git a/packages/core/src/escape.d.ts b/packages/core/src/escape.d.ts new file mode 100644 index 000000000..4084072a3 --- /dev/null +++ b/packages/core/src/escape.d.ts @@ -0,0 +1,2 @@ +export function escapeText(s: string): string; +export function escapeAttr(s: string): string; diff --git a/packages/core/src/html.d.ts b/packages/core/src/html.d.ts new file mode 100644 index 000000000..c5efe929d --- /dev/null +++ b/packages/core/src/html.d.ts @@ -0,0 +1,9 @@ +export interface TemplateResult { + _$webjs: 'template'; + strings: TemplateStringsArray | string[]; + values: unknown[]; +} + +export function html(strings: TemplateStringsArray | string[], ...values: unknown[]): TemplateResult; +export function isTemplate(x: unknown): x is TemplateResult; +export const MARKER: 'wjm-'; diff --git a/packages/core/src/repeat.d.ts b/packages/core/src/repeat.d.ts new file mode 100644 index 000000000..27f1bc567 --- /dev/null +++ b/packages/core/src/repeat.d.ts @@ -0,0 +1,15 @@ +// The runtime value also carries a module-private `Symbol.for('webjs.repeat')` +// key, the marker the renderers check. It is deliberately absent here: it is not +// exported, so it cannot be named, and no consumer constructs one by hand. +export interface RepeatDirective { + items: T[]; + keyFn: (item: T, i: number) => string | number; + templateFn: (item: T, i: number) => unknown; +} + +export function repeat( + items: Iterable, + keyFn: (item: T, i: number) => string | number, + templateFn: (item: T, i: number) => unknown, +): RepeatDirective; +export function isRepeat(x: unknown): x is RepeatDirective; diff --git a/packages/core/src/rich-fetch.d.ts b/packages/core/src/rich-fetch.d.ts new file mode 100644 index 000000000..74cc2af8c --- /dev/null +++ b/packages/core/src/rich-fetch.d.ts @@ -0,0 +1,4 @@ +// `body` is widened off `RequestInit` on purpose: richFetch also accepts a plain +// object, which it serializes with the WebJs wire format. `Omit` first, because +// an intersection would narrow the property back to `BodyInit | null`. +export function richFetch(url: string | URL, init?: Omit & { body?: unknown }): Promise; diff --git a/packages/core/src/suspense.d.ts b/packages/core/src/suspense.d.ts new file mode 100644 index 000000000..b620ed331 --- /dev/null +++ b/packages/core/src/suspense.d.ts @@ -0,0 +1,9 @@ +export interface SuspenseBoundary { + _$webjsSuspense: true; + fallback: unknown; + children: unknown; +} + +export function Suspense(props: { fallback: unknown; children: unknown | Promise }): SuspenseBoundary; +export function isSuspense(x: unknown): x is SuspenseBoundary; +export const SUSPENSE: unique symbol; diff --git a/packages/core/src/websocket-client.d.ts b/packages/core/src/websocket-client.d.ts new file mode 100644 index 000000000..da085b320 --- /dev/null +++ b/packages/core/src/websocket-client.d.ts @@ -0,0 +1,17 @@ +export interface ConnectOptions { + onOpen?: (ev: Event) => void; + onMessage?: (data: unknown, ev: MessageEvent) => void; + onClose?: (ev: CloseEvent) => void; + onError?: (ev: Event) => void; + protocols?: string | string[]; + reconnect?: boolean; +} + +export interface WSConnection { + send(data: string | ArrayBuffer | ArrayBufferView | object): void; + close(code?: number, reason?: string): void; + readonly socket: WebSocket | null; + readonly readyState: 0 | 1 | 2 | 3; +} + +export function connectWS(url: string, opts?: ConnectOptions): WSConnection; diff --git a/packages/server/index.d.ts b/packages/server/index.d.ts index ad770d647..98b28d2da 100644 --- a/packages/server/index.d.ts +++ b/packages/server/index.d.ts @@ -25,7 +25,8 @@ import type { LayoutProps, PageProps, RouteHandlerContext } from '@webjsdev/core // The `./testing` subpath types are re-exported wholesale (the helpers ship // from both the main entry and the subpath; this avoids duplicating them). -export * from './src/testing.d.ts'; +import type { Handle } from './src/testing.js'; +export * from './src/testing.js'; // --------------------------------------------------------------------------- // Shared local types @@ -34,8 +35,10 @@ export * from './src/testing.d.ts'; /** A webjs middleware: receives the request + a `next()` continuation. */ export type Middleware = (req: Request, next: () => Promise) => Promise | Response; -// `Handle` is re-exported from ./src/testing.d.ts (the `export *` above), so it -// is not re-declared here. `RequestHandler.handle` / `Handle` reference it. +// `Handle` is re-exported from ./src/testing.js (the `export *` above), so it +// is not re-declared here. It is IMPORTED as well, because `export *` re-exports +// a name without creating a local binding, so `RequestHandler.handle` below +// could not otherwise see it. /** * The `ActionResult` envelope a server action / page action returns. diff --git a/test/types/dts-no-any-exports.test.mjs b/test/types/dts-no-any-exports.test.mjs new file mode 100644 index 000000000..5b79c045a --- /dev/null +++ b/test/types/dts-no-any-exports.test.mjs @@ -0,0 +1,254 @@ +/** + * Drift guard for issue #1451: a published package's `.d.ts` overlay must not + * hand an APP an export typed `any`. + * + * The two sibling guards check export EXISTENCE in both directions + * (`dts-export-coverage` #388 forward, `dts-no-phantom-exports` #1031 reverse), + * and both are blind to this for the same two reasons. They run tsc with + * `--allowJs`, so an overlay re-exporting from a JSDoc `.js` with no `.d.ts` + * sibling still resolves, reading the types out of the JSDoc; and they assert a + * name is DECLARED, never that it carries a type. An app has `allowJs` off (it + * does not want the framework's `.js` in its program) and `skipLibCheck: true`, + * so there the same re-export degrades silently to `any`. That is how `html`, + * `css`, `TemplateResult`, `Suspense`, `repeat`, `connectWS`, `richFetch` and + * `escapeText` / `escapeAttr` shipped as `any` to every scaffolded app while + * every type test stayed green, taking a component's `render()` return, its + * `static styles` and a page's return type down with them. + * + * So this guard inverts BOTH flags: `--allowJs` OFF, `--skipLibCheck` OFF. That + * pair is the whole mechanism. Do not restore either to make an entry pass; the + * fix is a real `.d.ts`. + * + * Two checks, because the failure has two shapes: + * + * 1. Per overlay, tsc must report NOTHING against the package's own files. The + * untyped re-export surfaces there as `TS7016` (implicitly `any`), and a + * value `export *` from an explicit `.d.ts` path as `TS2846`. + * 2. A headline fixture proves the exports this issue named resolve to real + * types. It probes by ASSIGNMENT rather than by a conditional type, because + * an unresolved import produces TypeScript's error type, and that type + * absorbs every conditional: `0 extends (1 & T)`, `unknown extends T` and a + * bare `T extends X` all evaluate to the error type rather than to `true` or + * `false`, so a type-level `IsAny` silently reports nothing. Assigning to a + * branded type nothing real inhabits is the one probe that stays honest: + * a real type errors, `any` and the error type do not. + * + * Counterfactual: a synthetic overlay re-exporting an untyped `.js` is reported, + * and adding the sibling `.d.ts` silences it. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync, rmSync, readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(here, '..', '..'); +const tscBin = join(ROOT, 'node_modules', 'typescript', 'bin', 'tsc'); + +// `minEntries` matches the sibling guards' floors: if `entryPairs` ever returns +// fewer overlay entries than this (a renamed `exports` shape, a mapping +// regression), the run FAILS loudly instead of silently checking almost nothing. +const PACKAGES = [ + { name: '@webjsdev/core', dir: 'packages/core', minEntries: 12 }, + { name: '@webjsdev/server', dir: 'packages/server', minEntries: 3 }, +]; + +/** Every export subpath that declares a `types` overlay. Same mapping as the siblings. */ +function entryPairs(pkgDir) { + const pkg = JSON.parse(readFileSync(join(ROOT, pkgDir, 'package.json'), 'utf8')); + const pairs = []; + for (const [key, val] of Object.entries(pkg.exports || {})) { + if (!val || typeof val !== 'object' || !val.types || !val.types.endsWith('.d.ts')) continue; + pairs.push({ key, types: val.types.replace(/^\.\//, '') }); + } + return pairs; +} + +/** + * Run tsc over a fixture the way an APP resolves the packages, and return its + * output. `allowJs` and `skipLibCheck` are deliberately absent / off. + * + * It goes through a generated tsconfig rather than CLI flags for one reason: + * `paths` pins every bare `@webjsdev/*` specifier to THIS checkout's + * `packages/`. A bare specifier otherwise resolves through the workspace's + * `node_modules` symlink, which in a git worktree points at the PRIMARY + * checkout, so the guard would grade the wrong copy of the package and pass + * while the branch under test is still broken (or fail while it is fixed). + */ +const abs = (rel) => join(ROOT, rel).replace(/\\/g, '/'); + +function checkFixture(fixture) { + const tsconfig = join(dirname(fixture), 'tsconfig.probe.json'); + writeFileSync( + tsconfig, + JSON.stringify({ + compilerOptions: { + noEmit: true, + strict: true, + target: 'esnext', + module: 'esnext', + moduleResolution: 'bundler', + lib: ['esnext', 'dom'], + types: ['node'], + skipLibCheck: false, + // Absolute `paths` values, and no `baseUrl`: the tsconfig is generated + // into a temp dir, and `baseUrl` is deprecated from TypeScript 6. + paths: { + '@webjsdev/core': [abs('packages/core/index.d.ts')], + '@webjsdev/core/*': [abs('packages/core/src/*')], + '@webjsdev/server': [abs('packages/server/index.d.ts')], + '@webjsdev/server/*': [abs('packages/server/src/*')], + }, + // Same reason: `types` resolves against the tsconfig's own directory. + typeRoots: [abs('node_modules/@types')], + }, + files: [fixture], + }), + ); + const res = spawnSync(process.execPath, [tscBin, '--noEmit', '-p', tsconfig], { + cwd: ROOT, + encoding: 'utf8', + }); + return `${res.stdout || ''}${res.stderr || ''}`; +} + +/** + * Keep only the diagnostics that land in OUR OWN published sources. A run with + * `skipLibCheck` off also type-checks every third-party `.d.ts` in the program + * (drizzle-orm alone contributes about fifty), and those are not this guard's + * business. `node_modules/@webjsdev/*` is included because the workspace links + * each package there, so tsc may report either path for the same file. + */ +function ownPackageErrors(out) { + return out + .split('\n') + .filter((l) => /error TS\d+/.test(l)) + .filter((l) => /(^|[/\\])packages[/\\]|node_modules[/\\]@webjsdev[/\\]/.test(l)) + .filter((l) => !/node_modules[/\\](?!@webjsdev)/.test(l)) + .map((l) => l.trim()); +} + +for (const { name, dir, minEntries } of PACKAGES) { + test(`${name}: no overlay hands an app an \`any\` export (#1451)`, () => { + const entries = entryPairs(dir); + assert.ok( + entries.length >= minEntries, + `${name}: expected at least ${minEntries} overlay entries, found ${entries.length}. ` + + `The exports mapping changed; fix the mapping rather than lowering the floor.`, + ); + const workDir = mkdtempSync(join(tmpdir(), 'webjs-dts-any-')); + try { + // One fixture importing every overlay, so a single tsc run covers the + // package and a cross-entry breakage cannot hide behind a per-entry run. + const lines = entries.map(({ types }, i) => { + const spec = join(ROOT, dir, types).replace(/\\/g, '/').replace(/\.d\.ts$/, ''); + return `type Entry${i} = typeof import(${JSON.stringify(spec)});\nexport type _E${i} = Entry${i};`; + }); + const fixture = join(workDir, 'entries.ts'); + writeFileSync(fixture, `${lines.join('\n')}\n`); + const out = checkFixture(fixture); + // An unresolved overlay would make every entry `any` and this guard + // vacuous, so a resolution failure is a broken harness, not a pass. + if (/error TS2307|Cannot find module/.test(out)) { + throw new Error(`no-any fixture failed to resolve an overlay (harness broken):\n${out}`); + } + const errors = ownPackageErrors(out); + assert.deepEqual( + errors, + [], + `${name} overlays do not type-check the way an app resolves them (allowJs off, ` + + `skipLibCheck off), so the exports below reach every app as \`any\`:\n ` + + `${errors.join('\n ')}\n` + + `A TS7016 means the overlay re-exports from a JSDoc .js with no .d.ts sibling. ` + + `Add the sibling; do not relax the flags here.`, + ); + } finally { + rmSync(workDir, { recursive: true, force: true }); + } + }); +} + +test('the headline core and server exports carry real types, not `any` (#1451)', () => { + const workDir = mkdtempSync(join(tmpdir(), 'webjs-dts-headline-')); + try { + const core = join(ROOT, 'packages/core/index').replace(/\\/g, '/'); + const directives = join(ROOT, 'packages/core/src/directives').replace(/\\/g, '/'); + const server = join(ROOT, 'packages/server/index').replace(/\\/g, '/'); + // Nothing real inhabits `Probe`, so assigning a genuinely-typed export to it + // is an error. `any` (and TypeScript's error type) assign cleanly, which is + // exactly the silence this test reads as a failure. + const fixture = join(workDir, 'headline.ts'); + const coreNames = ['html', 'css', 'Suspense', 'connectWS', 'richFetch', 'escapeText', 'escapeAttr', 'isTemplate', 'isCSS']; + writeFileSync( + fixture, + `import { ${coreNames.join(', ')} } from ${JSON.stringify(core)};\n` + + `import type { TemplateResult } from ${JSON.stringify(core)};\n` + + `import { repeat } from ${JSON.stringify(directives)};\n` + + `import type { RequestHandler } from ${JSON.stringify(server)};\n` + + `declare const __brand: unique symbol;\n` + + `type Probe = { readonly [__brand]: 'webjs-no-any' };\n` + + // One statement per export. A missing error names the `any`. + coreNames.map((n) => `const _${n}: Probe = ${n}; void _${n};`).join('\n') + '\n' + + `const _repeat: Probe = repeat; void _repeat;\n` + + `const _tmpl: Probe = null as unknown as TemplateResult; void _tmpl;\n` + + `const _handle: Probe = null as unknown as RequestHandler['handle']; void _handle;\n`, + ); + const out = checkFixture(fixture); + if (/error TS2307|Cannot find module/.test(out)) { + throw new Error(`headline fixture failed to resolve a package (harness broken):\n${out}`); + } + const expected = [...coreNames, 'repeat', 'tmpl', 'handle']; + // ANY diagnostic on a probe line proves the type is real: each line holds a + // single assignment to `Probe`, and only `any` lets one through silently. + // The code varies with the export's shape (a function is TS2322, an object + // type is TS2741), so keying on one code would silently stop discriminating. + const errored = new Set( + [...out.matchAll(/headline\.ts\((\d+),\d+\): error TS\d+/g)].map((m) => m[1]), + ); + // Map the reported line numbers back to names via the fixture's own text. + const src = readFileSync(fixture, 'utf8').split('\n'); + const errNames = [...errored].map((ln) => /const _([A-Za-z0-9_$]+): Probe/.exec(src[Number(ln) - 1])?.[1]); + const missing = expected.filter((n) => !errNames.includes(n)); + assert.deepEqual( + missing, + [], + `these exports resolve to \`any\` for an app (allowJs off), so nothing about ` + + `their use is type-checked: ${missing.join(', ')}`, + ); + } finally { + rmSync(workDir, { recursive: true, force: true }); + } +}); + +// --- Counterfactual: the guard must FIRE on an untyped re-export and go quiet +// --- once the missing sibling exists. +test('the no-any guard fires on an overlay re-exporting an untyped .js (#1451)', () => { + const workDir = mkdtempSync(join(tmpdir(), 'webjs-dts-any-cf-')); + try { + // A JSDoc-typed impl with NO .d.ts sibling: what the real defect looked like. + writeFileSync( + join(workDir, 'impl.js'), + '/** @param {string} s @returns {string} */\nexport function shout(s) { return s.toUpperCase(); }\n', + ); + writeFileSync(join(workDir, 'overlay.d.ts'), "export { shout } from './impl.js';\n"); + const fixture = join(workDir, 'cf.ts'); + writeFileSync(fixture, `export type E = typeof import(${JSON.stringify(join(workDir, 'overlay').replace(/\\/g, '/'))});\n`); + + const before = checkFixture(fixture); + assert.match( + before, + /overlay\.d\.ts\(1,\d+\): error TS7016/, + 'the guard must report the untyped re-export as TS7016', + ); + + // Adding the sibling .d.ts is the fix, and it must silence the guard. + writeFileSync(join(workDir, 'impl.d.ts'), 'export function shout(s: string): string;\n'); + const after = checkFixture(fixture); + assert.doesNotMatch(after, /error TS7016/, 'a typed sibling must make the guard clean'); + } finally { + rmSync(workDir, { recursive: true, force: true }); + } +}); From 043a117f1aa3f46212c7516b59ffb8d16e0311c8 Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 21 Aug 2026 18:56:32 +0530 Subject: [PATCH 2/5] fix: stop the scaffold declaring a typescript it cannot use The generated package.json declared `"typescript": "^5.6.0"` while the tsconfig.json the same generator writes sets `erasableSyntaxOnly`, which landed in 5.8. Every version in the lower half of that range refuses the config outright with `TS5023: Unknown compiler option`, exit 2, nothing else checked. It stayed hidden because npm resolves a caret to the newest match, so a fresh scaffold picked up 5.9; it bites a pinned install, an older lockfile, or an editor whose own compiler is older. The range moves to the major the repo's own three apps already use, so an app and the framework that generated it type-check under one compiler. Both templates were generated and type-checked clean under 6.0.3. Nothing tied the two files together, so a new guard does: it maps every compiler option the generator emits to the release that introduced it and asserts the range's LOWEST version clears the highest of those floors. An option missing from the table fails the test rather than being skipped, so adding one has to record its floor. The docs site showed `^5.7.0` in its api-template manifest, itself below the floor, so that sample is corrected too. --- packages/cli/lib/create.js | 9 +- .../scaffold-typescript-floor.test.js | 157 ++++++++++++++++++ website/app/docs/backend-only/page.ts | 2 +- 3 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 test/scaffolds/scaffold-typescript-floor.test.js diff --git a/packages/cli/lib/create.js b/packages/cli/lib/create.js index b053b56f6..532635a6b 100644 --- a/packages/cli/lib/create.js +++ b/packages/cli/lib/create.js @@ -448,7 +448,14 @@ export async function scaffoldApp(name, cwd, opts = {}) { // The TypeScript compiler, for `npm run typecheck` (webjs typecheck runs // tsc --noEmit). Not needed at runtime (Node strips types in place), only // to type-check the app. - typescript: '^5.6.0', + // Must not resolve below the floor the tsconfig this same generator + // writes requires: `erasableSyntaxOnly` landed in TypeScript 5.8, and a + // 5.6 or 5.7 resolution refuses the whole config with + // `TS5023: Unknown compiler option`. Kept on the major the repo's own + // apps use, so an app and the framework that generated it type-check + // under the same compiler. Guarded by + // test/scaffolds/scaffold-typescript-floor.test.js. + typescript: '^6.0.3', '@types/node': '^24.0.0', '@web/test-runner': '^0.20.0', '@web/test-runner-playwright': '^0.11.0', diff --git a/test/scaffolds/scaffold-typescript-floor.test.js b/test/scaffolds/scaffold-typescript-floor.test.js new file mode 100644 index 000000000..d2b4851bc --- /dev/null +++ b/test/scaffolds/scaffold-typescript-floor.test.js @@ -0,0 +1,157 @@ +/** + * The generated `package.json` must not permit a TypeScript that cannot read + * the `tsconfig.json` the SAME generator writes. + * + * The scaffold shipped `"typescript": "^5.6.0"` alongside a tsconfig setting + * `erasableSyntaxOnly`, which landed in TypeScript 5.8. Every version in the + * lower half of that range refuses the config outright with + * `TS5023: Unknown compiler option 'erasableSyntaxOnly'`, exit 2, nothing else + * checked. It stayed invisible because `npm install` resolves a caret range to + * the newest matching version, so a fresh scaffold picked up 5.9 and worked; it + * bites a pinned install, an older lockfile, or a toolchain whose own compiler + * is older. Nothing tied the two files together, so they were free to drift. + * + * This ties them. `REQUIRES` maps each compiler option the generator emits to + * the TypeScript version that introduced it, and the test asserts two things: + * the declared range's LOWEST satisfying version clears the highest floor among + * the emitted options, and every emitted option is classified. The second half + * is what keeps this from rotting: adding an option the table does not know + * fails the test until someone records its floor, the same "classify it or CI + * stays red" contract as the gallery-coverage manifest. + * + * Counterfactual: restore `^5.6.0` (or add an unclassified option) and this + * fails. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { scaffoldApp } from '../../packages/cli/lib/create.js'; + +/** + * The TypeScript release that introduced each compiler option the generated + * tsconfig sets. `1.0.0` means "as old as anything we care about", used for the + * options that predate every version this project could run. + */ +const REQUIRES = { + target: '1.0.0', + module: '1.0.0', + moduleResolution: '1.0.0', + lib: '1.0.0', + types: '1.0.0', + strict: '2.3.0', + noEmit: '1.0.0', + skipLibCheck: '2.0.0', + plugins: '2.3.0', + allowImportingTsExtensions: '5.0.0', + // The option this guard exists for. + erasableSyntaxOnly: '5.8.0', +}; + +const TEMPLATES = ['full-stack', 'api']; + +for (const template of TEMPLATES) { + test(`${template}: the declared typescript range can read the generated tsconfig`, async () => { + const cwd = await mkdtemp(join(tmpdir(), `webjs-tsfloor-${template}-`)); + try { + await scaffoldApp('demo', cwd, { template, install: false }); + const pkg = JSON.parse(await readFile(join(cwd, 'demo', 'package.json'), 'utf8')); + // The generated tsconfig carries comments, so it is JSONC rather than JSON. + const tsconfigRaw = await readFile(join(cwd, 'demo', 'tsconfig.json'), 'utf8'); + const options = Object.keys(JSON.parse(stripJsonComments(tsconfigRaw)).compilerOptions); + + const unclassified = options.filter((o) => !(o in REQUIRES)); + assert.deepEqual( + unclassified, + [], + `the generated tsconfig sets compiler option(s) with no recorded TypeScript ` + + `floor: ${unclassified.join(', ')}. Add each to REQUIRES with the version ` + + `that introduced it, so the declared range keeps being checked against it.`, + ); + + const required = options + .map((o) => REQUIRES[o]) + .reduce((hi, v) => (compare(v, hi) > 0 ? v : hi), '1.0.0'); + + const range = pkg.devDependencies?.typescript; + assert.ok(range, `${template}: the generated package.json declares no typescript`); + // The LOWEST version the range admits is the one that has to work: npm + // resolves a caret to the newest match today, which is exactly why the + // drift went unnoticed. + const lowest = lowestSatisfying(range); + assert.ok( + compare(lowest, required) >= 0, + `${template}: "typescript": "${range}" admits ${lowest}, but the ` + + `generated tsconfig needs at least ${required} (its highest option floor). ` + + `That version refuses the config with TS5023 and checks nothing.`, + ); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); +} + +/** + * The lowest version a range admits. Deliberately narrow: it understands the + * range shapes a generated manifest actually uses and THROWS on anything else, + * because a range this cannot read is one it must not silently pass. Written + * out rather than pulled from `semver`, which this repo does not declare as a + * dependency (it is only present transitively, so importing it here would make + * the test hostage to an unrelated lockfile change). + */ +function lowestSatisfying(range) { + const m = /^\s*(?:\^|~|>=)?\s*(\d+)\.(\d+)\.(\d+)\s*$/.exec(range); + if (!m) { + throw new Error( + `cannot read the version range ${JSON.stringify(range)}. Extend ` + + `lowestSatisfying() to cover it rather than loosening this guard.`, + ); + } + return `${m[1]}.${m[2]}.${m[3]}`; +} + +/** Numeric x.y.z comparison. Returns >0 when `a` is newer than `b`. */ +function compare(a, b) { + const pa = a.split('.').map(Number); + const pb = b.split('.').map(Number); + for (let i = 0; i < 3; i += 1) { + if (pa[i] !== pb[i]) return pa[i] - pb[i]; + } + return 0; +} + +/** + * Strip `//` and block comments from JSONC. Deliberately string-aware, so a + * `//` inside a value (a url in a comment-free option) is not eaten. + */ +function stripJsonComments(text) { + let out = ''; + let inString = false; + let inLine = false; + let inBlock = false; + for (let i = 0; i < text.length; i += 1) { + const c = text[i]; + const next = text[i + 1]; + if (inLine) { + if (c === '\n') { inLine = false; out += c; } + continue; + } + if (inBlock) { + if (c === '*' && next === '/') { inBlock = false; i += 1; } + continue; + } + if (inString) { + out += c; + if (c === '\\') { out += next; i += 1; continue; } + if (c === '"') inString = false; + continue; + } + if (c === '"') { inString = true; out += c; continue; } + if (c === '/' && next === '/') { inLine = true; i += 1; continue; } + if (c === '/' && next === '*') { inBlock = true; i += 1; continue; } + out += c; + } + return out; +} diff --git a/website/app/docs/backend-only/page.ts b/website/app/docs/backend-only/page.ts index 616b4b30d..86a73ea1f 100644 --- a/website/app/docs/backend-only/page.ts +++ b/website/app/docs/backend-only/page.ts @@ -301,7 +301,7 @@ fastify.listen({ port: 8080 }); }, "devDependencies": { "drizzle-kit": "^1.0.0-rc.3", - "typescript": "^5.7.0" + "typescript": "^6.0.3" } } From 20335c2ca7d2b8d95f9dff1d2150139c7a60c01f Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 21 Aug 2026 19:03:23 +0530 Subject: [PATCH 3/5] docs: correct the JSONC claim in the typescript-floor guard The generated tsconfig is plain JSON.stringify output with no comments; the comment-stripping is defensive, not a present need. Say so. --- test/scaffolds/scaffold-typescript-floor.test.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/test/scaffolds/scaffold-typescript-floor.test.js b/test/scaffolds/scaffold-typescript-floor.test.js index d2b4851bc..823b7746d 100644 --- a/test/scaffolds/scaffold-typescript-floor.test.js +++ b/test/scaffolds/scaffold-typescript-floor.test.js @@ -58,7 +58,10 @@ for (const template of TEMPLATES) { try { await scaffoldApp('demo', cwd, { template, install: false }); const pkg = JSON.parse(await readFile(join(cwd, 'demo', 'package.json'), 'utf8')); - // The generated tsconfig carries comments, so it is JSONC rather than JSON. + // The generator emits plain JSON today (JSON.stringify, no comments), + // but tsconfig.json is JSONC by convention, so parse defensively: a + // comment added to the output later must red an assertion here, never + // crash the parse. const tsconfigRaw = await readFile(join(cwd, 'demo', 'tsconfig.json'), 'utf8'); const options = Object.keys(JSON.parse(stripJsonComments(tsconfigRaw)).compilerOptions); @@ -123,8 +126,10 @@ function compare(a, b) { } /** - * Strip `//` and block comments from JSONC. Deliberately string-aware, so a - * `//` inside a value (a url in a comment-free option) is not eaten. + * Strip `//` and block comments from JSONC. The generated tsconfig has none + * today, so this is a no-op on it; it exists so a comment added to the output + * later degrades to a failed assertion instead of a parse crash. String-aware, + * so a `//` inside a value is not eaten. */ function stripJsonComments(text) { let out = ''; From b6fd36e57dcd4ac606306afb51be57959480540d Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 21 Aug 2026 19:13:19 +0530 Subject: [PATCH 4/5] fix: repair the three consumers the Handle fix exposed Typing RequestHandler.handle for real broke every place that had restated it as (req: Request) => Promise. Those only passed while Handle was an unbound name, so the error type absorbed the mismatch: the website's four SSR test wrappers, and the server export fixture. The wrappers become async, the fixture states the real union. connectWS's onMessage payload goes back to `any`, matching its JSDoc. Refining it to `unknown` broke the blog's chat and comments handlers, which name the message shape they expect. That is the contract, and a PR filling in missing declarations does not get to change it. The new no-any guard joins the bun denylist beside its #1031 sibling: it spawns process.execPath as Node tsc, so under the matrix it spawns bun and every probe reads as `any`. Local runs missed all of this because a linked worktree resolves bare @webjsdev/* to the PRIMARY checkout, so the tests graded an unfixed copy. Verified by shadowing the packages into each test tree, which reproduced CI exactly. --- packages/core/src/websocket-client.d.ts | 8 +++++++- scripts/run-bun-tests.js | 1 + test/types/server-exports.test-d.ts | 8 ++++++-- website/test/ssr/conditional-get.test.ts | 2 +- website/test/ssr/docs-chrome.test.ts | 2 +- website/test/ssr/docs-links.test.ts | 2 +- website/test/ssr/ui-gallery.test.ts | 2 +- 7 files changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/core/src/websocket-client.d.ts b/packages/core/src/websocket-client.d.ts index da085b320..150d9da16 100644 --- a/packages/core/src/websocket-client.d.ts +++ b/packages/core/src/websocket-client.d.ts @@ -1,6 +1,12 @@ export interface ConnectOptions { onOpen?: (ev: Event) => void; - onMessage?: (data: unknown, ev: MessageEvent) => void; + // `any`, matching the JSDoc, and load-bearing rather than lazy: the socket + // delivers an arbitrary JSON payload, and the contract is that the CALLER + // names the shape it expects (`(msg: ChatMessage) => ...`). Narrowing this to + // `unknown` type-checks here and breaks every such handler, which is not a + // change a PR filling in missing declarations gets to make. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onMessage?: (data: any, ev: MessageEvent) => void; onClose?: (ev: CloseEvent) => void; onError?: (ev: Event) => void; protocols?: string | string[]; diff --git a/scripts/run-bun-tests.js b/scripts/run-bun-tests.js index 5bc156d88..adb8d538e 100644 --- a/scripts/run-bun-tests.js +++ b/scripts/run-bun-tests.js @@ -51,6 +51,7 @@ const DENYLIST = [ { match: 'packages/server/test/cache/cache-redis.test.js', reason: 'needs a running Redis + an ioredis/redis client, not provisioned in the matrix (skipped on Node too).' }, { match: 'packages/server/test/websocket/websocket.test.js', reason: 'exercises the node `ws`-library upgrade subsystem directly (node:http createServer + attachWebSocket, which do not interoperate on Bun). The Bun WebSocket path (Bun.serve + the BunWsAdapter, #511) is covered by test/bun/listener.mjs.' }, { match: 'test/cli/typecheck.test.mjs', reason: 'spawns process.execPath (the webjs CLI typecheck, a Node tsc tool); under the matrix process.execPath is bun, which resolves TypeScript differently, so the Node-tooling assertion does not hold.' }, + { match: 'test/types/dts-no-any-exports.test.mjs', reason: 'a Node-tooling type-check guard (#1451): it spawns process.execPath (Node tsc) over a generated tsconfig to prove no published export resolves to `any` for a consumer with allowJs off. Under the matrix process.execPath is bun, which resolves TypeScript differently, so the spawn yields no diagnostics and every probe reads as `any`, the same Node-tooling class as test/cli/typecheck.test.mjs and the #1031 sibling beside it. The .d.ts overlays it grades are runtime-agnostic, so there is no Bun behavior to prove. Fully covered on the Node path by the unit job.' }, { match: 'test/types/dts-no-phantom-exports.test.mjs', reason: 'a Node-tooling type-check guard (#1031): it copies each package tree and spawns process.execPath (Node tsc) per overlay entry to enumerate declared vs runtime exports. It has no runtime-sensitive surface (the .d.ts overlays are runtime-agnostic), and the per-package tsc sweep exceeds bun test\'s 5s default per-test timeout; same Node-tooling class as test/cli/typecheck.test.mjs. Fully covered on the Node path by the unit job.' }, { match: 'packages/server/test/elision/differential-elision.test.js', reason: 'boots the examples/blog app and renders its DB-backed home page, which needs a migrated Drizzle dev.db + jspm vendor resolution the matrix job does not provision (only the e2e / in-repo-app jobs do). The elision LOGIC is covered by the other unit tests in elision/; a real app boot on Bun is covered deterministically by test/bun/listener.mjs.' }, { match: 'test/docs/', reason: "every test/docs/*.test.mjs boots the app serving the docs via createRequestHandler and asserts rendered HTML / llms output (docs-CONTENT checks, not runtime-sensitive code). The cold boot resolves the docs code-sample bare imports via jspm, which intermittently exceeds bun test's 5s default per-test timeout (node --test has no default timeout); which docs page tips over varies by run (security-page, troubleshooting-page, llms have all flaked). Same app-boot + vendor-resolution class as differential-elision, fully covered on the Node path by the unit job." }, diff --git a/test/types/server-exports.test-d.ts b/test/types/server-exports.test-d.ts index 17cd03d5c..43cd777dd 100644 --- a/test/types/server-exports.test-d.ts +++ b/test/types/server-exports.test-d.ts @@ -47,8 +47,12 @@ import type { FileStore } from '@webjsdev/server'; import { testRequest } from '@webjsdev/server/testing'; import { checkConventions } from '@webjsdev/server/check'; -// createRequestHandler resolves to the documented handler shape. -const app: Promise<{ handle: (r: Request) => Promise }> = +// createRequestHandler resolves to the documented handler shape. `handle` may +// answer synchronously, so its return is `Promise | Response`, the +// same union `Handle` declares. Narrowing it to `Promise` here used to +// pass only because `RequestHandler.handle` referenced a `Handle` that was never +// imported, making it an error type that absorbed the mismatch (#1451). +const app: Promise<{ handle: (r: Request) => Promise | Response }> = createRequestHandler({ appDir: '.' }); // startServer takes options + a port and resolves to a server handle. diff --git a/website/test/ssr/conditional-get.test.ts b/website/test/ssr/conditional-get.test.ts index 6ad34724b..52b5f83c8 100644 --- a/website/test/ssr/conditional-get.test.ts +++ b/website/test/ssr/conditional-get.test.ts @@ -29,7 +29,7 @@ let handle: (path: string, headers?: Record) => Promise { const app = await createRequestHandler({ appDir: WEBSITE_ROOT, dev: false }); await app.warmup?.(); - handle = (path, headers = {}) => app.handle(new Request('http://localhost' + path, { headers })); + handle = async (path, headers = {}) => app.handle(new Request('http://localhost' + path, { headers })); }); for (const route of ['/', '/docs/getting-started']) { diff --git a/website/test/ssr/docs-chrome.test.ts b/website/test/ssr/docs-chrome.test.ts index 9ad1cb850..66ddca4b6 100644 --- a/website/test/ssr/docs-chrome.test.ts +++ b/website/test/ssr/docs-chrome.test.ts @@ -35,7 +35,7 @@ let handle: (path: string) => Promise; before(async () => { const app = await createRequestHandler({ appDir: WEBSITE_ROOT, dev: false }); await app.warmup?.(); - handle = (path) => app.handle(new Request('http://localhost' + path)); + handle = async (path) => app.handle(new Request('http://localhost' + path)); }); const bodyOf = async (path: string) => { diff --git a/website/test/ssr/docs-links.test.ts b/website/test/ssr/docs-links.test.ts index 88ea18d7c..39d1e09a1 100644 --- a/website/test/ssr/docs-links.test.ts +++ b/website/test/ssr/docs-links.test.ts @@ -29,7 +29,7 @@ let handle: (path: string) => Promise; before(async () => { const app = await createRequestHandler({ appDir: WEBSITE_ROOT, dev: false }); await app.warmup?.(); - handle = (path) => app.handle(new Request('http://localhost' + path)); + handle = async (path) => app.handle(new Request('http://localhost' + path)); }); /** diff --git a/website/test/ssr/ui-gallery.test.ts b/website/test/ssr/ui-gallery.test.ts index 407cb5395..a353ed2cb 100644 --- a/website/test/ssr/ui-gallery.test.ts +++ b/website/test/ssr/ui-gallery.test.ts @@ -35,7 +35,7 @@ let handle: (path: string) => Promise; before(async () => { const app = await createRequestHandler({ appDir: WEBSITE_ROOT, dev: false }); await app.warmup?.(); - handle = (path) => app.handle(new Request('http://localhost' + path)); + handle = async (path) => app.handle(new Request('http://localhost' + path)); }); const bodyOf = async (path: string) => { From 467ead697578867fe6b293e01efd7b1f58fd1d84 Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 21 Aug 2026 19:22:44 +0530 Subject: [PATCH 5/5] chore: drop an eslint directive from a repo with no eslint The no-explicit-any suppression I put on connectWS's onMessage suppresses nothing here: this repo has no eslint config and no lint script. The comment above it already carries the reason the `any` is deliberate. --- packages/core/src/websocket-client.d.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/websocket-client.d.ts b/packages/core/src/websocket-client.d.ts index 150d9da16..d6687c0c4 100644 --- a/packages/core/src/websocket-client.d.ts +++ b/packages/core/src/websocket-client.d.ts @@ -5,7 +5,6 @@ export interface ConnectOptions { // names the shape it expects (`(msg: ChatMessage) => ...`). Narrowing this to // `unknown` type-checks here and breaks every such handler, which is not a // change a PR filling in missing declarations gets to make. - // eslint-disable-next-line @typescript-eslint/no-explicit-any onMessage?: (data: any, ev: MessageEvent) => void; onClose?: (ev: CloseEvent) => void; onError?: (ev: Event) => void;