Files
zhu1090093659 62450aedeb fix(host): request identity encoding from every remote fetch
The alpha.2 host import of npm undici costs Node built-in fetch its

content-encoding decompression. shared/host/http.ts now owns the rule as

withIdentityEncoding(init), and every host fetch that parses a remote body

adopts it: the market installer (manifest and assets), the plugin-manager

npm-registry probe, the remote-web-ui registry/GitHub probes and inner

app-shell fetch, and the usage provider probes.
2026-09-23 18:28:54 +08:00

127 lines
4.8 KiB
TypeScript

/**
* Shared JSON body/response helpers for the host route families: one strict
* bounded body reader, one lenient bounded body reader, one JSON object
* narrow, one JSON writer, and the outbound request-init helper that keeps a
* host fetch body decodable (withIdentityEncoding). Previously the readers
* were copy-pasted across the
* package route files (routes.ts, update-routes.ts, mobile-api.ts, and each
* family's route module) with drifting contracts: body caps ranging 4 KiB to
* 1 MiB and four distinct overflow behaviors (reject, undefined, null, throw).
*
* Packages receive this file as a generated copy via scripts/sync-shared.mjs;
* edit this shared source and re-run the sync instead of editing a copy.
* Consumer code is migrated onto it in follow-up waves; no call site changes
* belong in the same change as its introduction.
* @module dsh-web-shared/host/http
*/
import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'node:http'
/** Default body cap for readJsonBody: 64 KiB. */
const DEFAULT_JSON_BODY_MAX_BYTES = 64 * 1024
/** Family-default JSON response headers; callers may append or override. */
const JSON_HEADERS = {
'content-type': 'application/json; charset=utf-8',
'referrer-policy': 'no-referrer',
} satisfies OutgoingHttpHeaders
/**
* Strict bounded body reader: parse a request body of at most maxBytes as
* JSON.
* @throws 'body too large' past the cap, or the JSON.parse error for an
* invalid or empty payload.
*/
export async function readBoundedJson(req: IncomingMessage, maxBytes: number): Promise<unknown> {
const chunks: Buffer[] = []
let size = 0
for await (const chunk of req) {
const buffer = chunk as Buffer
size += buffer.length
if (size > maxBytes) throw new Error('body too large')
chunks.push(buffer)
}
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
}
/**
* Lenient bounded body reader: parse a request body as JSON, or null on an
* empty body, invalid JSON, or a body past maxBytes (default 64 KiB).
* Overflow destroys the request instead of draining the remainder (no drain
* call, matching the current repo-wide behavior); callers must not keep
* reading the request afterwards. With objectOnly, non-JSON-object payloads
* also yield null.
*/
export async function readJsonBody(
req: IncomingMessage,
opts: { maxBytes?: number; objectOnly?: boolean } = {},
): Promise<unknown | null> {
const maxBytes = opts.maxBytes ?? DEFAULT_JSON_BODY_MAX_BYTES
const chunks: Buffer[] = []
let size = 0
for await (const chunk of req) {
const buffer = chunk as Buffer
size += buffer.length
if (size > maxBytes) {
req.destroy()
return null
}
chunks.push(buffer)
}
const text = Buffer.concat(chunks).toString('utf8')
if (text === '') return null
try {
const parsed: unknown = JSON.parse(text)
if (opts.objectOnly && !isJsonObject(parsed)) return null
return parsed
} catch {
return null
}
}
/** Whether a value is a JSON object: typeof object, not null, not an array. */
function isJsonObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/** Narrow a value to a JSON object, or undefined when it is not one. */
export function asJsonObject(value: unknown): Record<string, unknown> | undefined {
return isJsonObject(value) ? value : undefined
}
/**
* Request init that asks a remote origin for an uncompressed body.
*
* The DSH host boots `@deepseek-ai/dsh-http-proxy`, whose top-level import of
* the npm `undici` copy replaces the legacy `undici.globalDispatcher.1` slot
* Node's built-in `fetch()` reads. That cross-major wrapper drops the
* `content-encoding` header and automatic decompression, so a plain fetch
* resolves to raw gzip/brotli/zstd bytes — compressed noise no JSON or text
* consumer can read. Every host fetch that parses a remote body must request
* identity encoding. Caller headers survive; `accept-encoding` is forced to
* `identity` because a caller value has no decoder behind it in this host.
* @param init - the caller's request init (signal, headers, method, ...).
* @returns a copy with `accept-encoding: identity` merged in.
*/
export function withIdentityEncoding(init: RequestInit = {}): RequestInit {
const headers = new Headers(init.headers)
headers.set('accept-encoding', 'identity')
return { ...init, headers }
}
/**
* Write one JSON response. Default headers are the family defaults
* (content-type and referrer-policy); caller headers are appended or
* override them.
*/
export function writeJson(
res: ServerResponse,
status: number,
body: unknown,
headers: OutgoingHttpHeaders = {},
): void {
const payload = JSON.stringify(body)
res.writeHead(status, { ...JSON_HEADERS, ...headers })
res.end(payload)
}