fix(client): load plugin bundles as external scripts

This commit is contained in:
imccyu
2026-08-04 14:15:32 +08:00
parent 64f9a83bd6
commit 6c6d933732
26 changed files with 252 additions and 159 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/modules/README.md
README.md: 99565b349d782c58752ac3e73ce7c0be527f78a8
README.zh.md: a8ed0a4949ccefce53933b4f2fb8f51f5291684f
README.md: 7d661c806955d0fac021dd6620994aab83c0f773
README.zh.md: a1da42a552dbe8770fcb78bf458c01a0057ce8dd

View File

@@ -6,9 +6,9 @@ Client module system: the browser peer of Node's internal ESM loader, built as a
Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`window.__ModuleLoader__.load({id, factory})`); every module body side effect — CSS injection included — lives in the factory closure and runs at materialization (`factory(require)` → export surface, memoized in `loadCache`), not at script execution. A factory that requires another registered-but-unmaterialized module materializes it recursively, so load order needs no external sequencing; require cycles throw (factory-form CJS cannot deliver partial exports). `<id>/client` and the bare id name the same surface (a plugin bundle IS its package's client half).
Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → fetch + execute + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the fetch branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (fetch + execute, registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and the materialized record so the next prefetch/import refetches (the HMR hook).
Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → load its external classic script + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the asynchronous load branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (script load and factory registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and materialized record so the next prefetch/import reloads the script (the HMR hook).
The Node half scans enabled Loader entries for web `dshClient` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, and serves it under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
The Node half scans enabled Loader entries for web `dshClient` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, and serves it with its source map under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
## Model Experience

View File

@@ -6,9 +6,9 @@
惰性 CJS 模型web2执行插件组合包只会注册其 factory`window.__ModuleLoader__.load({id, factory})`);每个模块主体的副作用(包括 CSS 注入)都位于 factory 闭包中,在物化时运行(`factory(require)` → 导出表层,并在 `loadCache` 中记忆化),不会在脚本执行时运行。如果 factory 依赖另一个已注册但尚未物化的模块系统会递归物化它因此加载顺序无需外部编排require 循环会抛出异常factory 形式的 CJS 无法提供部分导出)。`<id>/client` 与裸 id 指向同一表层(一个插件组合包就是其包的客户端侧)。
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`app-shell→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 抓取 + 执行 + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含抓取分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段加载钩子(抓取 + 执行,只注册;并发调用共享一个进行中的任务);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新抓取;它是 HMR热模块替换钩子。
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`app-shell→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 加载外部 classic script + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含异步加载分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段到达钩子(只加载脚本并注册 factory;并发调用共享一个进行中的任务);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新加载脚本;它是 HMR热模块替换钩子。
Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费客户端导出的构建产物;缺失文件共享一条构建要求,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。
Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件及其 sourcemap。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费客户端导出的构建产物;缺失文件共享一条构建要求,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。
## 模型体验

View File

@@ -17,11 +17,11 @@
*
* Resolution branch order (import): seed word → shell instance; memoized
* record → surface; static registry (shell-own modules, e.g. app-shell) →
* module; registered factory → materialize; graph row → fetch + execute +
* materialize; anything else → throw (loud — the runtime mirror of the
* module; registered factory → materialize; graph row → load + materialize;
* anything else → throw (loud — the runtime mirror of the
* build-time bundle purity gate). The synchronous `require` handed to
* factories walks the same order minus the fetch branch: fetching is async,
* so only already-executed bundles can be required — and cross-plugin value
* factories walks the same order minus the load branch: loading is async,
* so only already-registered bundles can be required — and cross-plugin value
* imports are a build error anyway.
*
* This file is the browser-safe contract face (zero node imports): the
@@ -56,7 +56,7 @@ export interface WebBootEntry {
rev: string
/** Package-name dependency edges, informational (preflight display / HMR diffing). */
inject?: string[]
/** Stage-one prefetch mark: fetch + execute (factory registration) during module-face boot. */
/** Stage-one prefetch mark: load the script for factory registration during module-face boot. */
immediately?: boolean
}
@@ -210,18 +210,17 @@ export interface ClientModuleLoader {
*/
registerStatic(id: string, module: unknown): void
/**
* Stage-one arrival: fetch the entry's bundle and execute it, registering
* its factory (no materialization — module side effects wait for import).
* Stage-one arrival: load the entry's script to register its factory (no
* materialization — module side effects wait for import).
* No-op for static-registered ids and ids whose factory is already
* registered; concurrent calls share one in-flight task. To force a fresh
* fetch (HMR), {@link invalidate} first.
* load (HMR), {@link invalidate} first.
* @param id - graph entry name.
*/
prefetch(id: string): Promise<void>
/**
* Full reset of one module: drop its registered factory, its materialized
* record, and any consumed bundle text, so the next prefetch/import
* refetches and re-executes (the HMR invalidation hook).
* Full reset of one module: drop its registered factory and materialized
* record so the next prefetch/import reloads it (the HMR invalidation hook).
* @param id - entry name to invalidate.
*/
invalidate(id: string): void
@@ -233,11 +232,6 @@ export interface ClientModuleSystemOptions {
modules: BootModuleRow[]
/** Module-table seed: platform-singleton specifier → shell instance. */
staticModules: Record<string, unknown>
/** Bundle fetch seam (parallelizable half). Defaults to same-origin fetch().text(). */
fetchBundle?: (url: string) => Promise<string>
/**
* Bundle execution seam (synchronously performs the load() registration).
* Defaults to a <script> element carrying the code.
*/
executeBundle?: (code: string, url: string) => void
/** Bundle-load seam. Defaults to a same-origin classic `<script src>` element. */
loadBundle?: (url: string) => Promise<void>
}

View File

@@ -2,38 +2,28 @@
* ClientModuleSystem — the implementation behind the {@link ClientModuleLoader}
* seam. The conceptual contract (lazy CJS model, resolution branch order) is
* documented on the public interfaces in `./manifest.ts`; this file owns the
* state tables and the fetch/execute/materialize machinery.
* state tables and the load/materialize machinery.
*/
import type {
BootModuleRow, ClientModuleLoader, ClientModuleRecord,
ClientModuleSystemOptions, ClientPluginHandoff, DshWindow,
} from './manifest.ts'
/** A registered-but-unmaterialized bundle: the factory plus its source URL (diagnostics). */
interface RegisteredFactory {
factory: ClientPluginHandoff['factory']
url: string
}
/** Default bundle fetch seam: same-origin fetch().text(). */
const defaultFetchBundle = async (url: string): Promise<string> => {
const res = await fetch(url)
if (!res.ok) throw new Error(`client-modules: bundle fetch ${url} answered ${String(res.status)}`)
return res.text()
}
/** Default bundle execution seam: a <script> element carrying the code. */
const defaultExecuteBundle = (code: string, url: string): void => {
/** Default bundle-load seam: same-origin external classic script. */
const defaultLoadBundle = (url: string): Promise<void> => new Promise((resolve, reject) => {
const el = document.createElement('script')
// Inline execution (not src) so the fetch half stays parallelizable; the
// sourceURL comment keeps devtools stack frames attributed to the bundle.
el.textContent = `${code}\n//# sourceURL=${url}`
document.head.appendChild(el)
// Execution is synchronous for inline scripts: the factory is registered by
// now, so the node (and its source text) has no further job. Removing it
// keeps repeated HMR rebuilds from accumulating dead script nodes.
el.remove()
}
el.async = true
el.src = url
el.addEventListener('load', () => {
el.remove()
resolve()
}, { once: true })
el.addEventListener('error', () => {
el.remove()
reject(new Error(`client-modules: bundle script ${url} failed to load`))
}, { once: true })
document.head.append(el)
})
/**
* A plugin bundle IS its package's client half: `<id>/client` (the exports
@@ -72,31 +62,21 @@ export class ClientModuleSystem implements ClientModuleLoader {
private readonly seed: Map<string, unknown>
private readonly statics = new Map<string, unknown>()
private readonly factories = new Map<string, RegisteredFactory>()
/** In-flight prefetch (fetch + execute) per id; concurrent callers share it. */
private readonly factories = new Map<string, ClientPluginHandoff['factory']>()
/** In-flight prefetch (script load) per id; concurrent callers share it. */
private readonly pendingArrival = new Map<string, Promise<void>>()
/** Materialization re-entrancy guard: factory-form CJS cannot deliver partial exports, so a cycle is fatal. */
private readonly materializing = new Set<string>()
private readonly graphRows = new Map<string, BootModuleRow>()
// Execution URL of the bundle currently being executed (bound into the
// factory registration so diagnostics can name the source).
private executingUrl = ''
// Graph id of the row currently being executed ('' outside arrive):
// the load sink cross-checks the handoff id against it so a mis-stamped
// bundle cannot register under another entry's identity.
private executingId = ''
private readonly fetchBundle: (url: string) => Promise<string>
private readonly executeBundle: (code: string, url: string) => void
private readonly loadBundle: (url: string) => Promise<void>
/**
* Build the module system over the parsed boot rows.
* @param options - module rows, module-table staticModules, fetch/execute seams.
* @param options - module rows, module-table staticModules, and bundle-load seam.
*/
constructor(options: ClientModuleSystemOptions) {
this.seed = new Map(Object.entries(options.staticModules))
this.fetchBundle = options.fetchBundle ?? defaultFetchBundle
this.executeBundle = options.executeBundle ?? defaultExecuteBundle
this.loadBundle = options.loadBundle ?? defaultLoadBundle
for (const row of options.modules) {
if (this.graphRows.has(row.id)) throw new Error(`client-modules: duplicate graph entry "${row.id}"`)
@@ -110,37 +90,22 @@ export class ClientModuleSystem implements ClientModuleLoader {
// Registration is keyed by the handoff id; a duplicate means a bundle
// executed twice without an invalidate — always a bug, always loud.
if (this.factories.has(handoff.id)) throw new Error(`client-modules: duplicate factory registration for "${handoff.id}" (bundle executed twice without invalidate?)`)
// A fetched row's bundle must register the id its row names — a
// mis-stamped bundle registering under another entry's identity
// would let that entry silently materialize foreign exports.
if (this.executingId !== '' && handoff.id !== this.executingId) {
throw new Error(`client-modules: bundle ${this.executingUrl} registered "${handoff.id}" while arriving for "${this.executingId}" (mis-stamped bundle id)`)
}
this.factories.set(handoff.id, { factory: handoff.factory, url: this.executingUrl })
this.factories.set(handoff.id, handoff.factory)
},
}
}
/** Fetch + execute one graph row so its factory is registered (idempotent per in-flight arrival). */
/** Load one graph row so its factory is registered (idempotent per in-flight arrival). */
private arrive(row: BootModuleRow): Promise<void> {
const { id, url } = row
const pending = this.pendingArrival.get(id)
if (pending !== undefined) return pending
if (this.factories.has(id)) return Promise.resolve()
const task = (async (): Promise<void> => {
const code = await this.fetchBundle(url)
this.executingUrl = url
this.executingId = id
try {
this.executeBundle(code, url)
} finally {
this.executingUrl = ''
this.executingId = ''
}
const task = this.loadBundle(url).then(() => {
if (!this.factories.has(id)) {
throw new Error(`client-modules: bundle ${url} executed without registering "${id}" via __ModuleLoader__.load`)
throw new Error(`client-modules: bundle ${url} loaded without registering "${id}" via __ModuleLoader__.load`)
}
})().finally(() => { this.pendingArrival.delete(id) })
}).finally(() => { this.pendingArrival.delete(id) })
this.pendingArrival.set(id, task)
return task
}
@@ -158,7 +123,7 @@ export class ClientModuleSystem implements ClientModuleLoader {
this.materializing.add(id)
try {
const edges = new Set<string>()
const surface = registered.factory(this.makeRequire(edges))
const surface = registered(this.makeRequire(edges))
const record: ClientModuleRecord = { id, surface, styles: claimStyles(id), edges }
this.loadCache.set(id, record)
return record

View File

@@ -2,8 +2,8 @@
* Node half of the client module system (dshClient dual-face package): scans
* the host Loader's entries for `dshClient` packages, composes the
* `window.__DSH_BOOT__` entry graph (wire single source: {@link WebBootEntry}
* in `./client/manifest.ts`), serves `/plugins/<id>/client.js`, taps the
* index render to inject the boot manifest, and provides the
* in `./client/manifest.ts`), serves `/plugins/<id>/client.js` and its source
* map, taps the index render to inject the boot manifest, and provides the
* `clientModuleHost` service (the HMR node half's registration/notification
* face).
*
@@ -424,9 +424,15 @@ export class ClientModuleHostService extends Service {
const pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://x').pathname)
// The id may contain a scope slash. Anything else under /plugins (including
// /plugins/events when the HMR row is absent) is an unknown resource.
const path = pathname.startsWith('/plugins/') && pathname.endsWith('/client.js')
? this.clientPath(pathname.slice('/plugins/'.length, -'/client.js'.length))
const prefix = '/plugins/'
const mapSuffix = '/client.js.map'
const bundleSuffix = '/client.js'
const isSourceMap = pathname.startsWith(prefix) && pathname.endsWith(mapSuffix)
const suffix = isSourceMap ? mapSuffix : bundleSuffix
const clientPath = pathname.startsWith(prefix) && pathname.endsWith(suffix)
? this.clientPath(pathname.slice(prefix.length, -suffix.length))
: undefined
const path = clientPath === undefined ? undefined : `${clientPath}${isSourceMap ? '.map' : ''}`
if (path === undefined) {
res.writeHead(404)
res.end()
@@ -434,7 +440,10 @@ export class ClientModuleHostService extends Service {
}
try {
const body = await readFile(path)
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' })
res.writeHead(200, {
'content-type': isSourceMap ? 'application/json; charset=utf-8' : 'text/javascript; charset=utf-8',
'cache-control': 'no-cache',
})
res.end(body)
} catch {
// Registered but unreadable (bundle not built yet): loud 404 beats a silent SPA-fallback HTML page.

View File

@@ -4,7 +4,7 @@
* registers the factory), materialization on first import/require with
* memoization and recursive self-sequencing, the resolution branch order,
* shared in-flight arrival, invalidate-refetch (HMR), style claiming, the
* default transport seams, and the loud failure modes (duplicate
* default transport seam, and the loud failure modes (duplicate
* registration, cycles, table misses, double boot).
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -20,7 +20,6 @@ type Factory = ClientPluginHandoff['factory']
afterEach(() => {
vi.unstubAllGlobals()
delete win.__ModuleLoader__
delete (document as unknown as Record<string, unknown>).__realmBridge
for (const el of document.querySelectorAll('style, script')) el.remove()
})
@@ -33,9 +32,9 @@ interface Bench {
}
/**
* Loader over scripted bundles: fetch resolves to the row url (optionally
* gated on a release callback); execute registers the scripted factory
* through the window sink (`null` scripts a bundle that never calls load).
* Loader over scripted bundles: load records the row URL, optionally waits on
* a release callback, then registers the scripted factory through the window
* sink (`null` scripts a bundle that never calls load).
*/
function bench(
entries: BootModuleRow[],
@@ -47,15 +46,12 @@ function bench(
const loader = new ClientModuleSystem({
modules: entries,
staticModules: opts.seed ?? {},
fetchBundle: (url) => {
loadBundle: async (url) => {
fetched.push(url)
if (opts.gated?.includes(url) === true) {
return new Promise((resolve) => { gates.set(url, () => { resolve(url) }) })
await new Promise<void>((resolve) => { gates.set(url, resolve) })
}
return Promise.resolve(url)
},
executeBundle: (code) => {
const id = /\/plugins\/(.+)\/client\.js/.exec(code)?.[1]
const id = /\/plugins\/(.+)\/client\.js/.exec(url)?.[1]
const factory = id === undefined ? undefined : bundles[id]
if (factory == null || id === undefined) return
win.__ModuleLoader__?.load({ id, factory })
@@ -65,7 +61,7 @@ function bench(
}
describe('lazy CJS arrival', () => {
it('prefetch fetches and executes but does not run the factory', async () => {
it('prefetch loads and registers but does not run the factory', async () => {
const ran: string[] = []
const b = bench([row('a')], { a: () => { ran.push('a'); return {} } })
await b.loader.prefetch('a')
@@ -85,7 +81,7 @@ describe('lazy CJS arrival', () => {
expect(b.loader.loadCache.get('a')?.id).toBe('a')
})
it('import without prefetch fetches, executes, and materializes in one call', async () => {
it('import without prefetch loads, registers, and materializes in one call', async () => {
const b = bench([row('a')], { a: () => ({ marker: 'direct' }) })
const surface = await b.loader.import('a', '', {})
expect((surface as { marker: string }).marker).toBe('direct')
@@ -228,7 +224,7 @@ describe('failure modes', () => {
})
describe('HMR reset', () => {
it('invalidate drops the factory and record so the module refetches and re-registers', async () => {
it('invalidate drops the factory and record so the module reloads and re-registers', async () => {
let generation = 0
const b = bench([row('a')], { a: () => ({ generation: ++generation }) })
const first = await b.loader.import('a', '', {})
@@ -275,27 +271,35 @@ describe('style claiming', () => {
})
})
describe('default transport seams', () => {
it('fetches same-origin and executes through an inline script tag', async () => {
// In a browser the loader's globalThis IS the page window; vitest's jsdom
// evaluates <script> in a separate realm that shares only the document,
// so the fixture bundle restores the sink from a document bridge before
// using the normal calling convention.
const code = 'window.__ModuleLoader__ = document.__realmBridge;\n'
+ 'window.__ModuleLoader__.load({ id: "dee", factory: function () { return { marker: "via-script" } } })'
vi.stubGlobal('fetch', async () => ({ ok: true, text: async () => code }))
describe('default transport seam', () => {
it('loads through an external classic script and removes the settled node', async () => {
const append = vi.spyOn(document.head, 'append').mockImplementation((...nodes) => {
const script = nodes[0]
if (!(script instanceof HTMLScriptElement)) throw new Error('expected script node')
expect(script.async).toBe(true)
expect(script.getAttribute('src')).toBe('/plugins/dee/client.js?rev=0')
queueMicrotask(() => {
win.__ModuleLoader__?.load({ id: 'dee', factory: () => ({ marker: 'via-script' }) })
script.dispatchEvent(new Event('load'))
})
})
const loader: ClientModuleLoader = new ClientModuleSystem({ modules: [row('dee')], staticModules: {} })
;(document as unknown as Record<string, unknown>).__realmBridge = win.__ModuleLoader__
const surface = await loader.import('dee', '', {})
expect((surface as { marker: string }).marker).toBe('via-script')
// The script node is removed right after its synchronous execution —
// repeated HMR rebuilds must not accumulate dead script nodes.
expect(append).toHaveBeenCalledOnce()
expect([...document.querySelectorAll('script')]).toEqual([])
})
it('a non-ok bundle response is loud with the status', async () => {
vi.stubGlobal('fetch', async () => ({ ok: false, status: 404 }))
it('a script load failure is loud and removes the node', async () => {
vi.spyOn(document.head, 'append').mockImplementation((...nodes) => {
const script = nodes[0]
if (!(script instanceof HTMLScriptElement)) throw new Error('expected script node')
queueMicrotask(() => { script.dispatchEvent(new Event('error')) })
})
const loader = new ClientModuleSystem({ modules: [row('dee')], staticModules: {} })
await expect(loader.prefetch('dee')).rejects.toThrow('answered 404')
await expect(loader.prefetch('dee')).rejects.toThrow(
'bundle script /plugins/dee/client.js?rev=0 failed to load',
)
expect([...document.querySelectorAll('script')]).toEqual([])
})
})

View File

@@ -1,12 +1,13 @@
/** Node-half composition diagnostics for package metadata and built client bundles. */
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { dirname, join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { Context } from 'cordis'
import { afterEach, describe, expect, it } from 'vitest'
import type { HttpServerService } from '@deepseek-ai/dsh-host-webserver'
import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
import { ClientModuleHostService } from '../src/index.ts'
let root: string | undefined
@@ -33,8 +34,8 @@ function writePackage(packageName: string): string {
return clientPath
}
/** Construct the node-half service over the enabled fixture entries. */
function construct(packageNames: string[]): ClientModuleHostService {
/** Construct the node-half service and capture its plugin-bundle route. */
function constructWithRoute(packageNames: string[]): { service: ClientModuleHostService; route: WebRoute } {
const ctx = new Context()
ctx.baseUrl = pathToFileURL(root!).href + '/'
ctx.provide('loader', {
@@ -44,13 +45,24 @@ function construct(packageNames: string[]): ClientModuleHostService {
}
},
})
let route: WebRoute | undefined
const httpServer: Pick<HttpServerService, 'port' | 'register' | 'tapIndex'> = {
port: 0,
register: () => () => {},
register: (candidate) => {
if (candidate.path === '/plugins') route = candidate
return () => {}
},
tapIndex: () => () => {},
}
ctx.provide('httpServer', httpServer as HttpServerService)
return new ClientModuleHostService(ctx)
const service = new ClientModuleHostService(ctx)
if (route === undefined) throw new Error('client bundle route was not registered')
return { service, route }
}
/** Construct the node-half service over the enabled fixture entries. */
function construct(packageNames: string[]): ClientModuleHostService {
return constructWithRoute(packageNames).service
}
describe('client bundle activation', () => {
@@ -84,4 +96,40 @@ describe('client bundle activation', () => {
expect(String(thrown)).toContain('EISDIR')
expect(String(thrown)).not.toContain('pnpm run build')
})
it('serves the source map beside a registered client bundle', async () => {
const packageName = '@fixture/source-map'
const clientPath = writePackage(packageName)
mkdirSync(dirname(clientPath), { recursive: true })
writeFileSync(clientPath, 'module.exports = {}\n')
const map = '{"version":3,"sources":["src/client/index.tsx"]}\n'
writeFileSync(`${clientPath}.map`, map)
const { route } = constructWithRoute([packageName])
let status = 0
let headers: Record<string, string> | undefined
let body = ''
const response = {
writeHead(nextStatus: number, nextHeaders?: Record<string, string>) {
status = nextStatus
headers = nextHeaders
return response
},
end(chunk?: Uint8Array) {
body = chunk === undefined ? '' : Buffer.from(chunk).toString('utf8')
return response
},
} as unknown as ServerResponse
await route.handler({
method: 'GET',
url: `/plugins/${packageName}/client.js.map`,
} as IncomingMessage, response)
expect(status).toBe(200)
expect(headers).toEqual({
'content-type': 'application/json; charset=utf-8',
'cache-control': 'no-cache',
})
expect(body).toBe(map)
})
})