Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion gallery/test/rate-limit/rate-limit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)), '..', '..');

Expand All @@ -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<Response>, 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 },
});
Expand Down
9 changes: 8 additions & 1 deletion packages/cli/lib/create.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion packages/core/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* inference helpers. Zero runtime cost.
*/

export * from './src/component.d.ts';
export * from './src/component.js';
export type {
Metadata,
MetadataContext,
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/css.d.ts
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions packages/core/src/escape.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export function escapeText(s: string): string;
export function escapeAttr(s: string): string;
9 changes: 9 additions & 0 deletions packages/core/src/html.d.ts
Original file line number Diff line number Diff line change
@@ -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-';
15 changes: 15 additions & 0 deletions packages/core/src/repeat.d.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
items: T[];
keyFn: (item: T, i: number) => string | number;
templateFn: (item: T, i: number) => unknown;
}

export function repeat<T>(
items: Iterable<T>,
keyFn: (item: T, i: number) => string | number,
templateFn: (item: T, i: number) => unknown,
): RepeatDirective<T>;
export function isRepeat(x: unknown): x is RepeatDirective<unknown>;
4 changes: 4 additions & 0 deletions packages/core/src/rich-fetch.d.ts
Original file line number Diff line number Diff line change
@@ -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<T>(url: string | URL, init?: Omit<RequestInit, 'body'> & { body?: unknown }): Promise<T>;
9 changes: 9 additions & 0 deletions packages/core/src/suspense.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export interface SuspenseBoundary {
_$webjsSuspense: true;
fallback: unknown;
children: unknown;
}

export function Suspense(props: { fallback: unknown; children: unknown | Promise<unknown> }): SuspenseBoundary;
export function isSuspense(x: unknown): x is SuspenseBoundary;
export const SUSPENSE: unique symbol;
22 changes: 22 additions & 0 deletions packages/core/src/websocket-client.d.ts
Original file line number Diff line number Diff line change
@@ -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;
9 changes: 6 additions & 3 deletions packages/server/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Response>) => Promise<Response> | 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<T>` envelope a server action / page action returns.
Expand Down
1 change: 1 addition & 0 deletions scripts/run-bun-tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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." },
Expand Down
162 changes: 162 additions & 0 deletions test/scaffolds/scaffold-typescript-floor.test.js
Original file line number Diff line number Diff line change
@@ -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),
Comment thread
vivek7405 marked this conversation as resolved.
// 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;
}
Loading
Loading