Files
deepseek-harness/packages/host/frontend-static/src/index.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

168 lines
7.5 KiB
TypeScript

/**
* @deepseek-ai/dsh-host-frontend-static — SPA dist server over the webserver
* fallback seat: a webserver resource serving the built frontend directory
* with explicit index entry points. A readable index renders at the dist root
* and configured index path; missing paths return 404, traversal outside the
* dist root is 403, unknown extensions ship as octet-stream, and non-GET/HEAD
* is 405. An HTTP index request first passes Connection's browser
* authentication and renders for the browser Peer; an in-process read renders
* for the Peer its caller admitted. Both renders go through the webserver's
* index render (structured injection rows, then raw taps). Non-index assets
* stay public; every file other than the index carries a weak ETag with
* `cache-control: no-cache` and answers a matching `If-None-Match` with 304,
* while the index carries no cache headers. The dist location is workspace
* knowledge of the composing application, so `distIndex` is typically
* supplied through a `!!js` expression, never hardcoded by a deployment.
* @module @deepseek-ai/dsh-host-frontend-static
*/
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { dirname, extname, join, normalize, resolve, sep } from 'node:path'
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type {} from '@deepseek-ai/dsh-client-connection'
import type {} from '@deepseek-ai/dsh-host-webserver'
/** Stable Cordis plugin name. */
export const name = 'frontend-static'
/** Services required before the authenticated fallback seat can be claimed. */
export const inject = ['webServer', 'connection']
/** Plugin config: the dist anchor. */
export interface Config {
/** Absolute path of index.html inside the dist root. */
distIndex: string
}
export const Config: z<Config> = z.object({
distIndex: z.string().required(),
})
const HTML_MIME = 'text/html; charset=utf-8'
const MIME: Record<string, string> = {
'.html': HTML_MIME,
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
// Launchers require an image Content-Type for the install icons.
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.json': 'application/json',
'.map': 'application/json',
'.webmanifest': 'application/manifest+json',
// The packed VFS image. Served as its own bytes, never as a Content-Encoding:
// the worker inflates the body itself, and a transport-level encoding would
// leave it inflating an already-decoded archive.
'.gz': 'application/gzip',
}
const STATIC_MISS_CODES: ReadonlySet<string | undefined> = new Set([
'ENOENT',
'EISDIR',
'ENOTDIR',
])
/** A Response body that is `bytes` itself, without the copy a buffer body gets. */
function bytesBody(bytes: Uint8Array): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(bytes)
controller.close()
},
})
}
/**
* Answer one GET or HEAD dist request; the webserver answers any other method
* with 405 before a resource runs. A malformed percent-escape or a filesystem
* failure other than a missing or non-file target rejects.
* @param request - the request; only its method and URL path are read.
* @param distRoot - absolute dist root directory (resolved by the caller).
* @param distIndex - absolute path of index.html inside distRoot.
* @param authorizeIndex - answers an index request before its bytes are read:
* the response to send instead of the index, or undefined to serve it.
* @param renderIndex - turns the raw index.html body into the served document
* for the dist root and configured index path.
* @returns the dist response; a HEAD response carries the GET status and
* headers without a body. A file other than the index carries
* `cache-control: no-cache` and a weak ETag from a SHA-1 of its bytes, and a
* request whose `If-None-Match` equals that ETag receives an empty 304.
*/
async function serveStatic(
request: Request, distRoot: string, distIndex: string,
authorizeIndex: () => Response | undefined,
renderIndex: (html: string) => string,
): Promise<Response> {
const target = resolve(normalize(join(distRoot, decodeURIComponent(new URL(request.url).pathname))))
// Traversal rejection: the target must be distRoot itself (`/`) or stay under
// it. `sep`, not '/': resolve() emits backslash paths on Windows, where a '/'
// suffix would reject every legitimate subpath as traversal.
if (target !== distRoot && !target.startsWith(distRoot + sep)) return new Response(null, { status: 403 })
let body: string | Uint8Array<ArrayBuffer>
let headers: Record<string, string>
try {
if (target === distRoot || target === distIndex) {
const refusal = authorizeIndex()
if (refusal !== undefined) return refusal
body = renderIndex(await readFile(distIndex, 'utf8'))
headers = { 'content-type': HTML_MIME }
} else {
body = await readFile(target)
// Weak: gzip changes the transferred bytes.
const cache = { 'cache-control': 'no-cache', etag: `W/"${createHash('sha1').update(body).digest('base64url')}"` }
if (request.headers.get('if-none-match') === cache.etag) return new Response(null, { status: 304, headers: cache })
headers = { 'content-type': MIME[extname(target)] ?? 'application/octet-stream', ...cache }
}
} catch (error) {
// Only absent or non-file targets are 404; other filesystem failures reach
// the caller, which the webserver answers with 400 over HTTP.
if (!STATIC_MISS_CODES.has((error as NodeJS.ErrnoException).code)) throw error
return new Response(null, { status: 404 })
}
if (request.method === 'HEAD') return new Response(null, { status: 200, headers })
return new Response(typeof body === 'string' ? body : bytesBody(body), { status: 200, headers })
}
/**
* Claim the webserver fallback seat with a resource serving the dist.
* @param ctx - plugin context carrying the webServer and connection services.
* @param config - validated {@link Config}.
*/
export function apply(ctx: Context, config: Config): void {
const distIndex = config.distIndex
const distRoot = dirname(distIndex)
// Connection's browser authentication writes the token exchange's 303 or
// the 401 through a writeHead/end response; capture it as a Fetch response.
// The answer stays a 401 unless Connection writes another one.
const authorizeIndex = (request: Request): Response | undefined => {
let status = 401
let headers = new Headers()
let body: string | undefined
const admitted = ctx.connection.authorizeIndex(request, {
writeHead(nextStatus, nextHeaders) {
status = nextStatus
headers = new Headers(nextHeaders)
},
end(text) { body = text },
})
return admitted ? undefined : new Response(body, { status, headers })
}
ctx.effect(() => ctx.webServer.registerFallback({
resource: (request, peer) => serveStatic(
request,
distRoot,
distIndex,
// A read with a Peer was admitted by its caller; an HTTP request carries
// none and passes Connection's browser authentication instead.
() => peer === undefined ? authorizeIndex(request) : undefined,
// The browser authentication admits the owner's shared browser Peer. The
// base goes in after all index transforms, so it precedes every resource
// reference they insert.
html => ctx.webServer.renderIndex(html, peer ?? ctx.connection.browserPeer)
.replace(/<head(?:\s[^>]*)?>/i, open => `${open}<base href="./">`),
),
}), 'frontend-static: fallback seat')
}