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/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/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..d6687c0c4 --- /dev/null +++ b/packages/core/src/websocket-client.d.ts @@ -0,0 +1,22 @@ +export interface ConnectOptions { + onOpen?: (ev: Event) => 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. + onMessage?: (data: any, 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/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/scaffolds/scaffold-typescript-floor.test.js b/test/scaffolds/scaffold-typescript-floor.test.js new file mode 100644 index 000000000..823b7746d --- /dev/null +++ b/test/scaffolds/scaffold-typescript-floor.test.js @@ -0,0 +1,162 @@ +/** + * 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 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); + + 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. 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 = ''; + 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/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 }); + } +}); 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/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" } } 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) => {