mirror of
https://github.com/zhu1090093659/dsh-web.git
synced 2026-09-28 22:38:17 +08:00
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.
181 lines
6.6 KiB
TypeScript
181 lines
6.6 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
import { asJsonObject, readBoundedJson, readJsonBody, withIdentityEncoding, writeJson } from '../host/http.ts'
|
|
|
|
/**
|
|
* Async-readable IncomingMessage stand-in built from byte chunks; counts
|
|
* destroy calls. A hand-rolled async iterator keeps destroy counting exact:
|
|
* abandoning the loop must not trigger extra destroy activity.
|
|
*/
|
|
function fakeRequest(chunks: Buffer[] = []): { request: IncomingMessage; destroyCalls: () => number } {
|
|
let destroyCalls = 0
|
|
const request = {
|
|
[Symbol.asyncIterator]() {
|
|
let index = 0
|
|
return {
|
|
next: async () => (index < chunks.length ? { done: false, value: chunks[index++] } : { done: true, value: undefined }),
|
|
}
|
|
},
|
|
destroy() {
|
|
destroyCalls += 1
|
|
},
|
|
} as unknown as IncomingMessage
|
|
return { request, destroyCalls: () => destroyCalls }
|
|
}
|
|
|
|
function bytes(text: string): Buffer {
|
|
return Buffer.from(text, 'utf8')
|
|
}
|
|
|
|
/** ServerResponse stand-in recording status, headers and the payload. */
|
|
function fakeResponse(): { response: ServerResponse; status: () => number; headers: () => Record<string, unknown>; body: () => string } {
|
|
const state = { status: 0, headers: {} as Record<string, unknown>, body: '' }
|
|
const response = {
|
|
writeHead(status: number, headers: Record<string, unknown>) {
|
|
state.status = status
|
|
Object.assign(state.headers, headers)
|
|
},
|
|
end(payload?: string) {
|
|
state.body = payload ?? ''
|
|
},
|
|
} as unknown as ServerResponse
|
|
return {
|
|
response,
|
|
status: () => state.status,
|
|
headers: () => state.headers,
|
|
body: () => state.body,
|
|
}
|
|
}
|
|
|
|
describe('readBoundedJson', () => {
|
|
it('parses a JSON body', async () => {
|
|
const { request } = fakeRequest([bytes('{"a":1}')])
|
|
await expect(readBoundedJson(request, 1024)).resolves.toEqual({ a: 1 })
|
|
})
|
|
|
|
it('throws past the byte cap', async () => {
|
|
const { request } = fakeRequest([bytes('{"a":1}')])
|
|
await expect(readBoundedJson(request, 4)).rejects.toThrow('body too large')
|
|
})
|
|
|
|
it('throws on invalid JSON', async () => {
|
|
const { request } = fakeRequest([bytes('not json')])
|
|
await expect(readBoundedJson(request, 1024)).rejects.toThrow()
|
|
})
|
|
|
|
it('throws on an empty body', async () => {
|
|
const { request } = fakeRequest([])
|
|
await expect(readBoundedJson(request, 1024)).rejects.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('readJsonBody', () => {
|
|
it('parses a JSON body', async () => {
|
|
const { request } = fakeRequest([bytes('{"a":1}')])
|
|
await expect(readJsonBody(request)).resolves.toEqual({ a: 1 })
|
|
})
|
|
|
|
it('returns null for an empty body without destroying the request', async () => {
|
|
const { request, destroyCalls } = fakeRequest([])
|
|
await expect(readJsonBody(request)).resolves.toBeNull()
|
|
expect(destroyCalls()).toBe(0)
|
|
})
|
|
|
|
it('returns null for invalid JSON without destroying the request', async () => {
|
|
const { request, destroyCalls } = fakeRequest([bytes('not json')])
|
|
await expect(readJsonBody(request)).resolves.toBeNull()
|
|
expect(destroyCalls()).toBe(0)
|
|
})
|
|
|
|
it('defaults to a 64 KiB cap', async () => {
|
|
const atCap = fakeRequest([bytes('{"a":"' + 'x'.repeat(64 * 1024 - 8) + '"}')])
|
|
await expect(readJsonBody(atCap.request)).resolves.not.toBeNull()
|
|
const over = fakeRequest([bytes('{"a":"' + 'x'.repeat(64 * 1024 - 6) + '"}')])
|
|
await expect(readJsonBody(over.request)).resolves.toBeNull()
|
|
expect(over.destroyCalls()).toBe(1)
|
|
})
|
|
|
|
it('returns null and destroys the request when the body exceeds a custom cap', async () => {
|
|
const { request, destroyCalls } = fakeRequest([bytes('{"a":1}')])
|
|
await expect(readJsonBody(request, { maxBytes: 4 })).resolves.toBeNull()
|
|
expect(destroyCalls()).toBe(1)
|
|
})
|
|
|
|
it('returns null for a non-object payload in objectOnly mode', async () => {
|
|
for (const payload of ['[1,2]', '"text"', '42', 'null', 'true']) {
|
|
const { request, destroyCalls } = fakeRequest([bytes(payload)])
|
|
await expect(readJsonBody(request, { objectOnly: true })).resolves.toBeNull()
|
|
expect(destroyCalls()).toBe(0)
|
|
}
|
|
})
|
|
|
|
it('accepts an object payload in objectOnly mode', async () => {
|
|
const { request } = fakeRequest([bytes('{"a":1}')])
|
|
await expect(readJsonBody(request, { objectOnly: true })).resolves.toEqual({ a: 1 })
|
|
})
|
|
|
|
it('accepts non-object payloads without objectOnly', async () => {
|
|
const { request } = fakeRequest([bytes('[1,2]')])
|
|
await expect(readJsonBody(request)).resolves.toEqual([1, 2])
|
|
})
|
|
})
|
|
|
|
describe('asJsonObject', () => {
|
|
it('narrows plain objects', () => {
|
|
expect(asJsonObject({ a: 1 })).toEqual({ a: 1 })
|
|
})
|
|
|
|
it('returns undefined for arrays, primitives and null', () => {
|
|
expect(asJsonObject([1, 2])).toBeUndefined()
|
|
expect(asJsonObject('text')).toBeUndefined()
|
|
expect(asJsonObject(42)).toBeUndefined()
|
|
expect(asJsonObject(true)).toBeUndefined()
|
|
expect(asJsonObject(null)).toBeUndefined()
|
|
expect(asJsonObject(undefined)).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('writeJson', () => {
|
|
it('writes the family-default headers with the status and JSON body', () => {
|
|
const { response, status, headers, body } = fakeResponse()
|
|
writeJson(response, 200, { ok: true })
|
|
expect(status()).toBe(200)
|
|
expect(headers()).toEqual({
|
|
'content-type': 'application/json; charset=utf-8',
|
|
'referrer-policy': 'no-referrer',
|
|
})
|
|
expect(body()).toBe('{"ok":true}')
|
|
})
|
|
|
|
it('lets caller headers append to and override the defaults', () => {
|
|
const { response, headers } = fakeResponse()
|
|
writeJson(response, 201, { ok: true }, { 'x-trace-id': 't1', 'content-type': 'application/problem+json' })
|
|
expect(headers()).toMatchObject({
|
|
'referrer-policy': 'no-referrer',
|
|
'x-trace-id': 't1',
|
|
'content-type': 'application/problem+json',
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('withIdentityEncoding', () => {
|
|
it('forces identity encoding even with no caller init', () => {
|
|
const init = withIdentityEncoding()
|
|
expect(new Headers(init.headers).get('accept-encoding')).toBe('identity')
|
|
})
|
|
|
|
it('keeps caller headers, method and signal while overriding a caller accept-encoding', () => {
|
|
const controller = new AbortController()
|
|
const init = withIdentityEncoding({
|
|
method: 'POST',
|
|
signal: controller.signal,
|
|
headers: { authorization: 'Bearer token', 'accept-encoding': 'gzip' },
|
|
})
|
|
const headers = new Headers(init.headers)
|
|
expect(headers.get('authorization')).toBe('Bearer token')
|
|
expect(headers.get('accept-encoding')).toBe('identity')
|
|
expect(init.method).toBe('POST')
|
|
expect(init.signal).toBe(controller.signal)
|
|
})
|
|
})
|