Files
deepseek-harness/packages/host/webserver/tests/webserver.spec.ts
T
imccyu 73931626be feat(webserver): add resource routes and in-process readResource (#6901)
* feat(webserver): add resource routes and in-process readResource

A webserver route or the fallback seat may now answer through a resource,
a function from a Fetch Request to a Response, besides the existing
node:http handler. register accepts either route form and registerFallback
accepts a handler or { resource }; existing handler callers are unchanged.
HTTP GET and HEAD requests reach a resource; webServer.readResource(request,
peer) follows the same match order and reaches only resources, so handler
routes stay unreachable to in-process readers.

client-modules registers /plugins as the fetchBundle resource, and
frontend-static claims the fallback seat with a resource that renders the
index for a supplied Peer, or runs Connection's existing authorizeIndex for
an HTTP request and renders for the browser Peer.

* fix(webserver): flush resource headers and keep one drain listener per response

Resource responses send their status and headers before the first body
chunk, register one drain/close listener pair per response so the gzip
middleware's zlib stream does not accumulate listeners across backpressure
waits, and readResource drops the body of a HEAD read as HTTP does. HTTP
requests and readResource share one responder lookup.

* chore(scripts): prune the retired client-modules unknown-cast baseline entry

* feat(frontend-static): let browsers revalidate dist files except the index

Every dist file other than the rendered index now carries
cache-control: no-cache and a weak ETag from a SHA-1 of its bytes; a
request whose If-None-Match equals that ETag receives an empty 304. The
index keeps sending no cache headers. In-process reads through
webServer.readResource revalidate the same way.

* fix(webserver): stop reading Host for resource URLs and copy no held body

A resource request's URL is the request target resolved against the same
placeholder origin the route match uses, so a missing or unparsable Host no
longer turns a dist or /plugins request into 400. The webserver ends a
resource response itself and treats any earlier close as an abort, which
also covers the Web Worker tunnel marking an aborted response ended first.
client-modules bundle bodies and frontend-static file bodies are the held
bytes themselves instead of copies, and frontend-static no longer exports
its internal serveStatic helper.

* docs(webserver): document resource route types and the handler-or-resource fallback

Paste WebResource and WebResourceRoute beside WebRoute on the web-server
subsystem page and link the catalog signatures to it, replace the remaining
'fallback handler' wording now that the seat takes a handler or a resource,
and note why the drain listener removal is a no-op under gzip.
2026-10-22 13:41:18 +08:00

1081 lines
48 KiB
TypeScript

/**
* REAL-composition coverage: a test-only cordis.yml booted through the
* vendored Loader mounts the webserver row, and every assertion observes the
* user-visible HTTP surface of the running server (routing precedence, index
* taps, fallback-seat semantics, per-request error containment, the TLS
* listener and its teardown). The bind-address schema and both address
* predicates are asserted directly, before any server binds; one wildcard load
* denial goes through the real Loader.
*/
import { generateKeyPairSync } from 'node:crypto'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { once } from 'node:events'
import { Agent, request as httpsRequest } from 'node:https'
import { connect, createServer as createTcpServer, type AddressInfo } from 'node:net'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import type { Duplex } from 'node:stream'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context, FiberState } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
import type { PeerScope } from '@deepseek-ai/dsh-typert-protocol'
import HttpServer, {
bindUrlHost, isLoopbackHost, normalizeBindAddress, renderIndexInjections, type TlsConfig, type WebResource, type WebRoute,
} from '../src/index.ts'
import { materializeTlsFixture, type TlsFixture } from './tls-fixture.ts'
/** One-shot barrier for disposal overlapping a pending PEM read. */
const tlsReadGate = vi.hoisted(() => {
let held: { entered: () => void; resume: Promise<void> } | undefined
return {
/** Arm the gate and return the barrier the reading side reaches. */
arm() {
const entered = Promise.withResolvers<undefined>()
const resumed = Promise.withResolvers<undefined>()
held = { entered: () => { entered.resolve(undefined) }, resume: resumed.promise }
return { entered: entered.promise, release: () => { resumed.resolve(undefined) } }
},
async hold(path: string): Promise<void> {
if (held === undefined || !path.endsWith('.pem')) return
const gate = held
held = undefined
gate.entered()
await gate.resume
},
}
})
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
return {
...actual,
async readFile(...args: Parameters<typeof actual.readFile>) {
if (typeof args[0] === 'string') await tlsReadGate.hold(args[0])
return actual.readFile(...args)
},
}
})
// Every composition a test boots owns a temp root and a fiber; both are released
// even when an assertion fails, so a test may boot more than one.
let compositions: { context: Context; root: string }[] = []
let tlsRoot: string | undefined
afterEach(async () => {
const loaded = compositions
compositions = []
await Promise.all(loaded.map(async ({ context, root }) => {
await context.fiber.dispose()
await rm(root, { recursive: true, force: true })
}))
if (tlsRoot !== undefined) await rm(tlsRoot, { recursive: true, force: true })
tlsRoot = undefined
})
/** Materialize the generated TLS fixture into a temp root this file cleans up. */
async function tlsFixture(): Promise<TlsFixture> {
tlsRoot ??= await mkdtemp(join(tmpdir(), 'dsh-webserver-tls-'))
return materializeTlsFixture(tlsRoot)
}
/**
* Write a cordis.yml with one webserver row, then boot it through the real Loader.
* @param tls - a TLS row (partial rows model a user's incomplete YAML), `null`
* for an explicit empty `tls:` (what a user's `tls:` with no value parses to),
* or undefined to omit the field entirely.
*/
async function loadComposition(
port = 0,
gzip = false,
host = '127.0.0.1',
tls?: Partial<TlsConfig> | null,
): Promise<Context> {
const root = await mkdtemp(join(tmpdir(), 'dsh-webserver-loader-'))
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
"- name: '@deepseek-ai/dsh-host-webserver'",
' config:',
` host: '${host}'`,
` port: ${String(port)}`,
...(tls === undefined
? []
: tls === null
? [' tls:']
: [
' tls:',
...(tls.certFile === undefined ? [] : [` certFile: '${tls.certFile}'`]),
...(tls.keyFile === undefined ? [] : [` keyFile: '${tls.keyFile}'`]),
]),
...(gzip
? [
' compression: gzip',
' compressionLevel: 1',
' compressionThresholdBytes: 16',
]
: []),
'',
].join('\n'))
const context = new Context()
compositions.push({ context, root })
context.baseUrl = pathToFileURL(root).href + '/'
await context.plugin(Loader)
context.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['@deepseek-ai/dsh-host-webserver', HttpServer],
])
context.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
return modules.get(specifier)
},
} as unknown as NonNullable<typeof context.loader.internal>
await context.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(configPath).href },
})
await context.loader.await()
return context
}
/** The webserver row's fiber in a Loaded composition. */
function webserverFiber(context: Context) {
return [...context.loader.entries()].find(entry => entry.options.name === '@deepseek-ai/dsh-host-webserver')?.fiber
}
/** The port a started listener bound; a test that reads it before `listen` fails here. */
function boundPort(port: number | undefined): number {
if (port === undefined) throw new Error('webserver test: the listener has not bound a port')
return port
}
/** Write one raw request (fetch always sends Host) and collect the response text until the server closes. */
async function rawExchange(listenerPort: number | undefined, text: string): Promise<string> {
const socket = connect(boundPort(listenerPort), '127.0.0.1')
await once(socket, 'connect')
const { promise, resolve, reject } = Promise.withResolvers<string>()
let received = ''
socket.setEncoding('utf8')
socket.on('data', (chunk: string) => { received += chunk })
socket.once('close', () => { resolve(received) })
socket.once('error', reject)
socket.write(text)
try {
return await promise
} finally {
socket.destroy()
}
}
/** GET (by default) one path against the running server; returns status plus a body prefix. */
async function request(
port: number | undefined,
path: string,
init?: RequestInit,
): Promise<{ status: number; body: string; headers: Headers }> {
const response = await fetch(`http://127.0.0.1:${String(boundPort(port))}${path}`, init)
return { status: response.status, body: (await response.text()).slice(0, 80), headers: response.headers }
}
/** Open one raw upgrade request and return after the handler writes its response. */
async function upgrade(listenerPort: number | undefined, path: string): Promise<ReturnType<typeof connect>> {
const port = boundPort(listenerPort)
const socket = connect(port, '127.0.0.1')
await once(socket, 'connect')
const response = once(socket, 'data')
socket.write([
`GET ${path} HTTP/1.1`,
`Host: 127.0.0.1:${String(port)}`,
'Connection: Upgrade',
'Upgrade: dsh-test',
'',
'',
].join('\r\n'))
const [data] = await response as [Buffer]
expect(String(data)).toContain('101 Switching Protocols')
return socket
}
/**
* GET one path over TLS. `ca` is the client's trust anchor, and a call without
* it trusts nothing, so it only succeeds if the listener really speaks TLS.
*/
function tlsRequest(
port: number | undefined,
path: string,
ca?: string,
agent?: Agent,
): Promise<{ status: number; body: string }> {
const { promise, resolve, reject } = Promise.withResolvers<{ status: number; body: string }>()
const request = httpsRequest({
host: '127.0.0.1',
port: boundPort(port),
path,
...(ca === undefined ? {} : { ca }),
...(agent === undefined ? {} : { agent }),
}, (response) => {
let body = ''
response.setEncoding('utf8')
response.on('data', (chunk: string) => { body += chunk })
response.on('end', () => { resolve({ status: response.statusCode ?? 0, body }) })
})
request.on('error', reject)
request.end()
return promise
}
/** Open one upgrade request over TLS and return after the handler answers 101. */
function tlsUpgrade(port: number | undefined, path: string, ca: string): Promise<{ status: number; socket: Duplex }> {
const { promise, resolve, reject } = Promise.withResolvers<{ status: number; socket: Duplex }>()
const request = httpsRequest({
host: '127.0.0.1',
port: boundPort(port),
path,
ca,
headers: { Connection: 'Upgrade', Upgrade: 'dsh-test' },
})
request.on('upgrade', (response, socket) => {
resolve({ status: response.statusCode ?? 0, socket })
})
request.on('error', reject)
request.end()
return promise
}
describe('real Loader composition', () => {
it('accepts one concrete IP literal, keeping an IPv6 zone for listen', () => {
for (const host of ['127.0.0.1', '10.1.2.3', '0.1.2.3', '::1', 'fd00::1', '::ffff:10.1.2.3', '::ffff:0.1.2.3', '0:0:0:0:0:0:0:1', 'fe80::1%lo', '::0.0.0.1', '::0.0.0.1%lo', '::127.0.0.1']) {
expect(HttpServer.Config({ host, port: 0 }).host).toBe(host)
}
})
it('rejects every wildcard spelling by address value, not by text', () => {
// Every spelling node:net accepts that parses to the unspecified address:
// IPv4 any, IPv6 any in long and short forms, IPv4-compatible, and the
// IPv4-mapped forms of IPv4 any, with or without a zone.
for (const host of [
'0.0.0.0', '::', '::0', '0000::', '0::', '0:0:0:0:0:0:0:0', '::0.0.0.0', '::0.0.0.0%lo', '::%lo',
'::ffff:0.0.0.0', '::ffff:0:0', '::ffff:0000:0000', '0:0:0:0:0:ffff:0:0',
'0:0:0:0:0:ffff:0.0.0.0', '::ffff:0.0.0.0%eth0',
]) {
expect(() => HttpServer.Config({ host, port: 0 })).toThrow(
/is an unspecified \(wildcard\) address, which is not supported/,
)
}
for (const host of ['localhost', 'example.com', '*', '[::]', '[fd00::1]', '10.1.2.3/8']) {
expect(() => HttpServer.Config({ host, port: 0 }))
.toThrow(/is not a concrete IPv4 or IPv6 address literal/)
}
})
it('denies the IPv4-mapped wildcard through the real plugin load, not only the predicate', async () => {
// The Loader is nontransactional: a rejected config leaves a FAILED fiber
// whose await carries the schema error rather than rejecting loader.await().
const denied = await loadComposition(0, false, '::ffff:0.0.0.0')
const fiber = webserverFiber(denied)
expect(fiber?.state).toBe(FiberState.FAILED)
await expect(fiber?.await()).rejects.toThrow(/is an unspecified \(wildcard\) address/)
})
it('classifies loopback bind addresses from the parsed value, including mapped and zone forms', () => {
for (const host of ['127.0.0.1', '127.8.9.10', '127.5.5.5', '::1', '0:0:0:0:0:0:0:1', '::ffff:127.0.0.1', '::ffff:7f00:1', '::1%lo', '::0.0.0.1', '::0.0.0.1%lo']) {
expect(isLoopbackHost(host)).toBe(true)
}
// A dotted-quad tail is the address's own low 32 bits, never an IPv4 tail:
// ::127.0.0.1 is the unrelated IPv6 address ::7f00:1, not IPv4 loopback.
for (const host of ['10.1.2.3', '::ffff:10.1.2.3', 'fd00::1', 'fe80::1%lo', '::2', 'localhost', '::127.0.0.1', '::127.0.0.1%lo', '::0.0.0.2']) {
expect(isLoopbackHost(host)).toBe(false)
}
})
it('reads a dotted-quad IPv6 tail as the address it names', () => {
const rows: [string, string][] = [
['::0.0.0.1', '::1'],
['::0.0.0.1%lo', '::1'],
['::127.0.0.1', '::7f00:1'],
['::1', '::1'],
['10.1.2.3', '10.1.2.3'],
// Genuinely IPv4-mapped literals keep their IPv4 form for a browser URL.
['::ffff:127.0.0.1', '127.0.0.1'],
['::ffff:7f00:1', '127.0.0.1'],
['::ffff:10.1.2.3', '10.1.2.3'],
]
for (const [host, text] of rows) expect(normalizeBindAddress(host)).toBe(text)
expect(() => normalizeBindAddress('localhost')).toThrow(/is not a concrete IPv4 or IPv6 address literal/)
})
it('writes bind addresses in their URL host form', () => {
const rows: [string, string | undefined][] = [
['127.0.0.1', '127.0.0.1'],
['::ffff:127.0.0.1%lo', '127.0.0.1'],
['::1', '[::1]'],
['::0.0.0.1%lo', '[::1]'],
['10.1.2.3', '10.1.2.3'],
['fd00::1', '[fd00::1]'],
// A zone selects an interface and no URL can carry it; only a loopback zone is redundant.
['fe80::1%eth0', undefined],
['::127.0.0.1%lo', undefined],
]
for (const [host, text] of rows) expect(bindUrlHost(host)).toBe(text)
})
it('reports no origin before binding or for a bind address that no URL can express', () => {
expect(() => new HttpServer(new Context(), HttpServer.Config({ host: '127.0.0.1', port: 0 })).getOrigin())
.toThrow('webserver: origin read before the listener bound a port')
expect(() => new HttpServer(new Context(), HttpServer.Config({ host: 'fe80::1%eth0', port: 0 })).getOrigin())
.toThrow('webserver: bind address "fe80::1%eth0" carries an interface zone id, which no URL can express')
})
it('applies gzip only to eligible socket-backed HTTP responses', { timeout: 60_000 }, async () => {
expect(HttpServer.Config({ host: '127.0.0.1', port: 0 })).toEqual({
host: '127.0.0.1',
port: 0,
compression: 'none',
compressionLevel: 1,
compressionThresholdBytes: 1024,
})
expect(() => HttpServer.Config({
host: '127.0.0.1', port: 0, compressionLevel: 10,
})).toThrow()
const loaded = await loadComposition(0, true)
const server = loaded.webServer
const body = 'compressible response '.repeat(8)
server.register({
kind: 'exact',
path: '/text',
handler: (_req, res) => {
res.writeHead(200, {
'content-type': 'text/plain; charset=utf-8',
'content-length': String(Buffer.byteLength(body)),
})
res.end(body)
},
})
server.register({
kind: 'exact',
path: '/stream',
handler: (_req, res) => {
res.writeHead(200, { 'content-type': 'application/json' })
res.write(body.slice(0, 40))
res.end(body.slice(40))
},
})
server.register({
kind: 'exact',
path: '/small',
handler: (_req, res) => {
res.writeHead(200, { 'content-type': 'text/plain', 'content-length': '5' })
res.end('small')
},
})
server.register({
kind: 'exact',
path: '/events',
handler: (_req, res) => {
res.writeHead(200, { 'content-type': 'text/event-stream' })
res.end(body)
},
})
server.register({
kind: 'exact',
path: '/archive',
handler: (_req, res) => {
res.writeHead(200, { 'content-type': 'application/gzip' })
res.end(body)
},
})
server.register({
kind: 'exact',
path: '/range',
handler: (_req, res) => {
res.writeHead(206, { 'content-type': 'text/plain', 'content-range': 'bytes 0-15/160' })
res.end(body.slice(0, 16))
},
})
const compressed = await request(server.port, '/text', { headers: { 'accept-encoding': 'br, gzip, deflate' } })
expect(compressed).toMatchObject({ status: 200, body: body.slice(0, 80) })
expect(compressed.headers.get('content-encoding')).toBe('gzip')
expect(compressed.headers.get('content-length')).toBeNull()
expect(compressed.headers.get('vary')).toBe('Accept-Encoding')
const streamed = await request(server.port, '/stream', { headers: { 'accept-encoding': 'gzip' } })
expect(streamed).toMatchObject({ body: body.slice(0, 80) })
expect(streamed.headers.get('content-encoding')).toBe('gzip')
expect((await request(server.port, '/small', { headers: { 'accept-encoding': 'gzip' } }))
.headers.get('content-encoding')).toBeNull()
const identity = await request(server.port, '/text', {
headers: { 'accept-encoding': 'gzip;q=0.5, identity;q=1' },
})
expect(identity.headers.get('content-encoding')).toBeNull()
expect(identity.headers.get('vary')).toBe('Accept-Encoding')
expect((await request(server.port, '/events', { headers: { 'accept-encoding': 'gzip' } }))
.headers.get('content-encoding')).toBeNull()
expect((await request(server.port, '/archive', { headers: { 'accept-encoding': 'gzip' } }))
.headers.get('content-encoding')).toBeNull()
expect((await request(server.port, '/range', { headers: { 'accept-encoding': 'gzip' } }))
.headers.get('content-encoding')).toBeNull()
// A resource response passes through the same gzip decision as a handler's.
server.register({
kind: 'exact',
path: '/resource-text',
resource: () => Promise.resolve(new Response(body, { headers: { 'content-type': 'text/plain; charset=utf-8' } })),
})
const resourceCompressed = await request(server.port, '/resource-text', { headers: { 'accept-encoding': 'gzip' } })
expect(resourceCompressed).toMatchObject({ status: 200, body: body.slice(0, 80) })
expect(resourceCompressed.headers.get('content-encoding')).toBe('gzip')
const resourceHead = await request(server.port, '/resource-text', { method: 'HEAD', headers: { 'accept-encoding': 'gzip' } })
expect(resourceHead).toMatchObject({ status: 200, body: '' })
expect(resourceHead.headers.get('content-type')).toBe('text/plain; charset=utf-8')
// Many backpressure waits on one gzip response leave no listeners behind:
// the middleware forwards drain listeners to its zlib stream.
const warnings: Error[] = []
const onWarning = (warning: Error): void => { warnings.push(warning) }
process.on('warning', onWarning)
try {
const chunk = new TextEncoder().encode('compressible chunk '.repeat(64 * 1024 / 16))
server.register({
kind: 'exact',
path: '/resource-large',
resource: () => Promise.resolve(new Response(new ReadableStream<Uint8Array>({
start(controller) {
for (let index = 0; index < 64; index += 1) controller.enqueue(chunk)
controller.close()
},
}), { headers: { 'content-type': 'text/plain; charset=utf-8' } })),
})
const large = await fetch(`http://127.0.0.1:${String(server.port)}/resource-large`, { headers: { 'accept-encoding': 'gzip' } })
expect(large.headers.get('content-encoding')).toBe('gzip')
expect((await large.arrayBuffer()).byteLength).toBe(64 * chunk.byteLength)
await new Promise(resolve => setImmediate(resolve))
expect(warnings.filter(warning => warning.name === 'MaxListenersExceededWarning')).toEqual([])
} finally {
process.off('warning', onWarning)
}
})
// Real-Loader composition resolves workspace packages through tsx at test
// time; first resolution after the host/client program split is slow enough
// to trip the default 5s budget on cold caches.
it('serves registered routes, index taps, and the fallback-seat semantics', { timeout: 60_000 }, async () => {
const loaded = await loadComposition()
const unloaded = [...loaded.loader.entries()]
.filter(entry => entry.fiber === undefined && !entry.disabled)
.map(entry => entry.options.name)
expect(unloaded).toEqual([])
const server = loaded.webServer
expect(server).toBeInstanceOf(HttpServer)
const port = server.port
expect(port).toBeGreaterThan(0)
// Routing precedence: exact beats prefix, longest prefix wins, a prefix
// route answers its own path, and routes own their method handling
// (POST reaches a registered prefix; 405 is fallback-only semantics).
server.register({ kind: 'exact', path: '/probe', handler: (_req, res) => { res.writeHead(200); res.end('EXACT') } })
server.register({ kind: 'prefix', path: '/api', handler: (_req, res) => { res.writeHead(200); res.end('API') } })
server.register({ kind: 'prefix', path: '/api/deep', handler: (_req, res) => { res.writeHead(200); res.end('DEEP') } })
expect(await request(port, '/probe')).toMatchObject({ status: 200, body: 'EXACT' })
expect(await request(port, '/api/anything')).toMatchObject({ status: 200, body: 'API' })
expect(await request(port, '/api/deep/leaf')).toMatchObject({ status: 200, body: 'DEEP' })
expect(await request(port, '/api')).toMatchObject({ status: 200, body: 'API' })
expect(await request(port, '/api/anything', { method: 'POST' })).toMatchObject({ status: 200, body: 'API' })
// Fallback seat: 404 while unclaimed; the owner answers everything no
// named route matches; index taps are the owner's to apply; the seat
// admits exactly one owner and the disposer releases it.
expect((await request(port, '/no/such/route')).status).toBe(404)
const untap = server.tapIndex(html => html.replace('<head>', '<head><script>window.__T__=1</script>'))
expect(server.applyIndexTaps('<head></head>')).toContain('__T__')
const releaseFallback = server.registerFallback((req, res) => {
// Decode like a real static server would — a malformed %-escape throws
// here, probing the webserver's per-request error containment.
decodeURIComponent(new URL(req.url ?? '/', 'http://x').pathname)
res.writeHead(200, { 'content-type': 'text/html' })
res.end(server.applyIndexTaps('<head></head><body>shell</body>'))
})
expect(() => server.registerFallback(() => {})).toThrow(/fallback already registered/)
expect((await request(port, '/no/such/route')).body).toContain('__T__')
untap()
expect((await request(port, '/no/such/route')).body).not.toContain('__T__')
expect((await request(port, '/no/such/route')).body).toContain('shell')
// Per-request error containment: a malformed %-escape answers 400 and the
// server keeps serving afterwards (no process-level failure path).
expect((await request(port, '/%zz')).status).toBe(400)
expect(await request(port, '/probe')).toMatchObject({ status: 200, body: 'EXACT' })
// Duplicate (kind, path) is a misconfiguration and throws; the disposer
// restores registrability (register/disposer symmetry).
expect(() => server.register({ kind: 'exact', path: '/probe', handler: () => {} }))
.toThrow(/duplicate exact route/)
const disposeOnce = server.register({ kind: 'exact', path: '/once', handler: (_req, res) => { res.writeHead(200); res.end('ONCE') } })
expect(await request(port, '/once')).toMatchObject({ status: 200, body: 'ONCE' })
disposeOnce()
expect((await request(port, '/once')).body).toContain('shell') // back to the fallback owner
expect(() => server.register({ kind: 'exact', path: '/once', handler: () => {} })).not.toThrow()
// Releasing the seat restores the unclaimed 404 and registrability.
releaseFallback()
expect((await request(port, '/no/such/route')).status).toBe(404)
expect(() => server.registerFallback(() => {})).not.toThrow()
// Upgrade routes match exact pathnames, reject duplicate ownership, and
// become registrable again after disposal. The accepted socket stays open
// so the teardown assertion also covers upgraded-connection ownership.
let upgradedServerClosed = false
const disposeUpgrade = server.registerUpgrade({
path: '/events',
handler: (_req, socket) => {
socket.once('close', () => { upgradedServerClosed = true })
socket.write('HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: dsh-test\r\n\r\n')
},
})
expect(() => server.registerUpgrade({ path: '/events', handler: () => {} }))
.toThrow(/duplicate upgrade route/)
const upgraded = await upgrade(port, '/events?stream=mux')
disposeUpgrade()
expect(() => server.registerUpgrade({ path: '/events', handler: () => {} })).not.toThrow()
// The webserver contains raw-socket errors even before an upgrade handler
// has installed its protocol implementation.
server.registerUpgrade({
path: '/upgrade-error',
handler: async (_req, socket) => {
await Promise.resolve()
socket.destroy(new Error('test upgrade transport failure'))
},
})
const failedUpgrade = connect(boundPort(port), '127.0.0.1')
failedUpgrade.on('error', () => { /* The server-side reset is the fixture outcome. */ })
await once(failedUpgrade, 'connect')
const failedUpgradeClosed = once(failedUpgrade, 'close')
failedUpgrade.write([
'GET /upgrade-error HTTP/1.1',
`Host: 127.0.0.1:${String(port)}`,
'Connection: Upgrade',
'Upgrade: dsh-test',
'',
'',
].join('\r\n'))
await failedUpgradeClosed
expect(await request(port, '/probe')).toMatchObject({ status: 200, body: 'EXACT' })
// Teardown closes both ordinary and upgraded sockets before it resolves.
await loaded.fiber.dispose()
expect(upgradedServerClosed).toBe(true)
upgraded.destroy()
await expect(request(port, '/probe')).rejects.toThrow()
})
it('answers resource routes over HTTP with the Fetch response, streamed and cancelled with the client', { timeout: 60_000 }, async () => {
const loaded = await loadComposition()
const server = loaded.webServer
const port = boundPort(server.port)
const encoder = new TextEncoder()
// The resource sees the request as a Fetch Request without a Peer; status,
// headers (each set-cookie line separately), and body return unchanged.
let seen: {
url: string
method: string
probe: string | null
host: string | null
cookie: string | null
peer: PeerScope | undefined
} | undefined
server.register({
kind: 'exact',
path: '/resource',
resource: (request, peer) => {
seen = {
url: request.url,
method: request.method,
probe: request.headers.get('x-probe'),
host: request.headers.get('host'),
cookie: request.headers.get('cookie'),
peer,
}
const headers = new Headers({ 'content-type': 'text/plain; charset=utf-8', 'x-answer': 'yes' })
headers.append('set-cookie', 'a=1; Path=/')
headers.append('set-cookie', 'b=2; Path=/')
return Promise.resolve(new Response('RESOURCE', { status: 201, headers }))
},
})
const created = await fetch(`http://127.0.0.1:${String(port)}/resource?q=1`, { headers: { 'x-probe': 'probe', cookie: 'a=b' } })
expect(created.status).toBe(201)
expect(created.headers.get('x-answer')).toBe('yes')
expect(created.headers.getSetCookie()).toEqual(['a=1; Path=/', 'b=2; Path=/'])
expect(await created.text()).toBe('RESOURCE')
// Host and Cookie reach the resource: browser authentication reads them.
expect(seen).toEqual({
url: 'http://x/resource?q=1',
method: 'GET',
probe: 'probe',
host: `127.0.0.1:${String(port)}`,
cookie: 'a=b',
peer: undefined,
})
// The status and headers arrive before the first body chunk exists.
const firstChunk = Promise.withResolvers<undefined>()
server.register({
kind: 'exact',
path: '/held',
resource: () => Promise.resolve(new Response(new ReadableStream<Uint8Array>({
async start(controller) {
await firstChunk.promise
controller.enqueue(encoder.encode('late'))
controller.close()
},
}), { status: 202 })),
})
const held = await fetch(`http://127.0.0.1:${String(port)}/held`)
expect(held.status).toBe(202)
firstChunk.resolve(undefined)
expect(await held.text()).toBe('late')
// A streamed body reaches the client chunk by chunk: the first chunk is
// readable while the second is still withheld.
const second = Promise.withResolvers<undefined>()
server.register({
kind: 'exact',
path: '/stream',
resource: () => Promise.resolve(new Response(new ReadableStream<Uint8Array>({
async start(controller) {
controller.enqueue(encoder.encode('first;'))
await second.promise
controller.enqueue(encoder.encode('second'))
controller.close()
},
}))),
})
const streamed = await fetch(`http://127.0.0.1:${String(port)}/stream`)
const reader = streamed.body!.getReader()
expect(new TextDecoder().decode((await reader.read()).value)).toBe('first;')
second.resolve(undefined)
let rest = ''
for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) rest += new TextDecoder().decode(chunk.value)
expect(rest).toBe('second')
// HEAD writes the status and headers, sends no body, and cancels the body.
const headCancelled = Promise.withResolvers<undefined>()
server.register({
kind: 'exact',
path: '/head',
resource: () => Promise.resolve(new Response(new ReadableStream<Uint8Array>({
cancel() { headCancelled.resolve(undefined) },
}), { headers: { 'content-type': 'text/plain' } })),
})
expect(await request(port, '/head', { method: 'HEAD' })).toMatchObject({ status: 200, body: '' })
await headCancelled.promise
// A client leaving mid-body aborts the request signal and cancels the body.
const cancelled = Promise.withResolvers<undefined>()
let endlessSignal: AbortSignal | undefined
server.register({
kind: 'exact',
path: '/endless',
resource: (request) => {
endlessSignal = request.signal
return Promise.resolve(new Response(new ReadableStream<Uint8Array>({
pull(controller) { controller.enqueue(new Uint8Array(64 * 1024)) },
cancel() { cancelled.resolve(undefined) },
})))
},
})
const leaving = new AbortController()
const endless = await fetch(`http://127.0.0.1:${String(port)}/endless`, { signal: leaving.signal })
await endless.body!.getReader().read()
leaving.abort()
await cancelled.promise
expect(endlessSignal?.aborted).toBe(true)
// Only GET and HEAD reach a resource; another method is 405 and its body is never read.
let posted = false
server.register({ kind: 'exact', path: '/posted', resource: () => { posted = true; return Promise.resolve(new Response('POSTED')) } })
const refused = await request(port, '/posted', { method: 'POST', body: 'payload' })
expect(refused).toMatchObject({ status: 405, body: '' })
expect(refused.headers.get('allow')).toBe('GET, HEAD')
expect(posted).toBe(false)
// The webserver never parses Host, so a missing or unparsable Host still
// reaches the resource, which sees the raw header; a resource that throws
// is answered 400 like a throwing handler.
expect(await rawExchange(port, 'GET /resource?q=2 HTTP/1.0\r\n\r\n')).toMatch(/^HTTP\/1\.1 201/)
expect(seen?.url).toBe('http://x/resource?q=2')
expect(await rawExchange(port, 'GET /resource?q=3 HTTP/1.1\r\nHost: bad host\r\nConnection: close\r\n\r\n')).toMatch(/^HTTP\/1\.1 201/)
expect([seen?.url, seen?.host]).toEqual(['http://x/resource?q=3', 'bad host'])
server.register({ kind: 'exact', path: '/throws', resource: () => Promise.reject(new Error('resource failure')) })
expect((await request(port, '/throws')).status).toBe(400)
expect((await request(port, '/resource')).status).toBe(201)
})
it('cancels a resource body when a socketless carrier aborts mid-response', { timeout: 60_000 }, async () => {
// The Web Worker tunnel feeds synthesized exchanges to the request listener.
// Its abort marks the response ended before emitting close, and a write after
// abort returns false without a later drain.
const loaded = await loadComposition()
const server = loaded.webServer
const cancelled = Promise.withResolvers<undefined>()
const firstChunk = Promise.withResolvers<undefined>()
server.register({
kind: 'exact',
path: '/tunnel',
resource: () => Promise.resolve(new Response(new ReadableStream<Uint8Array>({
async pull(controller) {
await firstChunk.promise
controller.enqueue(new TextEncoder().encode('chunk'))
},
cancel() { cancelled.resolve(undefined) },
}))),
})
const listeners = new Map<string, Set<() => void>>()
let finished = false
const res = {
get writableEnded() { return finished },
get headersSent() { return false },
writeHead: () => res,
write: () => !finished,
end: () => { finished = true },
destroy: () => { finished = true },
on: (event: string, listener: () => void) => { listeners.set(event, (listeners.get(event) ?? new Set()).add(listener)); return res },
once: (event: string, listener: () => void) => res.on(event, listener),
off: (event: string, listener: () => void) => { listeners.get(event)?.delete(listener); return res },
}
const httpServer = Reflect.get(server, 'server') as { emit(event: 'request', req: object, res: object): boolean }
httpServer.emit('request', { url: '/tunnel', method: 'GET', headers: { host: 'tunnel.invalid' } }, res)
await vi.waitFor(() => { expect(listeners.get('close')?.size ?? 0).toBeGreaterThan(0) })
finished = true
for (const listener of [...(listeners.get('close') ?? [])]) listener()
firstChunk.resolve(undefined)
await cancelled.promise
})
it('reads resources in process by the HTTP match order, for the given Peer', { timeout: 60_000 }, async () => {
const loaded = await loadComposition()
const server = loaded.webServer
const port = boundPort(server.port)
const peer = { principal: { kind: 'owner' } } as PeerScope
const read = (path: string, init?: RequestInit) => server.readResource(new Request(`http://reader.invalid${path}`, init), peer)
const readersOf: (PeerScope | undefined)[] = []
const resource = (label: string): WebResource => (request, reader) => {
readersOf.push(reader)
return Promise.resolve(new Response(`${label}:${new URL(request.url).pathname}`))
}
const handler = (label: string): WebRoute['handler'] => (_req, res) => {
res.writeHead(200)
res.end(label)
}
server.register({ kind: 'prefix', path: '/assets', resource: resource('assets') })
server.register({ kind: 'prefix', path: '/assets/deep', resource: resource('deep') })
server.register({ kind: 'exact', path: '/assets/special', handler: handler('SPECIAL') })
server.register({ kind: 'prefix', path: '/api', handler: handler('API') })
// Exact, then longest prefix: the same responder HTTP picks answers the
// read, and the read passes its Peer to the resource.
for (const path of ['/assets', '/assets/x', '/assets/deep/leaf']) {
const response = await read(path)
expect(response.status).toBe(200)
expect(await response.text()).toBe((await request(port, path)).body)
}
expect(readersOf).toEqual([peer, undefined, peer, undefined, peer, undefined])
// A HEAD read keeps the status and headers and drops the body, as HTTP does.
const headRead = await read('/assets/deep/leaf', { method: 'HEAD' })
expect(headRead.status).toBe(200)
expect(headRead.headers.get('content-type')).toBe('text/plain;charset=UTF-8')
expect(await headRead.text()).toBe('')
// A path a handler owns is 404 to a read and never falls through to the
// fallback or a shorter prefix; HTTP still reaches the handler.
expect((await read('/assets/special')).status).toBe(404)
expect((await read('/api/session')).status).toBe(404)
expect((await request(port, '/assets/special')).body).toBe('SPECIAL')
// The fallback seat answers a read only while it holds a resource.
expect((await read('/elsewhere')).status).toBe(404)
const releaseHandler = server.registerFallback(handler('FALLBACK'))
expect((await read('/elsewhere')).status).toBe(404)
expect((await request(port, '/elsewhere')).body).toBe('FALLBACK')
releaseHandler()
server.registerFallback({ resource: resource('fallback') })
const fallback = await read('/elsewhere')
expect(await fallback.text()).toBe('fallback:/elsewhere')
expect(readersOf.at(-1)).toBe(peer)
// Reads are GET or HEAD: another method is 405 before any resource runs.
const calls = readersOf.length
const posted = await read('/assets/x', { method: 'POST', body: 'x' })
expect(posted.status).toBe(405)
expect(posted.headers.get('allow')).toBe('GET, HEAD')
expect(readersOf).toHaveLength(calls)
// A throwing resource rejects the read; over HTTP the same failure is 400.
server.register({ kind: 'exact', path: '/throws', resource: () => Promise.reject(new Error('resource failure')) })
await expect(read('/throws')).rejects.toThrow('resource failure')
expect((await request(port, '/throws')).status).toBe(400)
})
it('collects injection rows fresh per render for the rendered Peer and layers taps over the rendered rows', { timeout: 60_000 }, async () => {
const loaded = await loadComposition()
const server = loaded.webServer
let flag = 'dark'
const peer = { principal: { kind: 'owner' } } as PeerScope
const renderedFor: PeerScope[] = []
loaded.on('webserver/index-inject', (table, renderingFor) => {
renderedFor.push(renderingFor)
table.push(
{ kind: 'script', placement: 'head', text: 'window.__Q__=1' },
{ kind: 'script-src', placement: 'head', src: '/plugins/a.js?rev="1"&x=<y>' },
{ kind: 'script-preload', src: '/plugins/b.js?rev="2"&x=<z>' },
{ kind: 'global', name: '__DSH_BOOT__', value: { rev: '</script><b>' } },
{ kind: 'style', text: 'body{margin:0}' },
{ kind: 'html', placement: 'head', html: '<meta name="probe">' },
{ kind: 'script', placement: 'body', text: `window.__P__=${JSON.stringify(flag)}` },
)
})
const html = server.renderIndex('<html><head></head><body>shell</body></html>', peer)
// Head rows land right after the opening head tag in table order; the body
// row lands right after the opening body tag.
const order = [
'<head>',
'<script>window.__Q__=1</script>',
'<script src="/plugins/a.js?rev=&quot;1&quot;&amp;x=&lt;y&gt;"></script>',
'<link rel="preload" as="script" href="/plugins/b.js?rev=&quot;2&quot;&amp;x=&lt;z&gt;">',
'globalThis["__DSH_BOOT__"] = {"rev":"\\u003c/script>\\u003cb>"}',
'<style>body{margin:0}</style>',
'<meta name="probe">',
'<body>',
'<script>window.__P__="dark"</script>',
'shell',
].map(part => html.indexOf(part))
expect(order).toEqual([...order].sort((a, b) => a - b))
expect(order.every(at => at !== -1)).toBe(true)
// Fresh collection per render: the listener reads live state at emit time.
flag = 'light'
expect(server.renderIndex('<head></head><body></body>', peer)).toContain('window.__P__="light"')
// Raw taps still run, over the already-rendered rows.
const untap = server.tapIndex(h => h.replace('window.__Q__=1', 'window.__Q__=2'))
expect(server.renderIndex('<head></head><body></body>', peer)).toContain('window.__Q__=2')
untap()
// Every collection is dispatched with the Peer the render speaks for.
expect(renderedFor).toEqual([peer, peer, peer])
// Tag-less fragments: head rows prepend, body rows append, and the
// boot-readiness tail lands after the last body row.
expect(renderIndexInjections('<main>x</main>', [
{ kind: 'script', placement: 'head', text: 'H' },
{ kind: 'script', placement: 'body', text: 'B' },
])).toBe('<script>H</script><main>x</main><script>B</script>'
+ '<script>(globalThis.__DSH_BOOT_READY__ ??= Promise.withResolvers()).resolve()</script>')
})
it('serves HTTPS to a verifying client and reports the https protocol', { timeout: 60_000 }, async () => {
const fixture = await tlsFixture()
const tls = { certFile: fixture.certFile, keyFile: fixture.keyFile }
expect(HttpServer.Config({ host: '127.0.0.1', port: 0, tls })).toEqual({
host: '127.0.0.1',
port: 0,
tls,
compression: 'none',
compressionLevel: 1,
compressionThresholdBytes: 1024,
})
const loaded = await loadComposition(0, false, '127.0.0.1', tls)
const server = loaded.webServer
expect(server.protocol).toBe('https:')
expect(server.getOrigin()).toBe(`https://127.0.0.1:${String(server.port)}`)
server.register({
kind: 'exact',
path: '/tls',
handler: (_req, res) => {
res.writeHead(200, { 'content-type': 'text/plain' })
res.end('SECURE')
},
})
// The fixture CA is the only reason the handshake succeeds, so this is a
// verifying client rather than one that disabled its checks.
expect(await tlsRequest(server.port, '/tls', fixture.ca)).toEqual({ status: 200, body: 'SECURE' })
await expect(tlsRequest(server.port, '/tls')).rejects.toMatchObject({ code: 'DEPTH_ZERO_SELF_SIGNED_CERT' })
// The listener speaks TLS only: a plain-HTTP client cannot reach the route.
await expect(request(server.port, '/tls')).rejects.toThrow()
await loaded.fiber.dispose()
await expect(tlsRequest(server.port, '/tls', fixture.ca)).rejects.toThrow()
})
it('treats an explicit null tls as no TLS and keeps serving plain HTTP', { timeout: 60_000 }, async () => {
// A user's `tls:` with no value reaches the service as null through the schema.
const loaded = await loadComposition(0, false, '127.0.0.1', null)
const server = loaded.webServer
expect(server.protocol).toBe('http:')
expect(server.getOrigin()).toBe(`http://127.0.0.1:${String(server.port)}`)
server.register({ kind: 'exact', path: '/plain', handler: (_req, res) => { res.writeHead(200); res.end('PLAIN') } })
expect(await request(server.port, '/plain')).toMatchObject({ status: 200, body: 'PLAIN' })
await expect(tlsRequest(server.port, '/plain')).rejects.toThrow()
})
it('rejects unusable TLS material at activation, before the listener binds', { timeout: 60_000 }, async () => {
const fixture = await tlsFixture()
const directory = dirname(fixture.certFile)
// A valid key that pairs with nothing, plus an empty and an absent file.
const otherKey = generateKeyPairSync('rsa', { modulusLength: 2048 })
.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
const otherKeyFile = join(directory, 'tls-other-key.pem')
const emptyFile = join(directory, 'tls-empty.pem')
await writeFile(otherKeyFile, otherKey)
await writeFile(emptyFile, '')
// Holding the port makes the ordering observable: material validated only
// after a successful bind would report EADDRINUSE here instead.
const holder = createTcpServer()
const listening = once(holder, 'listening')
holder.listen(0, '127.0.0.1')
await listening
const port = (holder.address() as AddressInfo).port
const cases: { label: string; config: Partial<TlsConfig>; expected: RegExp }[] = [
{
label: 'mismatched key',
config: { certFile: fixture.certFile, keyFile: otherKeyFile },
expected: /are not a usable certificate and key pair/,
},
{
label: 'absent file',
config: { certFile: join(directory, 'tls-absent.pem'), keyFile: fixture.keyFile },
expected: /cannot read tls\.certFile/,
},
{
label: 'empty file',
config: { certFile: emptyFile, keyFile: fixture.keyFile },
expected: /tls\.certFile .* is empty/,
},
{
// Both files are one setting: a row naming only the certificate never resolves.
label: 'incomplete pair',
config: { certFile: fixture.certFile },
expected: /tls\.keyFile/,
},
]
try {
for (const { label, config, expected } of cases) {
const denied = await loadComposition(port, false, '127.0.0.1', config)
const fiber = webserverFiber(denied)
expect(fiber?.state, label).toBe(FiberState.FAILED)
if (fiber === undefined) throw new Error(`${label}: webserver row is missing from the composition`)
const failure = await fiber.await().then(() => '', (error: unknown) => error instanceof Error ? error.message : String(error))
expect(failure, label).toMatch(expected)
expect(failure, label).not.toMatch(/EADDRINUSE/)
// Diagnostics carry the paths that failed and never the material itself.
expect(failure, label).not.toMatch(/PRIVATE KEY/)
expect(failure, label).not.toContain(otherKey.split('\n')[1])
}
} finally {
const closed = once(holder, 'close')
holder.close()
await closed
}
})
it('reaches teardown quiescence for upgraded, idle, and handshaking TLS sockets', { timeout: 60_000 }, async () => {
const fixture = await tlsFixture()
const loaded = await loadComposition(0, false, '127.0.0.1', { certFile: fixture.certFile, keyFile: fixture.keyFile })
const server = loaded.webServer
let upgradeClosed = false
server.registerUpgrade({
path: '/events',
handler: (_req, socket) => {
socket.once('close', () => { upgradeClosed = true })
socket.write('HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: dsh-test\r\n\r\n')
},
})
server.register({ kind: 'exact', path: '/probe', handler: (_req, res) => { res.writeHead(200); res.end('EXACT') } })
const clients = new Map<Duplex, Promise<void>>()
const clientErrors: Error[] = []
const observeClient = (socket: Duplex): void => {
const onError = (error: Error): void => { clientErrors.push(error) }
socket.on('error', onError)
// Destruction may emit ECONNRESET before close; only close confirms release.
clients.set(socket, new Promise<void>((resolve) => {
socket.once('close', () => {
socket.off('error', onError)
resolve()
})
}))
}
const agent = new Agent({ keepAlive: true, ca: fixture.ca, maxSockets: 1 })
const idleReady = new Promise<void>((resolve) => {
agent.once('free', (socket: Duplex) => {
observeClient(socket)
resolve()
})
})
try {
const upgraded = await tlsUpgrade(server.port, '/events', fixture.ca)
observeClient(upgraded.socket)
expect(upgraded.status).toBe(101)
expect(await tlsRequest(server.port, '/probe', fixture.ca, agent)).toEqual({ status: 200, body: 'EXACT' })
await idleReady
// A TCP connection without a ClientHello is absent from closeAllConnections().
const handshaking = connect(boundPort(server.port), '127.0.0.1')
observeClient(handshaking)
await once(handshaking, 'connect')
expect(clientErrors).toEqual([])
for (const socket of clients.keys()) expect(socket.destroyed).toBe(false)
await loaded.fiber.dispose()
await Promise.all(clients.values())
for (const error of clientErrors) expect(error).toMatchObject({ code: 'ECONNRESET' })
expect(upgradeClosed).toBe(true)
await expect(tlsRequest(server.port, '/probe', fixture.ca)).rejects.toThrow()
} finally {
agent.destroy()
for (const socket of clients.keys()) socket.destroy()
await Promise.all(clients.values())
}
})
it('closes the listener when disposal lands while the TLS material is still being read', { timeout: 60_000 }, async () => {
const fixture = await tlsFixture()
const gate = tlsReadGate.arm()
const loading = loadComposition(0, false, '127.0.0.1', { certFile: fixture.certFile, keyFile: fixture.keyFile })
await gate.entered
// loadComposition registers the composition before its Loader starts, so the
// booting one — and the service its constructor already provided — is
// observable while initialization is parked inside the TLS read.
const composed = compositions.at(-1)
if (composed === undefined) throw new Error('the booting composition is not registered')
const server = composed.context.webServer
// Disposal is issued first and settles only after the read, the listen, and
// the teardown it owns: the listener bound in between must be closed.
const disposal = composed.context.fiber.dispose()
gate.release()
await disposal
await loading
expect(server.port).toBeGreaterThan(0)
await expect(tlsRequest(server.port, '/', fixture.ca)).rejects.toThrow()
})
it('fails the fiber when the port is already taken (fail-loud at activation)', { timeout: 60_000 }, async () => {
const first = await loadComposition()
const second = await loadComposition(first.webServer.port)
const fiber = webserverFiber(second)
expect(fiber?.state).toBe(FiberState.FAILED)
await expect(fiber?.await()).rejects.toThrow('EADDRINUSE')
})
})