1
/**2
* @deepseek-ai/dsh-host-webserver — node:http route registration with optional3
* gzip, index injection, and one fallback seat. It knows no harness concepts4
* and serves no files; the composing application owns dist serving. Electron5
* uses file:// plus IPC instead, and this package never prints the URL.6
* Route handlers retain direct response ownership.7
*/9
import { createServer } from 'node:http'10
import type { IncomingMessage, ServerResponse, Server } from 'node:http'11
import type { AddressInfo } from 'node:net'12
import type { Duplex } from 'node:stream'13
import { Context, Service } from '@deepseek-ai/cordis'14
import z from '@deepseek-ai/schemastery'15
import compressionMiddleware from 'compression'16
import Negotiator from 'negotiator'17
import { renderIndexInjections, type IndexInjection } from './injections.ts'19
export { renderIndexInjections } from './injections.ts'20
export type { IndexInjection, IndexInjectionPlacement } from './injections.ts'22
declare module '@deepseek-ai/cordis' {23
interface Context {24
webServer: WebServer25
}26
interface Events {27
/**28
* Collect the structured index injection table. Emitted on every index29
* render and every worker boot-payload request; listeners push their30
* current rows, so a row's data is read fresh at emit time.31
* @param table - Mutable row table; listeners append in activation order.32
* @mode emit33
*/34
'webserver/index-inject'(table: IndexInjection[]): void35
}36
}38
/** Route match kind: 'exact' matches the pathname verbatim; 'prefix' p matches p and p/<anything>. */39
export type WebRouteKind = 'exact' | 'prefix'41
/** One named route registration. */42
export interface WebRoute {43
kind: WebRouteKind44
/** Absolute pathname, no trailing slash. */45
path: string46
/** Owns the full response lifecycle (may hold the response open, e.g. SSE). */47
handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>48
}50
/** One exact-path HTTP upgrade registration. */51
export interface WebUpgradeRoute {52
/** Absolute pathname, no trailing slash. */53
path: string54
/** Owns protocol negotiation and the upgraded socket after dispatch. */55
handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise<void>56
}58
/** Web server listen and response-compression config. */59
export interface Config {60
/** Listen host; the two supported values are loopback and all-interfaces. */61
host: '127.0.0.1' | '0.0.0.0'62
/** Listen port; zero requests an OS-assigned port. */63
port: number64
/** Response compression for socket-backed HTTP requests. @default 'none' */65
compression?: 'none' | 'gzip'66
/** Gzip DEFLATE level from 0 through 9. @default 1 */67
compressionLevel?: number68
/** Minimum known response length eligible for gzip; unknown-length streams are eligible. @default 1024 */69
compressionThresholdBytes?: number70
}72
const DEFAULT_COMPRESSION = 'none' as const73
const DEFAULT_COMPRESSION_LEVEL = 174
const DEFAULT_COMPRESSION_THRESHOLD_BYTES = 102476
interface ResolvedConfig extends Config {77
compression: 'none' | 'gzip'78
compressionLevel: number79
compressionThresholdBytes: number80
}82
type NodeMiddleware = (83
req: IncomingMessage,84
res: ServerResponse,85
next: () => void,86
) => void88
function createGzipMiddleware(config: ResolvedConfig): NodeMiddleware {89
// `compression` is typed for Express, but its runtime uses only the90
// node:http request and response members supplied here.91
const middleware = compressionMiddleware({92
level: config.compressionLevel,93
threshold: config.compressionThresholdBytes,94
filter(request, response) {95
if (response.getHeader('content-range') !== undefined) return false96
const contentType = response.getHeader('content-type')97
if (typeof contentType === 'string' && contentType.toLowerCase().startsWith('text/event-stream')) return false98
if (typeof contentType === 'string' && /^multipart\/form-data(?:;|$)/i.test(contentType)) return true99
return compressionMiddleware.filter(request, response)100
},101
}) as NodeMiddleware103
return (req, res, next) => {104
// The Web Worker tunnel has no socket and transfers identity bytes.105
if ((res as { socket?: unknown }).socket === undefined) {106
next()107
return108
}109
const encoding = new Negotiator(req).encoding(['gzip', 'identity'])110
const gzipRequest = Object.create(req) as IncomingMessage111
Object.defineProperty(gzipRequest, 'headers', {112
value: { ...req.headers, 'accept-encoding': encoding === 'gzip' ? 'gzip' : 'identity' },113
})114
middleware(gzipRequest, res, next)115
}116
}118
/**119
* The browser HTTP carrier service. Activation listens immediately. Route120
* registration order does not affect requests because configured named routes121
* must be distinct, and the fallback handler answers anything not yet claimed122
* during startup with 404 until its owner registers. A listen failure rejects123
* initialization, and the boot process reports the failed fiber.124
*/125
export class WebServer extends Service {126
static Config: z<Config> = z.object({127
host: z.union([z.const('127.0.0.1'), z.const('0.0.0.0')]).required(),128
port: z.natural().max(65535).required(),129
compression: z.union([z.const('none'), z.const('gzip')]).default(DEFAULT_COMPRESSION),130
compressionLevel: z.number().step(1).min(0).max(9).default(DEFAULT_COMPRESSION_LEVEL),131
compressionThresholdBytes: z.natural().default(DEFAULT_COMPRESSION_THRESHOLD_BYTES),132
})134
private readonly exact = new Map<string, WebRoute>()135
private readonly prefixes = new Map<string, WebRoute>()136
private readonly upgrades = new Map<string, WebUpgradeRoute>()137
private readonly upgradedSockets = new Set<Duplex>()138
private readonly indexTaps: ((html: string) => string)[] = []139
private fallback: WebRoute['handler'] | undefined140
private server!: Server141
private listenedPort!: number142
private readonly gzip: NodeMiddleware | undefined144
constructor(ctx: Context, private config: Config) {145
super(ctx, 'webServer')146
const resolved = config as ResolvedConfig147
this.gzip = resolved.compression === 'gzip' ? createGzipMiddleware(resolved) : undefined148
}150
/** The listening port (the OS-assigned value when config.port is 0). */151
get port(): number {152
return this.listenedPort153
}155
/** The configured bind host (the loopback or all-interfaces literal). */156
get host(): Config['host'] {157
return this.config.host158
}160
/**161
* Register a named route. Duplicate (kind, path) throws — route patterns are162
* a composition-level contract, so a collision is a misconfiguration.163
* @param route - kind, path, and the owning handler.164
* @returns the disposer removing the route.165
*/166
register(route: WebRoute): () => void {167
const table = route.kind === 'exact' ? this.exact : this.prefixes168
if (table.has(route.path)) {169
throw new Error(`webserver: duplicate ${route.kind} route "${route.path}"`)170
}171
table.set(route.path, route)172
return () => { table.delete(route.path) }173
}175
/**176
* Register an exact-path HTTP upgrade route. Duplicate paths throw because177
* one socket can have only one protocol owner.178
* @param route - pathname and handler owning negotiation plus socket use.179
* @returns the disposer removing the route.180
*/181
registerUpgrade(route: WebUpgradeRoute): () => void {182
if (this.upgrades.has(route.path)) {183
throw new Error(`webserver: duplicate upgrade route "${route.path}"`)184
}185
this.upgrades.set(route.path, route)186
return () => { this.upgrades.delete(route.path) }187
}189
/**190
* Claim the fallback seat: the handler answering every request no named191
* route matches (the SPA dist server in the shipped Web composition). One192
* owner only — a second registration throws, because two fallbacks cannot193
* compose.194
* @param handler - owns the full response lifecycle of unmatched requests.195
* @returns the disposer releasing the seat.196
*/197
registerFallback(handler: WebRoute['handler']): () => void {198
if (this.fallback !== undefined) {199
throw new Error('webserver: fallback already registered')200
}201
this.fallback = handler202
return () => { this.fallback = undefined }203
}205
/**206
* Register a raw-HTML index transform, the escape hatch for markup no207
* {@link IndexInjection} row expresses: {@link renderIndex} applies taps in208
* registration order after rendering the structured rows.209
* @param transform - pure html-to-html function.210
* @returns the disposer removing the transform.211
*/212
tapIndex(transform: (html: string) => string): () => void {213
this.indexTaps.push(transform)214
return () => {215
const at = this.indexTaps.indexOf(transform)216
if (at !== -1) this.indexTaps.splice(at, 1)217
}218
}220
/** Listen; resolves once the socket is bound (rejection = FAILED fiber). */221
async [Service.init](): Promise<void> {222
const handle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {223
/* v8 ignore next -- `?? '/'` arm: node:http always sets url on server224
requests; the field is only optional on the client-side IncomingMessage type */225
const rawPath = new URL(req.url ?? '/', 'http://x').pathname226
const route = this.match(rawPath)227
if (route !== undefined) {228
await route.handler(req, res)229
return230
}231
const fallback = this.fallback232
if (fallback === undefined) {233
res.writeHead(404)234
res.end()235
return236
}237
await fallback(req, res)238
}239
// Last-resort guard: handle() rejecting would otherwise be an unhandled240
// rejection killing the process on one malformed request (bad %-escape,241
// client dropping mid-body). Per-request failures log and answer 400 —242
// never a process exit.243
this.server = createServer((req, res) => {244
const next = (): void => {245
void handle(req, res).catch((err: unknown) => {246
this.ctx.logger.warn(err instanceof Error ? err : new Error(String(err)))247
if (res.headersSent) {248
res.destroy()249
return250
}251
res.writeHead(400)252
res.end()253
})254
}255
if (this.gzip === undefined) next()256
else this.gzip(req, res, next)257
})258
this.server.on('upgrade', (req, socket, head) => {259
const onError = (error: Error): void => {260
this.ctx.logger.warn(error)261
socket.destroy()262
}263
socket.on('error', onError)264
socket.once('close', () => {265
socket.off('error', onError)266
this.upgradedSockets.delete(socket)267
})268
let route: WebUpgradeRoute | undefined269
try {270
/* v8 ignore next -- node:http always sets url on server requests. */271
route = this.upgrades.get(new URL(req.url ?? '/', 'http://x').pathname)272
} catch (error) {273
this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error)))274
socket.destroy()275
return276
}277
if (route === undefined) {278
socket.destroy()279
return280
}281
this.upgradedSockets.add(socket)282
try {283
Promise.resolve(route.handler(req, socket, head)).catch((error: unknown) => {284
this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error)))285
socket.destroy()286
})287
} catch (error) {288
this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error)))289
socket.destroy()290
}291
})293
await new Promise<void>((resolve, reject) => {294
this.server.once('error', reject)295
this.server.listen(this.config.port, this.config.host, () => {296
this.server.off('error', reject)297
this.server.on('error', (err) => { this.ctx.logger.error(err) })298
this.listenedPort = (this.server.address() as AddressInfo).port299
resolve()300
})301
})303
// Node does not include upgraded sockets in closeAllConnections(). The service304
// owns them with the other connections, so it tracks and destroys them explicitly.305
this.ctx.effect(() => async () => {306
const serverClosed = new Promise<void>((resolve) => {307
this.server.close(() => { resolve() })308
})309
this.server.closeAllConnections()310
const upgradedClosed = [...this.upgradedSockets].map(socket => new Promise<void>((resolve) => {311
socket.once('close', () => { resolve() })312
socket.destroy()313
}))314
await Promise.all([serverClosed, ...upgradedClosed])315
}, 'webServer.listen')316
}318
/** Longest-prefix-wins over the prefix table after an exact-table miss. */319
private match(pathname: string): WebRoute | undefined {320
const exact = this.exact.get(pathname)321
if (exact !== undefined) return exact322
let best: WebRoute | undefined323
for (const [prefix, route] of this.prefixes) {324
if (pathname !== prefix && !pathname.startsWith(`${prefix}/`)) continue325
if (best === undefined || prefix.length > best.path.length) best = route326
}327
return best328
}330
/**331
* Run an index.html body through the registered taps in registration order332
* — called by the fallback owner on every index response it renders.333
* @param html - the raw index.html body.334
* @returns the transformed body.335
*/336
applyIndexTaps(html: string): string {337
let out = html338
for (const transform of this.indexTaps) out = transform(out)339
return out340
}342
/**343
* Gather the structured injection table: one `webserver/index-inject` emit,344
* every subscriber pushes its current rows. Fresh per call, so subscribers345
* read live state (module graph, theme preference) at emit time.346
* @returns rows in subscriber activation order.347
*/348
collectIndexInjections(): IndexInjection[] {349
const table: IndexInjection[] = []350
this.ctx.emit('webserver/index-inject', table)351
return table352
}354
/**355
* Render one index.html body: the structured injection table first, then356
* the raw `tapIndex` transforms over the result.357
* @param html - the raw index.html body.358
* @returns the transformed body.359
*/360
renderIndex(html: string): string {361
return this.applyIndexTaps(renderIndexInjections(html, this.collectIndexInjections()))362
}363
}365
export default WebServer