|
1 | 1 | import { mkdir, writeFile } from 'node:fs/promises' |
2 | | -import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' |
| 2 | +import { createServer, get, type IncomingMessage, type ServerResponse } from 'node:http' |
3 | 3 | import { createRequire } from 'node:module' |
4 | 4 | import { dirname } from 'node:path' |
5 | 5 | import { pathToFileURL } from 'node:url' |
6 | 6 | import { createLogger } from '@sim/logger' |
7 | 7 | import { getErrorMessage } from '@sim/utils/errors' |
8 | 8 | import { toRecord } from '@sim/utils/object' |
| 9 | +import { NodeNextRequest, NodeNextResponse } from 'next/dist/server/base-http/node' |
9 | 10 | import { patchSetHeaderWithCookieSupport } from 'next/dist/server/lib/patch-set-header' |
10 | 11 | import { addRequestMeta } from 'next/dist/server/request-meta' |
| 12 | +import { sendResponse } from 'next/dist/server/send-response' |
11 | 13 |
|
12 | 14 | const logger = createLogger('DocsHeaderMergeE2E') |
13 | 15 | const reportPath = process.env.DOCS_HEADER_REPORT_PATH |
@@ -80,11 +82,66 @@ const cases: HeaderCase[] = [ |
80 | 82 | }, |
81 | 83 | ] |
82 | 84 |
|
| 85 | +interface FramingCase { |
| 86 | + name: string |
| 87 | + body: string |
| 88 | + headers: Record<string, string> |
| 89 | + expected: { 'content-length'?: string; 'content-encoding'?: string } |
| 90 | + forbidden?: string[] |
| 91 | +} |
| 92 | + |
| 93 | +const framingBody = 'route handler body' |
| 94 | +const framingCases: FramingCase[] = [ |
| 95 | + { |
| 96 | + name: 'an unencoded body keeps its declared length', |
| 97 | + body: framingBody, |
| 98 | + headers: { 'Content-Length': String(framingBody.length) }, |
| 99 | + expected: { 'content-length': String(framingBody.length) }, |
| 100 | + }, |
| 101 | + { |
| 102 | + name: 'an identity coding keeps its length and coding', |
| 103 | + body: framingBody, |
| 104 | + headers: { 'Content-Encoding': 'identity', 'Content-Length': String(framingBody.length) }, |
| 105 | + expected: { 'content-length': String(framingBody.length), 'content-encoding': 'identity' }, |
| 106 | + }, |
| 107 | + { |
| 108 | + name: 'a streaming response keeps its no-compression marker', |
| 109 | + body: framingBody, |
| 110 | + headers: { 'Content-Encoding': 'none' }, |
| 111 | + expected: { 'content-encoding': 'none' }, |
| 112 | + }, |
| 113 | + { |
| 114 | + name: 'a transparently decoded body drops its stale length and coding', |
| 115 | + body: framingBody, |
| 116 | + headers: { 'Content-Encoding': 'gzip', 'Content-Length': '7' }, |
| 117 | + expected: {}, |
| 118 | + forbidden: ['content-length', 'content-encoding'], |
| 119 | + }, |
| 120 | + { |
| 121 | + name: 'a declared transfer coding is never forwarded', |
| 122 | + body: framingBody, |
| 123 | + headers: { 'Transfer-Encoding': 'gzip, chunked' }, |
| 124 | + expected: {}, |
| 125 | + }, |
| 126 | +] |
| 127 | + |
83 | 128 | const require = createRequire(import.meta.url) |
84 | 129 | const esmPath = require.resolve('next/dist/esm/server/lib/patch-set-header.js') |
85 | 130 | const imported: unknown = await import(pathToFileURL(esmPath).href) |
86 | 131 | const esmPatch = toRecord(imported).patchSetHeaderWithCookieSupport |
87 | 132 | if (typeof esmPatch !== 'function') throw new Error('The ESM header merger is unavailable') |
| 133 | +const esmSendPath = require.resolve('next/dist/esm/server/send-response.js') |
| 134 | +const esmSendResponse = toRecord(await import(pathToFileURL(esmSendPath).href)).sendResponse |
| 135 | +if (typeof esmSendResponse !== 'function') throw new Error('The ESM response sender is unavailable') |
| 136 | +const senders: Array<{ name: string; send: typeof sendResponse }> = [ |
| 137 | + { name: 'CommonJS', send: sendResponse }, |
| 138 | + { |
| 139 | + name: 'ESM', |
| 140 | + send: async (req, res, response) => { |
| 141 | + await esmSendResponse(req, res, response) |
| 142 | + }, |
| 143 | + }, |
| 144 | +] |
88 | 145 |
|
89 | 146 | const variants: Array<{ |
90 | 147 | name: string |
@@ -187,6 +244,88 @@ try { |
187 | 244 | ) |
188 | 245 | } |
189 | 246 | } |
| 247 | + for (const sender of senders) { |
| 248 | + const server = createServer((req, res) => { |
| 249 | + const entry = framingCases[Number(req.url?.slice(1))] |
| 250 | + if (!entry) { |
| 251 | + res.writeHead(404).end() |
| 252 | + return |
| 253 | + } |
| 254 | + sender |
| 255 | + .send( |
| 256 | + new NodeNextRequest(req), |
| 257 | + new NodeNextResponse(res), |
| 258 | + new Response(entry.body, { headers: entry.headers }) |
| 259 | + ) |
| 260 | + .catch((error: unknown) => { |
| 261 | + res.statusCode = 500 |
| 262 | + res.end(getErrorMessage(error)) |
| 263 | + }) |
| 264 | + }) |
| 265 | + await new Promise<void>((resolve, reject) => { |
| 266 | + server.once('error', reject) |
| 267 | + server.listen(0, '127.0.0.1', resolve) |
| 268 | + }) |
| 269 | + try { |
| 270 | + const address = server.address() |
| 271 | + if (!address || typeof address === 'string') |
| 272 | + throw new Error('No loopback listener is available') |
| 273 | + for (const [index, entry] of framingCases.entries()) { |
| 274 | + const started = performance.now() |
| 275 | + let error: string | undefined |
| 276 | + try { |
| 277 | + const { status, headers, body } = await new Promise<{ |
| 278 | + status: number |
| 279 | + headers: IncomingMessage['headers'] |
| 280 | + body: string |
| 281 | + }>((resolve, reject) => { |
| 282 | + const request = get( |
| 283 | + { host: '127.0.0.1', port: address.port, path: `/${index}`, timeout: 5000 }, |
| 284 | + (response) => { |
| 285 | + const chunks: Buffer[] = [] |
| 286 | + response.on('data', (chunk: Buffer) => chunks.push(chunk)) |
| 287 | + response.on('end', () => |
| 288 | + resolve({ |
| 289 | + status: response.statusCode ?? 0, |
| 290 | + headers: response.headers, |
| 291 | + body: Buffer.concat(chunks).toString('utf8'), |
| 292 | + }) |
| 293 | + ) |
| 294 | + response.on('error', reject) |
| 295 | + } |
| 296 | + ) |
| 297 | + request.on('timeout', () => request.destroy(new Error('Request timed out'))) |
| 298 | + request.on('error', reject) |
| 299 | + }) |
| 300 | + if (status !== 200 || body !== entry.body) |
| 301 | + throw new Error(`Unexpected response: ${status} ${JSON.stringify(body)}`) |
| 302 | + for (const [name, value] of Object.entries(entry.expected)) |
| 303 | + if (headers[name] !== value) |
| 304 | + throw new Error(`Expected ${name} ${value}, got ${String(headers[name])}`) |
| 305 | + for (const name of entry.forbidden ?? []) |
| 306 | + if (headers[name] !== undefined) |
| 307 | + throw new Error(`Stale ${name} was forwarded: ${String(headers[name])}`) |
| 308 | + if ( |
| 309 | + entry.headers['Transfer-Encoding'] && |
| 310 | + headers['transfer-encoding'] === 'gzip, chunked' |
| 311 | + ) |
| 312 | + throw new Error('The declared transfer coding was forwarded') |
| 313 | + } catch (caught) { |
| 314 | + error = getErrorMessage(caught) |
| 315 | + } |
| 316 | + checks.push({ |
| 317 | + name: `${sender.name}: ${entry.name}`, |
| 318 | + status: error ? 'failed' : 'passed', |
| 319 | + durationMs: Math.round(performance.now() - started), |
| 320 | + ...(error ? { error } : {}), |
| 321 | + }) |
| 322 | + } |
| 323 | + } finally { |
| 324 | + await new Promise<void>((resolve, reject) => |
| 325 | + server.close((error) => (error ? reject(error) : resolve())) |
| 326 | + ) |
| 327 | + } |
| 328 | + } |
190 | 329 | } catch (error) { |
191 | 330 | checks.push({ |
192 | 331 | name: 'Header suite lifecycle', |
|
0 commit comments