From a047afcd6b0e9a9e2f5d3179b7e4ae92c6c7317a Mon Sep 17 00:00:00 2001 From: t Date: Wed, 3 Jun 2026 21:05:27 +0530 Subject: [PATCH 1/3] feat: ship type declarations for @webjsdev/server (fix TS7016) @webjsdev/core shipped an index.d.ts + a types export condition, but @webjsdev/server shipped neither, so under strict + nodenext a TS app's import { createRequestHandler, cors, cache, ... } from '@webjsdev/server' emitted TS7016 (no declaration file). That blocked the webjs typecheck-in-CI story and left a core import untyped for every TS app. Ship a hand-authored index.d.ts overlay typing every named export (precise for the high-traffic public API, reusing core's PageProps/LayoutProps/RouteHandlerContext), plus src/check.d.ts and src/testing.d.ts for the subpaths, and add types-first export conditions. A drift guard asserts the overlay matches index.js's runtime exports; a fixture + a TS7016 counterfactual prove the fix. Closes #310 --- agent-docs/typescript.md | 15 + packages/server/AGENTS.md | 33 + packages/server/index.d.ts | 650 ++++++++++++++++++ packages/server/package.json | 17 +- packages/server/src/check.d.ts | 38 + packages/server/src/testing.d.ts | 123 ++++ .../server/test/types/exports-drift.test.mjs | 64 ++ .../test/types/server-exports.test-d.ts | 133 ++++ .../server/test/types/server-types.test.mjs | 96 +++ 9 files changed, 1166 insertions(+), 3 deletions(-) create mode 100644 packages/server/index.d.ts create mode 100644 packages/server/src/check.d.ts create mode 100644 packages/server/src/testing.d.ts create mode 100644 packages/server/test/types/exports-drift.test.mjs create mode 100644 packages/server/test/types/server-exports.test-d.ts create mode 100644 packages/server/test/types/server-types.test.mjs diff --git a/agent-docs/typescript.md b/agent-docs/typescript.md index 4c7e467ff..7941e91bc 100644 --- a/agent-docs/typescript.md +++ b/agent-docs/typescript.md @@ -289,6 +289,21 @@ type, AND the reader in lockstep, the one procedure documented in (`packages/server/test/config/webjs-config-schema.test.js`) fails if the schema and the reader key set diverge. +### Both runtime packages ship a type overlay + +Both `@webjsdev/core` AND `@webjsdev/server` ship a hand-authored `.d.ts` +overlay plus a `types` export condition, so a `strict` + `nodenext` app +resolves real types for either import with no TS7016 ("could not find a +declaration file") error. The server overlay (`packages/server/index.d.ts`, +with `src/check.d.ts` and `src/testing.d.ts` for the `./check` / `./testing` +subpaths) types the full public surface (`createRequestHandler`, `startServer`, +`cors`, `cache`, `createAuth`, `rateLimit`, `sitemap`, `Session`, `json`, +`readBody`, the `revalidate*` family, the context helpers, the cache stores, the +auth providers, the test harness, and the convention validator), reusing the +core prop / metadata types rather than redefining them. The runtime stays plain +`.js` + JSDoc; the overlay is types-only with zero runtime cost. A drift test +keeps `index.d.ts` in lockstep with `index.js`'s runtime exports. + ### TypeScript is not required JS + JSDoc gets the same call-site type safety. The TypeScript language diff --git a/packages/server/AGENTS.md b/packages/server/AGENTS.md index 78dd2f877..76d3ce1c6 100644 --- a/packages/server/AGENTS.md +++ b/packages/server/AGENTS.md @@ -84,6 +84,39 @@ re-exported from the main entry). The (below) so an editor can resolve it from `node_modules/@webjsdev/server/webjs-config.schema.json`. +### Type overlay (#310) + +The package ships a hand-authored `.d.ts` overlay plus `types` export +conditions, so a `strict` + `nodenext` TypeScript app's `import { ... } from +'@webjsdev/server'` resolves real types instead of TS7016. The runtime stays +plain `.js` + JSDoc; the overlay is types-only with zero runtime cost. + +- [`index.d.ts`](./index.d.ts) types every named export of `index.js`. The + high-traffic public API (`createRequestHandler`, `startServer`, `cors`, + `cache`, `createAuth`, `rateLimit`, `sitemap` / `sitemapIndex`, `Session`, + `json`, `readBody`, the `revalidate*` family, the context helpers, + `memoryStore` / `redisStore` / `getStore` / `setStore`, the auth providers) + is precisely typed from each source function's JSDoc; lower-traffic internals + (scanner / importmap / module-graph / vendor) get reasonable structural + declarations. It REUSES the core prop / metadata types + (`PageProps` / `LayoutProps` / `RouteHandlerContext`) rather than redefining + them, and defines the one server-owned shared type, `ActionResult` + (`@webjsdev/core` does not export it). The `./testing` types are pulled in via + `export * from './src/testing.d.ts'` (no duplication). +- [`src/check.d.ts`](./src/check.d.ts) types the `./check` subpath + (`checkConventions`, `RULES`, `Violation`). +- [`src/testing.d.ts`](./src/testing.d.ts) types the `./testing` subpath (the + handle() harness helpers). + +In `package.json`, the top-level `"types"` plus each `exports` entry's `types` +condition (FIRST in the object, as nodenext requires) wire the resolution; the +top-level `index.d.ts` is added to the `files` allowlist (`src/*.d.ts` ships via +the globbed `src`). **A new export added to `index.js` MUST get a declaration in +`index.d.ts`,** enforced by the drift test +`test/types/exports-drift.test.js` (asserts the declared set equals the runtime +export set); the type fixture + TS7016 counterfactual live in +`test/types/server-exports.test-d.ts` + `test/types/server-types.test.mjs`. + ## The `webjs` package.json config block (typed surface, #259) The `webjs.*` keys an app sets in `package.json` are typed and validated diff --git a/packages/server/index.d.ts b/packages/server/index.d.ts new file mode 100644 index 000000000..c749de0ee --- /dev/null +++ b/packages/server/index.d.ts @@ -0,0 +1,650 @@ +/** + * Public type surface for `@webjsdev/server`. + * + * The runtime is packages/server/index.js + src/*.js (JSDoc-annotated + * JavaScript, no build step); this overlay exists so a TypeScript app under + * `strict` + `nodenext`/`node16` resolves real types for the server import + * instead of emitting TS7016 ("Could not find a declaration file for module + * '@webjsdev/server'"). The whole package stays plain `.js` + JSDoc at runtime; + * these declarations are types-only with zero runtime cost. + * + * House style follows packages/core/index.d.ts: `export type` for type-only + * surface, `export declare function` / `export declare const` for runtime + * values. Shapes the framework consumes from core (`Metadata`, `PageProps`, + * `ActionResult`, …) are reused, never redefined; the high-traffic public API + * is precisely typed from each source function's JSDoc; lower-traffic internals + * (scanner / importmap / module-graph / vendor) get reasonable structural + * declarations. + * + * The drift guard `test/types/exports-drift.test.js` asserts the set of named + * exports here exactly matches the runtime named exports of index.js, so a + * future export added to index.js without a type is caught. + */ + +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'; + +// --------------------------------------------------------------------------- +// Shared local types +// --------------------------------------------------------------------------- + +/** 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. + +/** + * The `ActionResult` envelope a server action / page action returns. + * Mirrors AGENTS.md's documented shape; `@webjsdev/core` does not export it, + * so it is defined here (the one server-owned shared type). + */ +export type ActionResult = + | { success: true; data?: T; redirect?: string } + | { + success: false; + error?: string; + fieldErrors?: Record; + values?: Record; + status?: number; + }; + +/** The pluggable cache store interface (`memoryStore` / `redisStore` / custom). */ +export interface CacheStore { + get(key: string): Promise; + set(key: string, value: string, ttlMs?: number): Promise; + delete(key: string): Promise; + /** Atomically increment a counter; returns the new value (TTL set on creation only). */ + increment(key: string, ttlMs?: number): Promise; +} + +/** The pluggable logger interface. */ +export interface Logger { + info(msg: string, meta?: Record): void; + warn(msg: string, meta?: Record): void; + error(msg: string, meta?: Record): void; + debug?(msg: string, meta?: Record): void; +} + +/** The wire serializer used by the RPC transport. */ +export interface Serializer { + serialize(value: unknown): Promise | string; + deserialize(text: string): unknown; +} + +/** Options shared by `createRequestHandler` and `startServer`. */ +export interface RequestHandlerOptions { + /** The app root directory (the dir containing `app/`). */ + appDir: string; + /** Dev mode (live reload, TS strip, uncompressed bytes). */ + dev?: boolean; + /** Pluggable logger; defaults to webjs's `defaultLogger`. */ + logger?: Logger; + /** + * APM / Sentry sink invoked on a caught unhandled error (#239). Best-effort: + * a throw here is swallowed and never affects the response. + */ + onError?: ( + error: unknown, + ctx: { request: Request; requestId: string | null; phase: string }, + ) => void; +} + +/** A matched page route for a path, as returned by `routeFor`. */ +export interface RouteForResult { + moduleUrls: string[]; +} + +/** The object `createRequestHandler` resolves to. */ +export interface RequestHandler { + /** The request -> response entry point for embedding under any host. */ + handle: Handle; + /** Re-derive the route table / analysis after a source change (dev). */ + rebuild: () => Promise; + /** Resolve a pathname to its page-route module URLs (for 103 Early Hints), or null. */ + routeFor: (pathname: string) => RouteForResult | null; + /** Proactively run the first-request analysis in the background. Idempotent, best-effort. */ + warmup: () => Promise; + /** Current route table getter (used by the WebSocket subsystem). */ + getRouteTable: () => unknown; + /** The resolved app root. */ + appDir: string; + /** Whether the handler is in dev mode. */ + dev: boolean; + /** The active logger. */ + logger: Logger; +} + +/** Options for `startServer` (a superset of `RequestHandlerOptions`). */ +export interface StartServerOptions extends RequestHandlerOptions { + /** Listen port (default 8080; `PORT` env honored by the CLI). */ + port?: number; + /** Response compression (default: on in prod, off in dev). */ + compress?: boolean; +} + +/** The object `startServer` resolves to. */ +export interface ServerHandle { + /** The underlying `node:http` server. */ + server: import('node:http').Server; + /** Gracefully close the server (and abort the dev file watcher). */ + close: () => Promise; +} + +// --------------------------------------------------------------------------- +// Server entry: dev.js +// --------------------------------------------------------------------------- + +/** + * Create an embeddable request handler. Returns `{ handle, rebuild, routeFor, + * warmup, getRouteTable, appDir, dev, logger }`. Throws at boot on an + * unsupported Node version or a failed `env.{js,ts}` validation. + */ +export declare function createRequestHandler(opts: RequestHandlerOptions): Promise; + +/** Start a webjs HTTP server (thin wrapper around `createRequestHandler`). */ +export declare function startServer(opts: StartServerOptions): Promise; + +// --------------------------------------------------------------------------- +// node-version.js (#238) +// --------------------------------------------------------------------------- + +/** Parse a Node version string to its major integer. */ +export declare function parseMajor(version: string): number; +/** Parse an `engines.node` range string to the minimum acceptable major. */ +export declare function parseRequiredMajor(engines: string): number; +/** Pure comparison: is `current` at least `requiredMajor`? */ +export declare function checkNodeVersion( + current: string, + requiredMajor: number, +): { ok: boolean; current: string; currentMajor: number; requiredMajor: number; message: string }; +/** The minimum Node major, sourced from this package's `engines.node`. */ +export declare function requiredNodeMajor(): number; +/** Throw or exit if the running Node is too old. */ +export declare function assertNodeVersion(opts?: { + current?: string; + requiredMajor?: number; + onFail?: 'exit' | 'throw'; +}): void; + +// --------------------------------------------------------------------------- +// env-schema.js (#236) +// --------------------------------------------------------------------------- + +/** Pure env validator: check an env object against a schema or validator function. */ +export declare function validateEnv( + schema: object | ((env: Record) => void), + env: Record, +): { ok: boolean; errors: string[]; coerced: Record }; +/** Compose the aggregated boot-failure message from a list of errors. */ +export declare function formatEnvErrors(errors: string[]): string; +/** Read the optional app-root `env.{js,ts}` default export (null when absent). */ +export declare function loadEnvSchema( + appDir: string, + opts?: { dev?: boolean }, +): Promise) => void) | null>; +/** Side-effecting boot wrapper: validate `process.env`, write back coerced values, throw on failure. */ +export declare function applyEnvValidation( + appDir: string, + opts?: { dev?: boolean; env?: Record }, +): Promise; + +// --------------------------------------------------------------------------- +// router.js +// --------------------------------------------------------------------------- + +/** Scan `app/` and build the route table. */ +export declare function buildRouteTable(appDir: string): Promise; +/** Match a pathname against the page routes in a table; null when no page matches. */ +export declare function matchPage( + table: unknown, + pathname: string, +): { route: { file: string; layouts: string[] }; params: Record } | null; +/** Match a pathname against the API (`route.{js,ts}`) routes in a table; null when none. */ +export declare function matchApi( + table: unknown, + pathname: string, +): { route: { file: string }; params: Record } | null; + +// --------------------------------------------------------------------------- +// route-types.js (#258) +// --------------------------------------------------------------------------- + +/** Generate the augmentation `.d.ts` text for an app's routes (backs `webjs types`). */ +export declare function generateRouteTypes(appDir: string): Promise; + +// --------------------------------------------------------------------------- +// ssr.js +// --------------------------------------------------------------------------- + +/** Server-render a matched page route to a `Response`. */ +export declare function ssrPage( + route: unknown, + params: Record, + url: string, + opts?: Record, +): Promise; +/** Server-render the nearest `not-found.{js,ts}` to a `Response`. */ +export declare function ssrNotFound(notFoundFile: string, opts?: Record): Promise; + +// --------------------------------------------------------------------------- +// api.js +// --------------------------------------------------------------------------- + +/** Dispatch a matched `route.{js,ts}` handler (GET/POST/...) to a `Response`. */ +export declare function handleApi( + route: unknown, + params: Record, + webRequest: Request, + dev: boolean, +): Promise; + +// --------------------------------------------------------------------------- +// actions.js (server-action scanner + RPC endpoint) +// --------------------------------------------------------------------------- + +/** Scan the app for `.server.{js,ts}` files and build the RPC + expose index. */ +export declare function buildActionIndex(appDir: string, dev: boolean): Promise; +/** Whether a file path is a `.server.{js,ts,mjs,mts}` server file. */ +export declare function isServerFile(file: string): boolean; +/** SHA-256 hash of an action file's absolute path (the RPC endpoint addressing scheme). */ +export declare function hashFile(file: string): Promise; +/** Resolve a browser-visible URL path to its server module entry in the action index. */ +export declare function resolveServerModule(idx: unknown, urlPath: string): unknown; +/** Produce the generated RPC / throw-at-load stub source for a server file. */ +export declare function serveActionStub(idx: unknown, absFile: string): Promise; +/** Invoke a server action over the RPC endpoint, returning the `Response`. */ +export declare function invokeAction( + idx: unknown, + hash: string, + fnName: string, + req: Request, + onError?: (error: unknown) => void, +): Promise; + +// --------------------------------------------------------------------------- +// importmap.js +// --------------------------------------------------------------------------- + +/** Build the browser import map object (optionally content-hash-fingerprinted). */ +export declare function buildImportMap(opts?: { fingerprint?: boolean }): unknown; +/** Render the `