From 8d4aa73abec5e865ef68cccf29c485bbbeb52270 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 25 Jul 2026 01:19:10 +0800 Subject: [PATCH] feat(web): grow real node halves in connection and hmr connection binds the web transport: it injects httpServer + apiProxy and registers toFetchHandler(ctx.apiProxy) under the /api prefix (the node:http to fetch bridge moves in from the webserver, keeping the res-close disconnect detection and drain/close backpressure waits). hmr owns dev reload: a stat-poll watch per graph row driven by clientModuleHost.onGraphChanged, rebuilt(id) on content change, and the /plugins/events SSE route (GET/HEAD guarded); frame types are single-sourced in events.ts shared by both halves. --- packages/client/connection/package.json | 2 + packages/client/connection/src/api-path.ts | 8 + packages/client/connection/src/http-bridge.ts | 53 ++++++ packages/client/connection/src/index.ts | 40 ++++- packages/client/connection/src/invariant.ts | 7 +- .../client/connection/tests/node-half.spec.ts | 35 +++- packages/client/connection/tsconfig.json | 6 +- packages/client/hmr/package.json | 5 + packages/client/hmr/src/client/index.ts | 17 +- packages/client/hmr/src/events.ts | 16 ++ packages/client/hmr/src/index.ts | 155 +++++++++++++++++- packages/client/hmr/src/invariant.ts | 45 ++++- packages/client/hmr/tests/node-half.spec.ts | 118 ++++++++++++- packages/client/hmr/tsconfig.json | 8 +- 14 files changed, 461 insertions(+), 54 deletions(-) create mode 100644 packages/client/connection/src/api-path.ts create mode 100644 packages/client/connection/src/http-bridge.ts create mode 100644 packages/client/hmr/src/events.ts diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 778b431b71..c26cafb143 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -43,10 +43,12 @@ "src" ], "peerDependencies": { + "@deepseek-ai/dsh-host-webserver": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/client/connection/src/api-path.ts b/packages/client/connection/src/api-path.ts new file mode 100644 index 0000000000..30e91522a2 --- /dev/null +++ b/packages/client/connection/src/api-path.ts @@ -0,0 +1,8 @@ +/** + * The /api URL prefix — single source for both halves of the web transport. + * The node half registers this prefix on the web server; browser-side path + * literals currently live in the apiproxy client layer (out of scope here). + */ + +/** Route prefix owning every api request (`/api` and `/api/`). */ +export const API_PATH = '/api' diff --git a/packages/client/connection/src/http-bridge.ts b/packages/client/connection/src/http-bridge.ts new file mode 100644 index 0000000000..c929b208a7 --- /dev/null +++ b/packages/client/connection/src/http-bridge.ts @@ -0,0 +1,53 @@ +/** + * node:http ↔ WHATWG fetch bridge for the /api transport (host side of the + * web carrier; the fetch-shaped handler itself is transport-agnostic). + */ + +import type { IncomingMessage, ServerResponse } from 'node:http' + +/** Bridge one node:http request to the fetch-shaped handler (client close aborts; SSE bodies stream out chunk by chunk). */ +export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: { fetch: typeof fetch }): Promise { + const abort = new AbortController() + // Client-disconnect detection MUST hang off the response, not the request: + // since Node 16, IncomingMessage 'close' fires as soon as the request body is + // fully consumed (immediately for a bodyless GET), which would abort every SSE + // stream right after open. ServerResponse 'close' fires on connection teardown; + // writableEnded distinguishes a normal end() from the client going away. + res.on('close', () => { + if (!res.writableEnded) abort.abort() + }) + const chunks: Buffer[] = [] + for await (const chunk of req) chunks.push(chunk as Buffer) + /* v8 ignore next 3 -- `??` arms: node:http always sets url/method on server + requests; the fields are only optional on the client-side IncomingMessage type */ + const request = new Request(new URL(req.url ?? '/', 'http://dsh.internal'), { + method: req.method ?? 'GET', + headers: Object.fromEntries(Object.entries(req.headers).filter(([, v]) => typeof v === 'string') as [string, string][]), + ...chunks.length > 0 ? { body: Buffer.concat(chunks) } : {}, + signal: abort.signal, + }) + const response = await apiHandler.fetch(request) + res.writeHead(response.status, Object.fromEntries(response.headers.entries())) + if (response.body === null) { + res.end() + return + } + for await (const chunk of response.body) { + // Backpressure: a false return means the socket buffer is full — wait for drain + // instead of buffering unboundedly (slow/suspended SSE consumers). 'close' also + // resolves so a mid-wait disconnect can't park this loop forever; the close + // handler above aborts the handler stream, which then ends the iteration. + if (!res.write(chunk)) { + await new Promise((resolve) => { + const done = (): void => { + res.off('drain', done) + res.off('close', done) + resolve() + } + res.once('drain', done) + res.once('close', done) + }) + } + } + res.end() +} diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 313db07225..61d718b618 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -1,10 +1,36 @@ /** - * Connection plugin, node half. The package IS a dshClient plugin: the wire - * consumer layer lives in its client half in full (src/client/ — contract: - * api-contracts v3 section 3, inventory §3.2); consumers import the /client - * subpath. The empty apply exists so the plugin appears in the host Loader - * (lifecycle governance + dshClient discovery). + * Connection plugin, node half: the host end of the web transport. Registers + * the /api prefix route on the web server and bridges node:http requests to + * the transport-agnostic fetch-shaped api handler. The wire consumer layer + * lives in the client half (src/client/ — contract: api-contracts v3 + * section 3); consumers import the /client subpath. */ +import type { Context } from 'cordis' +// Type-only route import; it also carries the httpServer Context merge. +import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' +import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' +import { API_PATH } from './api-path.ts' +import { bridge } from './http-bridge.ts' -/** Host plugin body — no host-side behavior for the connection plugin. */ -export function apply(_ctx: unknown): void {} +export { API_PATH } from './api-path.ts' + +/** Cordis plugin name. */ +export const name = 'client-connection' + +/** Required services: the route registry and the api gateway. */ +export const inject = ['httpServer', 'apiProxy'] + +/** + * Mount the /api transport: wrap the api gateway into a fetch handler and + * serve it under the /api prefix. + * @param ctx - host plugin context carrying httpServer and apiProxy. + */ +export function apply(ctx: Context): void { + const apiHandler = toFetchHandler(ctx.apiProxy) + const route: WebRoute = { + kind: 'prefix', + path: API_PATH, + handler: (req, res) => bridge(req, res, apiHandler), + } + ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route') +} diff --git a/packages/client/connection/src/invariant.ts b/packages/client/connection/src/invariant.ts index df16e00fd4..1112a4e638 100644 --- a/packages/client/connection/src/invariant.ts +++ b/packages/client/connection/src/invariant.ts @@ -15,10 +15,11 @@ export const name = 'client-connection-invariant' export const inject = ['invariants'] /** - * No runtime invariant: the pure wire layer emits no cordis events and owns no + * No runtime invariant: the wire layer emits no cordis events and owns no * mutable cross-plugin relation — stream/reconnect sequencing is exercised - * directly by its behavior specs, and rpcId round-trip discipline is owned by - * the apiproxy contract layer. + * directly by its behavior specs, rpcId round-trip discipline is owned by the + * apiproxy contract layer, and the node half's single route registration's + * register/dispose symmetry is audited by the webserver package's invariant. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index efba1b0445..e9e880cfb4 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -1,10 +1,33 @@ -/** Node half: the empty host apply (Loader governance + dshClient discovery placeholder). */ +/** Node half: registers the /api prefix route bridging to the api gateway. */ +import { Context } from 'cordis' import { describe, expect, it } from 'vitest' -import { apply } from '../src/index.ts' +import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api' +import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver' +import { API_PATH, apply, inject } from '../src/index.ts' -describe('node half', () => { - it('apply is a no-op host placeholder', () => { - apply(undefined) - expect(true).toBe(true) // reaching here without throw is the contract +describe('connection node half', () => { + it('registers the /api prefix route and removes it with the fiber', async () => { + const ctx = new Context() + const routes: WebRoute[] = [] + // Structural fake: the plugin only touches register(); the service class + // carries private state a literal cannot (and need not) reproduce. + const httpServer: Pick = { + register(route) { + routes.push(route) + return () => { routes.splice(routes.indexOf(route), 1) } + }, + tapIndex: () => () => {}, + port: 0, + } + ctx.provide('httpServer', httpServer as HttpServerService) + ctx.provide('apiProxy', {} as unknown as ApiProxy) + + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(routes).toHaveLength(1) + expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH }) + + await fiber.dispose() + expect(routes).toHaveLength(0) }) }) diff --git a/packages/client/connection/tsconfig.json b/packages/client/connection/tsconfig.json index 8b0357cf97..97d020dc53 100644 --- a/packages/client/connection/tsconfig.json +++ b/packages/client/connection/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../../tsconfig.base.client.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/types" + "outDir": "lib/types", + "types": ["node"] }, "include": [ "src" @@ -20,6 +21,9 @@ { "path": "../../host/apiproxy" }, + { + "path": "../../host/webserver" + }, { "path": "../../ui/user-approval" }, diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json index 129a7e879a..0773a1fce5 100644 --- a/packages/client/hmr/package.json +++ b/packages/client/hmr/package.json @@ -28,15 +28,20 @@ "immediately": true }, "license": "BSD-3-Clause", + "dependencies": { + "schemastery": "^3.18.0" + }, "peerDependencies": { "@cordisjs/plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-client-modules": "^0.0.1", + "@deepseek-ai/dsh-host-webserver": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", + "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7" }, diff --git a/packages/client/hmr/src/client/index.ts b/packages/client/hmr/src/client/index.ts index 21df48b561..eae29e8db3 100644 --- a/packages/client/hmr/src/client/index.ts +++ b/packages/client/hmr/src/client/index.ts @@ -64,20 +64,11 @@ */ import type { Context } from 'cordis' import type { Entry, Loader } from '@cordisjs/plugin-loader' -import type { WebBootGraph } from '@deepseek-ai/dsh-client-modules' +import type { PluginsEventFrame } from '../events.ts' +import { EVENTS_ENDPOINT } from '../events.ts' -/** - * Frames on the `GET /plugins/events` system SSE channel (owned host-side by - * dsh-host-webserver's PluginEventFrame). Mirrored here because this is a - * wire boundary: frames arrive as JSON text and are validated at the parse - * point, not shared as a same-process typed seam. - */ -export type PluginsEventFrame = - | { type: 'graph'; graph: WebBootGraph } - | { type: 'rebuilt'; id: string; rev: string } - -/** System SSE endpoint pushing graph/rebuilt frames (wire protocol constant). */ -export const EVENTS_ENDPOINT = '/plugins/events' +export type { PluginsEventFrame } from '../events.ts' +export { EVENTS_ENDPOINT } from '../events.ts' /** Cordis plugin name. */ export const name = 'client-hmr' diff --git a/packages/client/hmr/src/events.ts b/packages/client/hmr/src/events.ts new file mode 100644 index 0000000000..756bd24074 --- /dev/null +++ b/packages/client/hmr/src/events.ts @@ -0,0 +1,16 @@ +/** + * Wire protocol of the `/plugins/events` dev SSE channel — single source for + * both halves of this package. Frames still cross a wire boundary: the + * browser half validates them at its JSON parse point; sharing the type keeps + * the two ends from drifting, not from parsing. + */ + +import type { WebBootGraph } from '@deepseek-ai/dsh-client-modules' + +/** One SSE frame: the full graph on connect, or one rebuilt bundle notice. */ +export type PluginsEventFrame = + | { type: 'graph'; graph: WebBootGraph } + | { type: 'rebuilt'; id: string; rev: string } + +/** System SSE endpoint pushing graph/rebuilt frames (wire protocol constant). */ +export const EVENTS_ENDPOINT = '/plugins/events' diff --git a/packages/client/hmr/src/index.ts b/packages/client/hmr/src/index.ts index cca3c0ddac..4c97ad8bd1 100644 --- a/packages/client/hmr/src/index.ts +++ b/packages/client/hmr/src/index.ts @@ -1,9 +1,152 @@ /** - * HMR plugin, node half. The package IS a dshClient plugin (dev-only row in - * the host graph): the reload driver lives in its client half in full - * (src/client/); the empty apply exists so the plugin appears in the host - * Loader (lifecycle governance + dshClient discovery). + * HMR plugin, node half: the host end of the dev reload chain. Stat-polls + * every graph row's client bundle (fs.watchFile — polling by design: network + * mounts deliver no inotify events), reports content changes through + * `clientModuleHost.rebuilt(id)`, and serves the `/plugins/events` SSE channel + * broadcasting graph/rebuilt frames to the browser half (src/client/). + * Dev-only row: prod compositions never mount this plugin. */ +import type { Stats } from 'node:fs' +import { unwatchFile, watchFile } from 'node:fs' +import type { ServerResponse } from 'node:http' +import type { Context } from 'cordis' +import z from 'schemastery' +// Empty type imports carry the clientModuleHost/httpServer Context merges. +import type {} from '@deepseek-ai/dsh-client-modules' +import type {} from '@deepseek-ai/dsh-host-webserver' +import type { PluginsEventFrame } from './events.ts' +import { EVENTS_ENDPOINT } from './events.ts' -/** Host plugin body — no host-side behavior for the HMR plugin. */ -export function apply(): void {} +export type { PluginsEventFrame } from './events.ts' +export { EVENTS_ENDPOINT } from './events.ts' + +/** Cordis plugin name. */ +export const name = 'client-hmr' + +/** Required services: the web plugin table and the route registry. */ +export const inject = ['clientModuleHost', 'httpServer'] + +/** Plugin config, validated by the same-named schemastery schema. */ +export interface Config { + /** Bundle stat-poll interval in milliseconds (default 500, the build-side watcher's polling default). */ + pollIntervalMs?: number +} + +export const Config: z = z.object({ + pollIntervalMs: z.number().step(1).min(1).default(500), +}) + +/** Serialize one frame as an SSE data line. */ +function sseData(frame: PluginsEventFrame): string { + return `data: ${JSON.stringify(frame)}\n\n` +} + +/** + * Mount the dev chain: bundle watches, rebuilt reporting, and the SSE channel. + * @param ctx - host plugin context carrying clientModuleHost and httpServer. + * @param config - validated {@link Config}. + */ +export function apply(ctx: Context, config: Config): void { + // schemastery's .default() guarantees the field is set after validation. + const pollIntervalMs = config.pollIntervalMs as number + + // --- bundle watch: one fs.watchFile stat poll per graph row ------------- + const watched = new Map void }>() + + const watchRow = (id: string, path: string): void => { + const listener = (curr: Stats, prev: Stats): void => { + // fs.watchFile fires on any stat delta (atime included); only content + // signals count. An all-zero curr means the file vanished mid-rebuild + // — the completing write fires the next tick, so skipping is safe. + if (curr.mtimeMs === prev.mtimeMs && curr.size === prev.size) return + if (curr.mtimeMs === 0) return + try { + // rebuilt() re-hashes; an unchanged hash stays silent (clientModuleHost + // fires onRebuilt only on a real rev change). A torn read of a + // half-written bundle self-heals on the next poll tick. + ctx.clientModuleHost.rebuilt(id) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'ENOENT') return // mid-rename window; the completed write fires the next poll tick + ctx.logger.warn(error) + } + } + watchFile(path, { interval: pollIntervalMs, persistent: false }, listener) + watched.set(id, { path, listener }) + } + + // Diff the watch set against the current graph: drop watches for removed + // rows (or rows whose bundle path moved), add watches for new rows. + const syncWatches = (): void => { + const rows = new Map() + for (const row of ctx.clientModuleHost.graph().entries) { + const path = ctx.clientModuleHost.clientPath(row.id) + if (path !== undefined) rows.set(row.id, path) + } + for (const [id, watch] of watched) { + if (rows.get(id) === watch.path) continue + unwatchFile(watch.path, watch.listener) + watched.delete(id) + } + for (const [id, path] of rows) { + if (!watched.has(id)) watchRow(id, path) + } + } + + ctx.effect(() => { + // Initial sync covers rows already in the graph; the subscription covers + // rows arriving later (boot-window activations, including this plugin's + // own row — no self-exemption, a modules/hmr rebuild rides the same chain). + syncWatches() + const unsubscribe = ctx.clientModuleHost.onGraphChanged(syncWatches) + return () => { + unsubscribe() + for (const { path, listener } of watched.values()) unwatchFile(path, listener) + watched.clear() + } + }, 'client-hmr: bundle watches') + + // --- /plugins/events SSE channel ---------------------------------------- + const connections = new Set() + + const connect = (res: ServerResponse): void => { + res.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + 'connection': 'keep-alive', + }) + // Comment line on open so clients/proxies see a live channel even when + // no rebuild ever happens; EventSource frame parsing skips it naturally. + res.write(': connected\n\n') + res.write(sseData({ type: 'graph', graph: ctx.clientModuleHost.graph() })) + connections.add(res) + res.on('close', () => { connections.delete(res) }) + } + + ctx.effect(() => { + const disposeRoute = ctx.httpServer.register({ + kind: 'exact', + path: EVENTS_ENDPOINT, + handler: (req, res) => { + // Named routes match ahead of the carrier's method gate; keep the old + // global 405 semantics for non-GET hits on this endpoint. + if (req.method !== 'GET' && req.method !== 'HEAD') { + res.writeHead(405) + res.end() + return + } + connect(res) + }, + }) + const unsubscribe = ctx.clientModuleHost.onRebuilt((id, rev) => { + const line = sseData({ type: 'rebuilt', id, rev }) + for (const res of connections) res.write(line) + }) + return () => { + unsubscribe() + disposeRoute() + for (const res of connections) res.destroy() + connections.clear() + } + }, 'client-hmr: /plugins/events channel') +} diff --git a/packages/client/hmr/src/invariant.ts b/packages/client/hmr/src/invariant.ts index a4c546c991..a18b7f6a2e 100644 --- a/packages/client/hmr/src/invariant.ts +++ b/packages/client/hmr/src/invariant.ts @@ -3,8 +3,7 @@ * @module @deepseek-ai/dsh-client-hmr/invariant */ -/* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context, Fiber } from 'cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-hmr' @@ -14,14 +13,43 @@ export const name = 'client-hmr-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] +/** Live fs.watchFile pollers (this package is the composition's only stat-poll user). */ +function statWatchers(): number { + return process.getActiveResourcesInfo().filter(kind => kind === 'StatWatcher').length +} + /** - * No runtime invariant: a dev-only reload driver — it consumes the loader - * entry tree and module cache but owns no events and no cross-plugin mutable - * state; reload correctness (dispose → style removal → re-execute ordering) - * is observable only through the assembled browser runtime, not a host-side - * event relation. + * Owned relation: every bundle stat watcher the node half starts must die + * with its fiber — a surviving poller would keep re-hashing bundles for a + * torn-down dev chain forever. Checked as a baseline delta: the StatWatcher + * count observed at fiber creation must be restored once disposal has drained + * the fiber's effects (`internal/plugin` fires at dispose start; the microtask + * hop lets the disposer queue its unload before `fiber.await()` joins it). + * SSE-connection and listener teardown live inside the same ctx.effect + * disposers, so the watcher count is the relation's observable proxy. */ -const install: InvariantInstaller = () => {} +const install: InvariantInstaller = (ctx, fail) => { + const baselines = new WeakMap() + ctx.on('internal/plugin', (fiber) => { + if (fiber.name !== 'client-hmr') return + if (fiber.uid !== null) { + baselines.set(fiber, statWatchers()) + return + } + const baseline = baselines.get(fiber) + if (baseline === undefined) return + // Returned to the emitter: emitPluginDisposed awaits-and-logs async + // listener failures, so a violation surfaces loudly instead of unhandled. + return (async () => { + await Promise.resolve() + await fiber.await() + const remaining = statWatchers() + if (remaining > baseline) { + fail(`client-hmr fiber disposed but ${remaining - baseline} bundle stat watcher(s) survived teardown`) + } + })() + }, { global: true }) +} /** * Register this package's invariant companion. @@ -30,4 +58,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/hmr/tests/node-half.spec.ts b/packages/client/hmr/tests/node-half.spec.ts index e340263b7a..df48db92d9 100644 --- a/packages/client/hmr/tests/node-half.spec.ts +++ b/packages/client/hmr/tests/node-half.spec.ts @@ -1,14 +1,116 @@ /** - * Node half of the HMR plugin: an empty apply placeholder (the reload driver - * lives in the client half) whose only contract is mounting and disposing - * cleanly in the host Loader. + * Node half of the HMR plugin: bundle watches follow the graph, stat changes + * report through clientModuleHost.rebuilt, and everything dies with the fiber. */ -import { describe, expect, it } from 'vitest' -import { apply } from '@deepseek-ai/dsh-client-hmr' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WebBootGraph, ClientModuleHostService } from '@deepseek-ai/dsh-client-modules' +import type { WebRoute, HttpServerService } from '@deepseek-ai/dsh-host-webserver' +import { apply, Config, EVENTS_ENDPOINT, inject } from '../src/index.ts' + +const POLL_MS = 20 + +let dir: string + +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-')) }) +afterEach(() => { rmSync(dir, { recursive: true, force: true }) }) + +/** + * Controllable clientModuleHost fake over a mutable id → bundle-path table. + * Structural (Pick+cast): the plugin only touches the read/notify surface; + * the service class carries private scan state a literal need not reproduce. + */ +type FakeHost = ClientModuleHostService & { rebuiltCalls: string[]; fireGraphChanged(): void } +function fakeClientModuleHost(rows: Map): FakeHost { + const graphListeners = new Set<() => void>() + const rebuiltCalls: string[] = [] + const fake: Pick = { + rebuiltCalls, + fireGraphChanged: () => { for (const l of graphListeners) l() }, + graph: (): WebBootGraph => ({ + rev: 'r', + entries: [...rows.keys()].map(id => ({ id, url: `/plugins/${id}/client.js?rev=r`, rev: 'r' })), + }), + clientPath: id => rows.get(id), + rebuilt: (id) => { rebuiltCalls.push(id); return 'r2' }, + onRebuilt: () => () => {}, + onGraphChanged: (listener) => { + graphListeners.add(listener) + return () => { graphListeners.delete(listener) } + }, + } + return fake as FakeHost +} + +// Structural fake: the plugin only touches register(); the service class +// carries private state a literal cannot (and need not) reproduce. +function fakeHttpServer(routes: WebRoute[]): HttpServerService { + const fake: Pick = { + register(route) { + routes.push(route) + return () => { routes.splice(routes.indexOf(route), 1) } + }, + tapIndex: () => () => {}, + port: 0, + } + return fake as HttpServerService +} + +async function mount(clientModuleHost: FakeHost, httpServer: HttpServerService) { + const ctx = new Context() + ctx.provide('clientModuleHost', clientModuleHost) + ctx.provide('httpServer', httpServer) + const fiber = ctx.plugin( + { inject: [...inject], Config, apply }, + { pollIntervalMs: POLL_MS }, + ) + await fiber.await() + return fiber +} describe('hmr node half', () => { - it('apply is a no-op host placeholder', () => { - apply() - expect(true).toBe(true) // reaching here without throw is the contract + it('watches graph bundles, reports stat changes, and unwatches on dispose', async () => { + const bundle = join(dir, 'a.js') + writeFileSync(bundle, 'v1') + const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]])) + const routes: WebRoute[] = [] + const fiber = await mount(clientModuleHost, fakeHttpServer(routes)) + + expect(routes).toHaveLength(1) + expect(routes[0]).toMatchObject({ kind: 'exact', path: EVENTS_ENDPOINT }) + + // Nudge mtime past stat granularity so the poller sees a content signal. + await new Promise(resolve => setTimeout(resolve, POLL_MS * 2)) + writeFileSync(bundle, 'v2-longer') + await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-a') }, { timeout: 3_000 }) + + await fiber.dispose() + expect(routes).toHaveLength(0) + // Watcher gone: further file changes report nothing. + clientModuleHost.rebuiltCalls.length = 0 + writeFileSync(bundle, 'v3-even-longer') + await new Promise(resolve => setTimeout(resolve, POLL_MS * 4)) + expect(clientModuleHost.rebuiltCalls).toHaveLength(0) + }) + + it('follows graph changes: rows added after activation get watched', async () => { + const early = join(dir, 'early.js') + const late = join(dir, 'late.js') + writeFileSync(early, 'v1') + const rows = new Map([['pkg-early', early]]) + const clientModuleHost = fakeClientModuleHost(rows) + const fiber = await mount(clientModuleHost, fakeHttpServer([])) + + writeFileSync(late, 'v1') + rows.set('pkg-late', late) + clientModuleHost.fireGraphChanged() + + await new Promise(resolve => setTimeout(resolve, POLL_MS * 2)) + writeFileSync(late, 'v2-longer') + await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-late') }, { timeout: 3_000 }) + await fiber.dispose() }) }) diff --git a/packages/client/hmr/tsconfig.json b/packages/client/hmr/tsconfig.json index 764741c9cb..9ad1837558 100644 --- a/packages/client/hmr/tsconfig.json +++ b/packages/client/hmr/tsconfig.json @@ -8,7 +8,7 @@ "DOM", "DOM.Iterable" ], - "types": [] + "types": ["node"] }, "include": [ "src" @@ -23,6 +23,12 @@ { "path": "../modules" }, + { + "path": "../../host/webserver" + }, + { + "path": "../../../vendor/schemastery" + }, { "path": "../../support/invariants" }