From 5a5e2a47eb5e2831b4a44fa800f22a8a66555753 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 24 Sep 2026 01:51:34 -0500 Subject: [PATCH] feat(http): expand static ServerResponse parity - Expose response state, socket access, and timeout calls to static programs. - Enforce strict response body lengths with Node compatible errors. - Add differential evidence and scoped compatibility claims. --- .../node-v24-compatibility-meta.json | 2 +- .../src/generated/node-v24-compatibility.json | 52 ++++---- .../generated/node-v24-backlog.json | 116 ++++++++++-------- .../generated/node-v24-internal.json | 61 ++++++--- internal/compatibility/src/generate.mjs | 32 +++-- internal/compatibility/static-support.json | 4 +- .../ambient/scriptc-node-fallback.d.ts | 9 +- packages/compiler/src/backend/c/exprs.ts | 49 +++++++- .../compiler/src/backend/llvm/lib-network.ts | 24 +++- .../compiler/src/backend/llvm/lib-shared.ts | 7 ++ .../src/frontend/lowering/lower-server.ts | 60 +++++++-- packages/compiler/src/ir/ir.ts | 9 ++ packages/compiler/src/ir/validate.ts | 23 +++- .../test/ts7/baselines/order-parity.json | 72 +++++++++++ packages/runtime/src/scr_http.c | 104 ++++++++++++++-- packages/runtime/src/scr_runtime.h | 8 ++ .../2957-http-static-response-controls.ts | 77 ++++++++++++ 17 files changed, 574 insertions(+), 135 deletions(-) create mode 100644 tests/corpus/2957-http-static-response-controls.ts diff --git a/docs/src/generated/node-v24-compatibility-meta.json b/docs/src/generated/node-v24-compatibility-meta.json index 0dd04f74d..21968a283 100644 --- a/docs/src/generated/node-v24-compatibility-meta.json +++ b/docs/src/generated/node-v24-compatibility-meta.json @@ -2,6 +2,6 @@ "schemaVersion": 3, "nodeVersion": "24.15.0", "nodeCommit": "848430679556aed0bd073f2bc263331ad84fa119", - "artifactVersion": "a7e38d1b2b0e92cf8ec4", + "artifactVersion": "b92c655b71259eb9c1f8", "rowCount": 3662 } diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index d88426135..d746d7fa0 100644 --- a/docs/src/generated/node-v24-compatibility.json +++ b/docs/src/generated/node-v24-compatibility.json @@ -506,9 +506,9 @@ "apiEntries": 174, "static": { "supported": 0, - "partial": 70, + "partial": 78, "refused": 0, - "not-implemented": 104, + "not-implemented": 96, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -47865,9 +47865,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Static ServerResponse state, socket access, timeouts, and strict body length checks support the tested HTTP/1.1 forms.", + "verification": "test-backed" }, "dynamic": { "status": "not-implemented", @@ -48061,9 +48061,9 @@ "inherited": false }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Static ServerResponse state, socket access, timeouts, and strict body length checks support the tested HTTP/1.1 forms.", + "verification": "test-backed" }, "dynamic": { "status": "not-implemented", @@ -48145,9 +48145,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Static ServerResponse state, socket access, timeouts, and strict body length checks support the tested HTTP/1.1 forms.", + "verification": "test-backed" }, "dynamic": { "status": "not-implemented", @@ -48173,9 +48173,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Static ServerResponse state, socket access, timeouts, and strict body length checks support the tested HTTP/1.1 forms.", + "verification": "test-backed" }, "dynamic": { "status": "not-implemented", @@ -48201,9 +48201,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Static ServerResponse state, socket access, timeouts, and strict body length checks support the tested HTTP/1.1 forms.", + "verification": "test-backed" }, "dynamic": { "status": "not-implemented", @@ -48257,9 +48257,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Static ServerResponse state, socket access, timeouts, and strict body length checks support the tested HTTP/1.1 forms.", + "verification": "test-backed" }, "dynamic": { "status": "not-implemented", @@ -48285,9 +48285,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Static ServerResponse state, socket access, timeouts, and strict body length checks support the tested HTTP/1.1 forms.", + "verification": "test-backed" }, "dynamic": { "status": "not-implemented", @@ -48341,9 +48341,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Static ServerResponse state, socket access, timeouts, and strict body length checks support the tested HTTP/1.1 forms.", + "verification": "test-backed" }, "dynamic": { "status": "not-implemented", diff --git a/internal/compatibility/generated/node-v24-backlog.json b/internal/compatibility/generated/node-v24-backlog.json index 327c5c5e4..3e6b762a4 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -8,8 +8,8 @@ "tiers": { "static": { "replace-refusal": 46, - "verify-gap": 2642, - "audit-partial": 524, + "verify-gap": 2634, + "audit-partial": 532, "classify": 2, "implement": 81 }, @@ -49540,12 +49540,14 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:http", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:http.response.setTimeout", + "tests": [ + "tests/corpus/2957-http-static-response-controls.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -49790,12 +49792,14 @@ "priority": "low", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:http", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:http.connection", + "tests": [ + "tests/corpus/2957-http-static-response-controls.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -49891,12 +49895,14 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:http", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:http.req", + "tests": [ + "tests/corpus/2957-http-static-response-controls.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -49924,12 +49930,14 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:http", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:http.sendDate", + "tests": [ + "tests/corpus/2957-http-static-response-controls.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -49957,12 +49965,14 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:http", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:http.socket", + "tests": [ + "tests/corpus/2957-http-static-response-controls.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -50025,12 +50035,14 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:http", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:http.statusMessage", + "tests": [ + "tests/corpus/2957-http-static-response-controls.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -50058,12 +50070,14 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:http", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:http.strictContentLength", + "tests": [ + "tests/corpus/2957-http-static-response-controls.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -50126,12 +50140,14 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:http", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:http.writableFinished", + "tests": [ + "tests/corpus/2957-http-static-response-controls.ts" + ] }, "dynamic": { "status": "not-implemented", diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 5a671193f..8ebb8b704 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -555,9 +555,9 @@ "apiEntries": 174, "static": { "supported": 0, - "partial": 70, + "partial": 78, "refused": 0, - "not-implemented": 104, + "not-implemented": 96, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -62165,6 +62165,7 @@ "tests/corpus/2954-http-outgoing-headers.ts", "tests/corpus/2955-http-static-state.ts", "tests/corpus/2956-http-static-informational.ts", + "tests/corpus/2957-http-static-response-controls.ts", "tests/fixtures/server/cases/http-trailers/main.ts" ] }, @@ -64847,8 +64848,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:http" + "status": "partial", + "evidence": "compiler-feature:http.response.setTimeout", + "tests": [ + "tests/corpus/2957-http-static-response-controls.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -65061,8 +65065,11 @@ "inherited": false }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:http" + "status": "partial", + "evidence": "compiler-feature:http.connection", + "tests": [ + "tests/corpus/2957-http-static-response-controls.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -65145,8 +65152,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:http" + "status": "partial", + "evidence": "compiler-feature:http.req", + "tests": [ + "tests/corpus/2957-http-static-response-controls.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -65172,8 +65182,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:http" + "status": "partial", + "evidence": "compiler-feature:http.sendDate", + "tests": [ + "tests/corpus/2957-http-static-response-controls.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -65199,8 +65212,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:http" + "status": "partial", + "evidence": "compiler-feature:http.socket", + "tests": [ + "tests/corpus/2957-http-static-response-controls.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -65256,8 +65272,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:http" + "status": "partial", + "evidence": "compiler-feature:http.statusMessage", + "tests": [ + "tests/corpus/2957-http-static-response-controls.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -65283,8 +65302,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:http" + "status": "partial", + "evidence": "compiler-feature:http.strictContentLength", + "tests": [ + "tests/corpus/2957-http-static-response-controls.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -65340,8 +65362,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:http" + "status": "partial", + "evidence": "compiler-feature:http.writableFinished", + "tests": [ + "tests/corpus/2957-http-static-response-controls.ts" + ] }, "dynamic": { "status": "not-implemented", diff --git a/internal/compatibility/src/generate.mjs b/internal/compatibility/src/generate.mjs index 170fac04a..45e34ed63 100644 --- a/internal/compatibility/src/generate.mjs +++ b/internal/compatibility/src/generate.mjs @@ -186,9 +186,10 @@ function featureEntries(compat) { return compat.features ?? []; } -function featureOf(row, entries) { +function featureOf(row, entries, parentSignature) { return entries.find((entry) => entry.chapter === row.chapter && + (entry.parent === undefined || entry.parent === parentSignature) && ((entry.symbols ?? []).includes(row.apiSymbol) || (entry.signatures ?? []).includes(row.signature)) ); } @@ -320,10 +321,10 @@ function classificationContext() { }; } -function classifyStatic(row, chapter, ctx) { +function classifyStatic(row, chapter, ctx, parentSignature) { if (row.depth === 0) return tier("unreviewed", "", { source: "chapter-summary" }); if (row.scope === "documentation" || row.scope === "configuration") return tier("not-applicable", "", { source: row.scope }); - const feature = featureOf(row, ctx.staticFeatures); + const feature = featureOf(row, ctx.staticFeatures, parentSignature); if (feature) return tier(feature.status, "", { source: `compiler-feature:${row.chapter}.${row.apiSymbol}`, tests: feature.evidence }); for (const candidate of symbolCandidates(row)) { // Dedicated lowering paths are authoritative when an older generic @@ -354,10 +355,10 @@ function classifyStatic(row, chapter, ctx) { return tier("unreviewed", ""); } -function classifyDynamic(row, chapter, ctx) { +function classifyDynamic(row, chapter, ctx, parentSignature) { if (row.depth === 0) return tier("unreviewed", "", { source: "chapter-summary" }); if (row.scope === "documentation" || row.scope === "configuration") return tier("not-applicable", "", { source: row.scope }); - const feature = featureOf(row, ctx.dynamicFeatures); + const feature = featureOf(row, ctx.dynamicFeatures, parentSignature); if (feature) return tier(feature.status, "", { source: `island-feature:${row.chapter}.${row.apiSymbol}`, tests: feature.evidence }); if (row.chapter === "globals") { @@ -445,8 +446,9 @@ function flattenChapter(rootNode, chapter, ctx) { nodeStability: stability, }; row.scope = scopeOf(row); - row.static = classifyStatic(row, chapter, ctx); - row.dynamic = classifyDynamic(row, chapter, ctx); + const parentSignature = parents.at(-1)?.textRaw ?? parents.at(-1)?.name; + row.static = classifyStatic(row, chapter, ctx, parentSignature); + row.dynamic = classifyDynamic(row, chapter, ctx, parentSignature); rows.push(row); for (const field of CHILD_FIELDS) { for (const child of node[field] ?? []) walk(child, [...parents, node], depth + 1, stability); @@ -616,6 +618,9 @@ function publicDetail(tier) { if (/^compiler-feature:http\.response\.write(?:Continue|Processing|EarlyHints)$/.test(source)) { return "Static HTTP/1.1 informational responses support the tested no-callback forms; Early Hints accepts string-valued fields."; } + if (/^compiler-feature:http\.(?:response\.setTimeout|connection|req|sendDate|socket|statusMessage|strictContentLength|writableFinished)$/.test(source)) { + return "Static ServerResponse state, socket access, timeouts, and strict body length checks support the tested HTTP/1.1 forms."; + } return "Implemented for the documented scriptc module-loader subset."; } if (source.startsWith("compiler-chapter-policy:")) { @@ -893,11 +898,18 @@ function checkLocal() { } const ctx = classificationContext(); if (snapshot.chapters.length !== 62) throw new Error(`expected 62 pinned Node API chapters, found ${snapshot.chapters.length}`); + const ancestors = []; for (const row of snapshot.rows) { - if (row.depth === 0) continue; + if (row.depth === 0) { + ancestors.length = 1; + ancestors[0] = row; + continue; + } const chapter = snapshot.chapters.find((item) => item.slug === row.chapter); - const expectedStatic = classifyStatic(row, chapter, ctx); - const expectedDynamic = classifyDynamic(row, chapter, ctx); + const parentSignature = ancestors[row.depth - 1]?.signature; + const expectedStatic = classifyStatic(row, chapter, ctx, parentSignature); + const expectedDynamic = classifyDynamic(row, chapter, ctx, parentSignature); + ancestors[row.depth] = row; if (JSON.stringify(row.static) !== JSON.stringify(expectedStatic) || JSON.stringify(row.dynamic) !== JSON.stringify(expectedDynamic)) { throw new Error(`generated classification is stale at ${row.id} (${row.signature}); run 'pnpm node-compat'`); } diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index c61f3241c..abe5d7aca 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -49,7 +49,7 @@ "node-builtin.fs.fstatSync": ["tests/corpus/2950-fs-descriptor-publication.ts"], "node-builtin.fs.fsyncSync": ["tests/corpus/2950-fs-descriptor-publication.ts"], "node-builtin.fs.linkSync": ["tests/corpus/2950-fs-descriptor-publication.ts"], - "node-builtin.http": ["tests/corpus/1780-http-res-surface.ts", "tests/corpus/2672-http-request-response-callback.ts", "tests/corpus/2700-http-static-trailers.ts", "tests/corpus/2701-http-static-trailer-wire.ts", "tests/corpus/2702-http-static-trailer-end.ts", "tests/fixtures/server/cases/http-trailers/main.ts"], + "node-builtin.http": ["tests/corpus/1780-http-res-surface.ts", "tests/corpus/2672-http-request-response-callback.ts", "tests/corpus/2700-http-static-trailers.ts", "tests/corpus/2701-http-static-trailer-wire.ts", "tests/corpus/2702-http-static-trailer-end.ts", "tests/corpus/2957-http-static-response-controls.ts", "tests/fixtures/server/cases/http-trailers/main.ts"], "node-builtin.http2": ["tests/corpus/2260-http2-constants.cjs"], "node-builtin.https": ["tests/corpus/2645-https-client-url-argument.ts", "tests/corpus/2690-https-server-timeout-option.ts"], "node-builtin.module": ["tests/corpus/1598-cjs-builtin-require/main.js"], @@ -83,6 +83,8 @@ { "chapter": "http", "signatures": ["`request.getHeader(name)`", "`request.getHeaderNames()`", "`request.getHeaders()`", "`request.getRawHeaderNames()`", "`request.hasHeader(name)`", "`request.removeHeader(name)`", "`request.setHeader(name, value)`", "`response.getHeaderNames()`", "`response.getHeaders()`"], "status": "partial", "evidence": ["tests/corpus/2954-http-outgoing-headers.ts"] }, { "chapter": "http", "signatures": ["`path` Type: {string} The request path.", "`method` Type: {string} The request method.", "`host` Type: {string} The request host.", "`protocol` Type: {string} The request protocol.", "`listening` Type: {boolean} Indicates whether or not the server is listening for connections.", "`headersSent` Type: {boolean}", "`writableEnded` Type: {boolean}", "`complete` Type: {boolean}", "`httpVersion` Type: {string}", "`method` Type: {string}", "`url` Type: {string}", "`statusCode` Type: {number}", "`statusCode` Type: {number} **Default:** `200`"], "status": "partial", "evidence": ["tests/corpus/2955-http-static-state.ts"] }, { "chapter": "http", "signatures": ["`response.writeContinue()`", "`response.writeProcessing()`", "`response.writeEarlyHints(hints[, callback])`"], "status": "partial", "evidence": ["tests/corpus/2956-http-static-informational.ts"] }, + { "chapter": "http", "signatures": ["`response.setTimeout(msecs[, callback])`"], "status": "partial", "evidence": ["tests/corpus/2957-http-static-response-controls.ts"] }, + { "chapter": "http", "parent": "Class: `http.ServerResponse`", "signatures": ["`req` Type: {http.IncomingMessage}", "`sendDate` Type: {boolean}", "`strictContentLength` Type: {boolean} **Default:** `false`", "`statusMessage` Type: {string}", "`socket` Type: {stream.Duplex}", "`connection` Type: {stream.Duplex}", "`writableFinished` Type: {boolean}"], "status": "partial", "evidence": ["tests/corpus/2957-http-static-response-controls.ts"] }, { "chapter": "http", "signatures": ["`request.cork()`", "`request.flushHeaders()`", "`request.uncork()`", "`response.addTrailers(headers)`", "`response.cork()`", "`response.flushHeaders()`", "`response.uncork()`", "`headersDistinct` Type: {Object}", "`rawTrailers` Type: {string\\[]}", "`trailers` Type: {Object}", "`trailersDistinct` Type: {Object}", "`outgoingMessage.addTrailers(headers)`", "`outgoingMessage.cork()`", "`outgoingMessage.flushHeaders()`", "`outgoingMessage.uncork()`", "`writableCorked` Type: {number}"], "status": "partial", "evidence": ["tests/corpus/2700-http-static-trailers.ts", "tests/corpus/2701-http-static-trailer-wire.ts", "tests/corpus/2702-http-static-trailer-end.ts", "tests/fixtures/server/cases/http-trailers/main.ts"] }, { "chapter": "buffer", "signatures": ["`buf.readBigInt64BE([offset])`", "`buf.readBigInt64LE([offset])`", "`buf.readBigUInt64BE([offset])`", "`buf.readBigUInt64LE([offset])`", "`buf.writeBigInt64BE(value[, offset])`", "`buf.writeBigInt64LE(value[, offset])`", "`buf.writeBigUInt64BE(value[, offset])`", "`buf.writeBigUInt64LE(value[, offset])`"], "status": "supported", "evidence": ["tests/corpus/2902-bigint-bytes.ts", "tests/corpus/2904-bigint-errors.ts"] }, { "chapter": "environment_variables", "symbols": ["export"], "status": "not-applicable" }, diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 755800a8e..7eb050627 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -2390,10 +2390,16 @@ declare module "http" { export interface ServerResponse { readonly headersSent: boolean; readonly writableEnded: boolean; + readonly writableFinished: boolean; readonly writableCorked: number; + readonly req: IncomingMessage; + readonly socket: Socket | null; + readonly connection: Socket | null; + sendDate: boolean; + strictContentLength: boolean; /* Node's writable head properties: the implicit head reads them. */ statusCode: number; - statusMessage: string; + statusMessage: string | undefined; setHeader(name: string, value: string | number): void; getHeader(name: string): string | undefined; getHeaderNames(): string[]; @@ -2413,6 +2419,7 @@ declare module "http" { cork(): void; uncork(): void; addTrailers(headers: OutgoingHttpHeaders | ReadonlyArray<[string, string]>): void; + setTimeout(msecs: number, callback?: () => void): this; /* end's callback forms fire once the body went out (the 'finish' * emit, deferred past the handler's synchronous tail). */ end(data?: string | Uint8Array, callback?: () => void): void; diff --git a/packages/compiler/src/backend/c/exprs.ts b/packages/compiler/src/backend/c/exprs.ts index 6ae8d0d0f..24bd5e545 100644 --- a/packages/compiler/src/backend/c/exprs.ts +++ b/packages/compiler/src/backend/c/exprs.ts @@ -3,7 +3,7 @@ import { InternalCompilerError } from "../../errors.js"; * expression lands in a fresh C temp, with RC ownership tracked on the * emitter's frames (see the discipline comment in emitter core). */ import type { CEmitter, Temp } from "./c-emitter.js"; -import { arrayOf, BOOL, BYTES_U8, bytesOf, canMarshalFuncIntoIsland, CHILDSTREAM_T, CHILDWRITER_T, DYN, F64, IrExpr, IrLibFn, IrRecordShape, IrType, islandPromisePayloadTag, isClassOwnEnumerableFieldName, isDynTypedRefType, isFfiCallbackParam, isFfiContextParam, isFfiReleaseParam, isRefCounted, isUnitType, MAY_THROW_LIB_FNS, RUNTIME_ERROR_CLASSES, STRING, typeEquals, typeKey } from "../../ir/ir.js"; +import { arrayOf, BOOL, BYTES_U8, bytesOf, canMarshalFuncIntoIsland, CHILDSTREAM_T, CHILDWRITER_T, DYN, F64, IrExpr, IrLibFn, IrRecordShape, IrType, islandPromisePayloadTag, isClassOwnEnumerableFieldName, isDynTypedRefType, isFfiCallbackParam, isFfiContextParam, isFfiReleaseParam, isRefCounted, isUnitType, MAY_THROW_LIB_FNS, NETSOCKET_T, RUNTIME_ERROR_CLASSES, STRING, typeEquals, typeKey } from "../../ir/ir.js"; import { boxAccess, BYTES_NUM_KIND_C, BYTES_NUM_VAR_C, bytesElemKindC, cDecl, cFnPtrCast, cNumberLiteral, cStringLiteral, cType, DV_GET_KIND_C, DV_SET_KIND_C, elemAccess, mapKeyAccess, mapKeyKindC, mapValKindC, releaseCallC, retainCallC, vAdapters } from "./types.js"; import { mangleClassNew, mangleClassRetain, mangleClassStruct, mangleField, mangleFnClosure, mangleFunction, mangleGlobal, mangleLocal, mangleRecordClone, mangleRecordNew, mangleRecordStruct, mangleVtStruct } from "../mangle.js"; import { OVERFLOW_MEMBER } from "./shapes.js"; @@ -6672,11 +6672,54 @@ function emitHttpLibCall(state: LibCallState): Temp { case "http.resStatusSet": emitter.line(`scr_http_res_status_set(${arg(0)}, ${arg(1)});${emitter.srcComment(e.loc)}`); return { name: "", type: e.type }; - case "http.resStatusMsgGet": - return finish(`scr_http_res_status_msg_get(${arg(0)})`); + case "http.resStatusMsgGet": { + if (e.type.kind !== "union") throw new InternalCompilerError("emitter bug: http.resStatusMsgGet result is not a union"); + const def = emitter.unionsById.get(e.type.unionId); + const strTag = def ? def.arms.findIndex((a) => a.kind === "string") : -1; + const undefTag = undefinedArmTag(e.type, emitter.unionsById); + if (strTag < 0 || undefTag < 0) throw new InternalCompilerError("emitter bug: http.resStatusMsgGet union lacks its arms"); + const msg = emitter.newTemp(STRING, `scr_http_res_status_msg_get(${arg(0)})`); + emitter.moveTemp(msg); + const present = `scr_union_new_ref(${strTag}, ${msg.name}, &scr_str_retain_v, &scr_str_release_v, NULL)`; + return emitter.newTemp(e.type, `${msg.name} ? ${present} : ${emitter.unitInstanceRef(e.type.unionId, undefTag)}`); + } case "http.resStatusMsgSet": emitter.line(`scr_http_res_status_msg_set(${arg(0)}, ${arg(1)});${emitter.srcComment(e.loc)}`); return { name: "", type: e.type }; + case "http.resRequest": + return finish(`scr_http_res_request(${arg(0)})`); + case "http.resSocket": { + if (e.type.kind !== "union") throw new InternalCompilerError("emitter bug: http.resSocket result is not a union"); + const def = emitter.unionsById.get(e.type.unionId); + const socketTag = def ? def.arms.findIndex((a) => a.kind === "netSocket") : -1; + const nullTag = def ? def.arms.findIndex((a) => a.kind === "nullT") : -1; + if (socketTag < 0 || nullTag < 0) throw new InternalCompilerError("emitter bug: http.resSocket union lacks its arms"); + const sock = emitter.newTemp(NETSOCKET_T, `scr_http_res_socket(${arg(0)})`); + emitter.moveTemp(sock); + const present = `scr_union_new_ref(${socketTag}, ${sock.name}, &scr_net_sock_retain_v, &scr_net_sock_release_v, NULL)`; + return emitter.newTemp(e.type, `${sock.name} ? ${present} : ${emitter.unitInstanceRef(e.type.unionId, nullTag)}`); + } + case "http.resWritableFinished": + return finish(`scr_http_res_writable_finished(${arg(0)})`); + case "http.resSendDateGet": + return finish(`scr_http_res_send_date(${arg(0)})`); + case "http.resSendDateSet": + emitter.line(`scr_http_res_set_send_date(${arg(0)}, ${arg(1)});${emitter.srcComment(e.loc)}`); + return { name: "", type: e.type }; + case "http.resStrictContentLengthGet": + return finish(`scr_http_res_strict_content_length(${arg(0)})`); + case "http.resStrictContentLengthSet": + emitter.line(`scr_http_res_set_strict_content_length(${arg(0)}, ${arg(1)});${emitter.srcComment(e.loc)}`); + return { name: "", type: e.type }; + case "http.resSetTimeout": + emitter.line(`scr_http_res_set_timeout_plain(${arg(0)}, ${arg(1)});${emitter.srcComment(e.loc)}`); + return { name: "", type: e.type }; + case "http.resSetTimeoutCb": { + const cb = args[2]!; + emitter.moveTemp(cb); + emitter.line(`scr_http_res_set_timeout(${arg(0)}, ${arg(1)}, ${cb.name});${emitter.srcComment(e.loc)}`); + return { name: "", type: e.type }; + } case "http.resGetHeader": case "http.clientGetHeader": { // string|undefined, exactly the http.reqHeader emission. diff --git a/packages/compiler/src/backend/llvm/lib-network.ts b/packages/compiler/src/backend/llvm/lib-network.ts index d2c9a542c..801147f38 100644 --- a/packages/compiler/src/backend/llvm/lib-network.ts +++ b/packages/compiler/src/backend/llvm/lib-network.ts @@ -1,7 +1,7 @@ /* Focused LLVM library-call emission extracted from emitter.ts. */ import { InternalCompilerError } from "../../errors.js"; import { undefinedArmTag } from "../../ir/analysis.js"; -import { arrayOf, STRING } from "../../ir/ir.js"; +import { arrayOf, NETSOCKET_T, STRING } from "../../ir/ir.js"; import { mangleRecordNew, mangleRecordStruct } from "../mangle.js"; import type { LlvmEmitterContext, LibCallExpr, LlValue } from "./expr-context.js"; import { f64Lit } from "./common.js"; @@ -242,7 +242,19 @@ export function emitNetworkHttpLibCall(host: LlvmEmitterContext, e: LibCallExpr) B.line(`${raw} = call ptr @scr_net_sock_read_bytes(ptr ${args[0]!.name}, double ${args[1]!.name}) ; +1 or NULL`); return host.wrapNullable(raw, raw, def!.arms[bytesTag]!, bytesTag, e.type, nullTag); } - if (e.fn === "net.sockRemoteAddress" || e.fn === "http.reqHeader" || e.fn === "http.reqTrailer" || e.fn === "http.resGetHeader" || e.fn === "http.clientGetHeader" || e.fn === "http.reqStatusMessage") { + if (e.fn === "http.resSocket") { + if (e.type.kind !== "union") throw new InternalCompilerError("llvm emitter bug: http.resSocket result is not a union"); + const def = host.unionsById.get(e.type.unionId); + const socketTag = def ? def.arms.findIndex((a) => a.kind === "netSocket") : -1; + const nullTag = def ? def.arms.findIndex((a) => a.kind === "nullT") : -1; + if (socketTag < 0 || nullTag < 0) throw new InternalCompilerError("llvm emitter bug: http.resSocket union lacks its arms"); + const args = e.args.map((a) => host.emitExpr(a)); + host.declare(`declare ptr @scr_http_res_socket(ptr)`); + const raw = B.tmp(); + B.line(`${raw} = call ptr @scr_http_res_socket(ptr ${args[0]!.name}) ; +1 or NULL`); + return host.wrapNullable(raw, raw, NETSOCKET_T, socketTag, e.type, nullTag); + } + if (e.fn === "net.sockRemoteAddress" || e.fn === "http.reqHeader" || e.fn === "http.reqTrailer" || e.fn === "http.resGetHeader" || e.fn === "http.clientGetHeader" || e.fn === "http.reqStatusMessage" || e.fn === "http.resStatusMsgGet") { // string | undefined: +1 or NULL, NULL takes the undefined arm. if (e.type.kind !== "union") throw new InternalCompilerError(`llvm emitter bug: ${e.fn} result is not a union`); const def = host.unionsById.get(e.type.unionId); @@ -256,6 +268,7 @@ export function emitNetworkHttpLibCall(host: LlvmEmitterContext, e: LibCallExpr) "http.resGetHeader": "scr_http_res_get_header", "http.clientGetHeader": "scr_http_client_get_header", "http.reqStatusMessage": "scr_http_req_status_message", + "http.resStatusMsgGet": "scr_http_res_status_msg_get", }[e.fn]!; const args = e.args.map((a) => host.emitExpr(a)); const argList = args.map((a) => `${host.llType(a.type)} ${a.name}`).join(", "); @@ -418,6 +431,13 @@ export function emitNetworkHttpLibCall(host: LlvmEmitterContext, e: LibCallExpr) B.line(`call void @scr_http_res_on_finish(ptr ${args[0]!.name}, ptr ${args[1]!.name})`); return { name: "", type: e.type }; } + if (e.fn === "http.resSetTimeoutCb") { + const args = e.args.map((a) => host.emitExpr(a)); + host.moveTemp(args[2]!); + host.declare(`declare void @scr_http_res_set_timeout(ptr, double, ptr)`); + B.line(`call void @scr_http_res_set_timeout(ptr ${args[0]!.name}, double ${args[1]!.name}, ptr ${args[2]!.name})`); + return { name: "", type: e.type }; + } if (e.fn === "net.sockOnFinish") { const args = e.args.map((a) => host.emitExpr(a)); host.moveTemp(args[1]!); diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 335d3651a..3ebe9af93 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -652,6 +652,13 @@ export const LIB_FN_SYMS: Record = { "http.resStatusSet": "scr_http_res_status_set", "http.resStatusMsgGet": "scr_http_res_status_msg_get", "http.resStatusMsgSet": "scr_http_res_status_msg_set", + "http.resRequest": "scr_http_res_request", + "http.resWritableFinished": "scr_http_res_writable_finished", + "http.resSendDateGet": "scr_http_res_send_date", + "http.resSendDateSet": "scr_http_res_set_send_date", + "http.resStrictContentLengthGet": "scr_http_res_strict_content_length", + "http.resStrictContentLengthSet": "scr_http_res_set_strict_content_length", + "http.resSetTimeout": "scr_http_res_set_timeout_plain", "http.resHasHeader": "scr_http_res_has_header_named", "http.resRemoveHeader": "scr_http_res_remove_header", "http.resWriteHeadPairs": "scr_http_res_write_head_pairs", diff --git a/packages/compiler/src/frontend/lowering/lower-server.ts b/packages/compiler/src/frontend/lowering/lower-server.ts index d54d825a0..595120489 100644 --- a/packages/compiler/src/frontend/lowering/lower-server.ts +++ b/packages/compiler/src/frontend/lowering/lower-server.ts @@ -976,22 +976,23 @@ export function lowerServerCloseOverrideAssignment(lowerer: Lowerer, left: ts.Ex }; } -/** `res.statusCode = 404` / `res.statusMessage = "Nope"` — Node's - * writable ServerResponse properties (the implicit head reads them): - * routed from lower-stmts' property-assignment path beside the - * close-override hook. Null when the target isn't one of the two. */ +/** Writable ServerResponse properties consulted by the implicit head and + * the strict body-length check. Null when the target isn't one of them. */ export function lowerHttpResPropertyAssignment(lowerer: Lowerer, left: ts.Expression, right: ts.Expression, loc: SrcLoc,): IrStmt | null { if (!ts.isPropertyAccessExpression(left) || left.questionDotToken) return null; const name = left.name.text; - if (name !== "statusCode" && name !== "statusMessage") return null; + if (name !== "statusCode" && name !== "statusMessage" && + name !== "sendDate" && name !== "strictContentLength") return null; if (lowerer.mapTypeOf(lowerer.typeOf(left.expression))?.kind !== "httpRes") return null; if (!lowerer.isStdlibMember(left)) return null; - const receiver = lowerer.lowerExpr(left.expression); + const receiver = coerceToHandle(lowerer, left.expression, HTTPRES_T); const value = name === "statusCode" ? lowerer.lowerExprExpecting(right, F64) - : lowerer.lowerExprExpecting(right, STRING); - const fn: IrLibFn = name === "statusCode" ? "http.resStatusSet" : "http.resStatusMsgSet"; + : lowerer.lowerExprExpecting(right, name === "statusMessage" ? STRING : BOOL); + const fn: IrLibFn = name === "statusCode" ? "http.resStatusSet" + : name === "statusMessage" ? "http.resStatusMsgSet" + : name === "sendDate" ? "http.resSendDateSet" : "http.resStrictContentLengthSet"; return { kind: "exprStmt", expr: { kind: "libCall", fn, args: [receiver, value], type: VOID, loc }, @@ -2295,11 +2296,30 @@ export function lowerServerProperty(lowerer: Lowerer, expr: ts.PropertyAccessExp return { kind: "libCall", fn: "http.resStatusGet", args: [receiver], type: F64, loc }; } if (recvKind === "httpRes" && lowerer.isStdlibMember(expr) && expr.name.text === "statusMessage") { - // The assigned reason phrase, or the current status code's default - // when none was set (Node answers undefined until the head goes out - // — divergence: this surface is string-typed, the checker's shape). + // Unset until the head is sent; then the assigned or default reason. const receiver = coerceToHandle(lowerer, expr.expression, HTTPRES_T); - return { kind: "libCall", fn: "http.resStatusMsgGet", args: [receiver], type: STRING, loc }; + return { kind: "libCall", fn: "http.resStatusMsgGet", args: [receiver], type: lowerer.withUndefinedArm(STRING), loc }; + } + if (recvKind === "httpRes" && lowerer.isStdlibMember(expr)) { + const name = expr.name.text; + if (name === "req") { + const receiver = coerceToHandle(lowerer, expr.expression, HTTPRES_T); + return { kind: "libCall", fn: "http.resRequest", args: [receiver], type: HTTPREQ_T, loc }; + } + if (name === "socket" || name === "connection") { + const receiver = coerceToHandle(lowerer, expr.expression, HTTPRES_T); + const type: IrType = { kind: "union", unionId: lowerer.unions.intern([NETSOCKET_T, NULL_T]) }; + return { kind: "libCall", fn: "http.resSocket", args: [receiver], type, loc }; + } + if (name === "writableFinished") { + const receiver = coerceToHandle(lowerer, expr.expression, HTTPRES_T); + return { kind: "libCall", fn: "http.resWritableFinished", args: [receiver], type: BOOL, loc }; + } + if (name === "sendDate" || name === "strictContentLength") { + const receiver = coerceToHandle(lowerer, expr.expression, HTTPRES_T); + const fn: IrLibFn = name === "sendDate" ? "http.resSendDateGet" : "http.resStrictContentLengthGet"; + return { kind: "libCall", fn, args: [receiver], type: BOOL, loc }; + } } if (recvKind === "http2Session" && lowerer.isStdlibMember(expr)) { const m = expr.name.text; @@ -4634,6 +4654,20 @@ function lowerHttpResMethodCall(lowerer: Lowerer, call: ts.CallExpression, const name = access.name.text; const loc = locOf(call); const args = call.arguments; + if (name === "setTimeout") { + if (args.length < 1 || args.length > 2) { + lowerer.noLowering(`res.setTimeout with ${args.length} arguments`, call, "use setTimeout(milliseconds[, callback])"); + } + const receiver = coerceToHandle(lowerer, access.expression, HTTPRES_T); + const ms = lowerer.lowerExprExpecting(args[0]!, F64); + const cb = args.length === 2 + ? lowerCallbackArg(lowerer, args[1]!, "response timeout callbacks", 0, () => false, "use ()", []).cb + : null; + const fn: IrLibFn = cb === null ? "http.resSetTimeout" : "http.resSetTimeoutCb"; + const callArgs = cb === null ? [receiver, ms] : [receiver, ms, cb]; + if (resultIsDiscarded(call)) return { kind: "libCall", fn, args: callArgs, type: VOID, loc }; + return receiverReturningCall(lowerer, fn, callArgs, HTTPRES_T, loc); + } if (name === "writeContinue" || name === "writeProcessing" || name === "writeEarlyHints") { requireStatementPosition(lowerer, call, `res.${name}(...)`); if (lowerer.typeOf(access.expression).getSymbol()?.name === "Http2ServerResponse") { @@ -4974,7 +5008,7 @@ function lowerHttpResMethodCall(lowerer: Lowerer, call: ts.CallExpression, lowerer.noLowering( `ServerResponse.${name}`, call, - "setHeader, getHeader, hasHeader, removeHeader, writeHead, writeContinue, writeProcessing, writeEarlyHints, write, end, destroy, headersSent, statusCode, statusMessage, and on/once of close are the supported ServerResponse members", + "setHeader, getHeader, hasHeader, removeHeader, writeHead, writeContinue, writeProcessing, writeEarlyHints, setTimeout, write, end, destroy, response state reads, and on/once of close are the supported ServerResponse members", lowerer.checker.getSymbolAtLocation(access.name), ); } diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 963acebb9..d6bfe41e0 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -2767,6 +2767,15 @@ export type IrLibFn = | "http.resStatusSet" | "http.resStatusMsgGet" | "http.resStatusMsgSet" + | "http.resRequest" + | "http.resSocket" + | "http.resWritableFinished" + | "http.resSendDateGet" + | "http.resSendDateSet" + | "http.resStrictContentLengthGet" + | "http.resStrictContentLengthSet" + | "http.resSetTimeout" + | "http.resSetTimeoutCb" | "http.resGetHeader" | "http.resGetHeaderNames" | "http.resGetRawHeaderNames" diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 265bea7c1..d86afe456 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -514,8 +514,17 @@ export const LIB_FN_SIGS: Record a.kind === "string") && def.arms.some((a) => a.kind === "undefinedT"); if (!ok) { - err(`libCall http.reqStatusMessage must return the 'string | undefined' union`, e.loc); + err(`libCall ${e.fn} must return the 'string | undefined' union`, e.loc); } break; } + if (e.fn === "http.resSocket") { + const def = e.type.kind === "union" ? unions.get(e.type.unionId) : undefined; + const ok = def && def.arms.length === 2 && + def.arms.some((a) => a.kind === "netSocket") && + def.arms.some((a) => a.kind === "nullT"); + if (!ok) err(`libCall http.resSocket must return the 'Socket | null' union`, e.loc); + break; + } if (e.fn === "http.reqH2Stream") { const def = e.type.kind === "union" ? unions.get(e.type.unionId) : undefined; const ok = def && def.arms.length === 2 && diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 1e8ae74ba..0638dd826 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -6277,6 +6277,12 @@ ], "diags": [] }, + "/tests/corpus/2900-float64array-static.ts": { + "order": [ + "/tests/corpus/2900-float64array-static.ts" + ], + "diags": [] + }, "/tests/corpus/2900-void-conditional-arrow.ts": { "order": [ "/tests/corpus/2900-void-conditional-arrow.ts" @@ -6576,6 +6582,48 @@ ], "diags": [] }, + "/tests/corpus/2951-math-static-analytic.ts": { + "order": [ + "/tests/corpus/2951-math-static-analytic.ts" + ], + "diags": [] + }, + "/tests/corpus/2952-math-static-remaining.ts": { + "order": [ + "/tests/corpus/2952-math-static-remaining.ts" + ], + "diags": [] + }, + "/tests/corpus/2953-http-header-validation.ts": { + "order": [ + "/tests/corpus/2953-http-header-validation.ts" + ], + "diags": [] + }, + "/tests/corpus/2954-http-outgoing-headers.ts": { + "order": [ + "/tests/corpus/2954-http-outgoing-headers.ts" + ], + "diags": [] + }, + "/tests/corpus/2955-http-static-state.ts": { + "order": [ + "/tests/corpus/2955-http-static-state.ts" + ], + "diags": [] + }, + "/tests/corpus/2956-http-static-informational.ts": { + "order": [ + "/tests/corpus/2956-http-static-informational.ts" + ], + "diags": [] + }, + "/tests/corpus/2957-http-static-response-controls.ts": { + "order": [ + "/tests/corpus/2957-http-static-response-controls.ts" + ], + "diags": [] + }, "/tests/corpus/2961-emitter-computed-names.cjs": { "order": [ "/tests/corpus/2961-emitter-computed-names.cjs" @@ -6594,6 +6642,30 @@ ], "diags": [] }, + "/tests/corpus/2964-indexed-compound.ts": { + "order": [ + "/tests/corpus/2964-indexed-compound.ts" + ], + "diags": [] + }, + "/tests/corpus/2966-numeric-array-read-fusion.ts": { + "order": [ + "/tests/corpus/2966-numeric-array-read-fusion.ts" + ], + "diags": [] + }, + "/tests/corpus/2967-numeric-array-read-lifetime.ts": { + "order": [ + "/tests/corpus/2967-numeric-array-read-lifetime.ts" + ], + "diags": [] + }, + "/tests/corpus/2968-numeric-array-borrow.ts": { + "order": [ + "/tests/corpus/2968-numeric-array-borrow.ts" + ], + "diags": [] + }, "/tests/corpus/300-if-else.ts": { "order": [ "/tests/corpus/300-if-else.ts" diff --git a/packages/runtime/src/scr_http.c b/packages/runtime/src/scr_http.c index ab3145840..349699240 100644 --- a/packages/runtime/src/scr_http.c +++ b/packages/runtime/src/scr_http.c @@ -813,8 +813,11 @@ struct ScrHttpRes { bool chunked; bool finished; bool no_date; /* res.sendDate = false: suppress the implicit Date header */ + bool strict_content_length; + size_t strict_bytes_written; /* Node counts UTF-8 bytes, not JS characters */ bool keep_alive; /* the REQUEST's verdict; Connection: close overrides */ bool destroyed; /* res.destroy()/teardown — res.destroyed (true in 'close') */ + bool socket_detached; /* finish/close removes the response's socket view */ /* cork()/uncork(): corked counts the nesting (res.writableCorked); * writes while corked coalesce in cork_buf and flush as ONE write when * the count reaches zero (or at end()) — Node's coalescing, and the @@ -871,6 +874,51 @@ void scr_http_res_release_v(void *p) { scr_http_res_release((ScrHttpRes *)p); } bool scr_http_res_headers_sent(ScrHttpRes *r) { return r->head_sent; } bool scr_http_res_writable_ended(ScrHttpRes *r) { return r->finished; } +bool scr_http_res_send_date(ScrHttpRes *r) { return !r->no_date; } +void scr_http_res_set_send_date(ScrHttpRes *r, bool value) { r->no_date = !value; } +bool scr_http_res_strict_content_length(ScrHttpRes *r) { return r->strict_content_length; } +void scr_http_res_set_strict_content_length(ScrHttpRes *r, bool value) { r->strict_content_length = value; } +ScrHttpReq *scr_http_res_request(ScrHttpRes *r) { return scr_http_req_retain(r->req_ref); } +ScrNetSocket *scr_http_res_socket(ScrHttpRes *r) { + return r->sock && !r->socket_detached ? scr_net_sock_retain(r->sock) : NULL; +} +bool scr_http_res_writable_finished(ScrHttpRes *r) { return r->finished; } + +static bool scr_http_res_has_header(ScrHttpRes *r, const char *name); + +/* Node checks declared body length only while strictContentLength is true, + * with no transfer encoding and a body-bearing response. The stored header + * string has already passed setHeader's validation; strtod matches Node's + * numeric conversion for the ordinary decimal Content-Length values. */ +static bool scr_http_res_expected_length(ScrHttpRes *r, double *expected) { + if (!r->strict_content_length || r->h2_stream || r->chunked || + (r->status >= 100 && r->status < 200) || r->status == 204 || r->status == 304 || + scr_http_res_has_header(r, "transfer-encoding")) return false; + if (r->req_ref && r->req_ref->method && r->req_ref->method->len == 4 && + memcmp(r->req_ref->method->data, "HEAD", 4) == 0) return false; + for (size_t i = 0; i < r->nheaders; i++) { + if (r->hnames[i]->len != 14) continue; + bool match = true; + for (size_t j = 0; j < 14; j++) { + if (tolower((unsigned char)r->hnames[i]->data[j]) != "content-length"[j]) { + match = false; + break; + } + } + if (!match) continue; + *expected = strtod(r->hvalues[i]->data, NULL); + return true; + } + return false; +} + +static void scr_http_res_length_mismatch(ScrHttpRes *r, double expected) { + char msg[256]; + int n = snprintf(msg, sizeof msg, + "Response body's content-length of %zu byte(s) does not match the content-length of %.15g byte(s) set in header", + r->strict_bytes_written, expected); + scr_throw_error_msg_code(SCR_ERR_ERROR, msg, (size_t)n, "ERR_HTTP_CONTENT_LENGTH_MISMATCH"); +} /* res.statusCode: 200 until assigned (Node's fresh-response default); * assignment after the head went out is inert (Node throws on the WRITE @@ -882,11 +930,11 @@ void scr_http_res_status_set(ScrHttpRes *r, double status) { r->status = (int)status; } -/* res.statusMessage: the assigned reason phrase, or the current status - * code's default once none was set (Node answers undefined before the - * head goes out — divergence: this surface is string-typed). */ +/* An unset statusMessage is undefined until the head chooses the status + * code's default reason phrase. */ ScrStr *scr_http_res_status_msg_get(ScrHttpRes *r) { if (r->status_msg) return scr_str_retain(r->status_msg); + if (!r->head_sent) return NULL; const char *reason = scr_http_reason(r->status > 0 ? r->status : 200); return scr_str_new(reason, strlen(reason)); } @@ -1373,10 +1421,28 @@ static void scr_http_res_write_raw(ScrHttpRes *r, const char *data, size_t len) } void scr_http_res_write_str(ScrHttpRes *r, ScrStr *data /*borrowed*/) { + double expected; + if (r->head_sent && scr_http_res_expected_length(r, &expected) && + (double)(r->strict_bytes_written + data->len) > expected) { + r->strict_bytes_written += data->len; + scr_http_res_length_mismatch(r, expected); + r->strict_bytes_written -= data->len; + return; + } + if (r->strict_content_length) r->strict_bytes_written += data->len; scr_http_res_write_raw(r, data->data, data->len); } void scr_http_res_write_bytes(ScrHttpRes *r, ScrBytes *data /*borrowed*/) { + double expected; + if (r->head_sent && scr_http_res_expected_length(r, &expected) && + (double)(r->strict_bytes_written + data->len) > expected) { + r->strict_bytes_written += data->len; + scr_http_res_length_mismatch(r, expected); + r->strict_bytes_written -= data->len; + return; + } + if (r->strict_content_length) r->strict_bytes_written += data->len; scr_http_res_write_raw(r, (const char *)data->data, data->len); } @@ -1420,6 +1486,10 @@ void scr_http_res_set_timeout(ScrHttpRes *r, double ms, ScrClosure *cb /*moves, if (cb) scr_net_sock_on_timeout(r->sock, cb, true); } +void scr_http_res_set_timeout_plain(ScrHttpRes *r, double ms) { + scr_http_res_set_timeout(r, ms, NULL); +} + static void scr_http_conn_response_finished(struct ScrHttpConn *conn, bool keep_alive); static void scr_http_queue_res_finish(ScrHttpRes *res); @@ -1437,6 +1507,15 @@ static void scr_http_res_cork_flush(ScrHttpRes *r) { static void scr_http_res_end_raw(ScrHttpRes *r, const char *data, size_t len) { if (r->finished) return; + double expected; + if (len > 0 && r->head_sent && scr_http_res_expected_length(r, &expected) && + (double)(r->strict_bytes_written + len) != expected) { + r->strict_bytes_written += len; + scr_http_res_length_mismatch(r, expected); + r->strict_bytes_written -= len; + return; + } + if (r->strict_content_length) r->strict_bytes_written += len; if (r->corked > 0 || r->cork_len > 0) { /* end() flushes every cork level (Node) — the body streamed, so the * framing below takes the already-committed streaming path */ @@ -1449,7 +1528,7 @@ static void scr_http_res_end_raw(ScrHttpRes *r, const char *data, size_t len) { if (!r->head_sent) scr_http_res_send_head(r, (long long)len); scr_http_h2_ops->end(r->h2_stream, data, len); r->finished = true; - if (r->finish_ls.n > 0) scr_http_queue_res_finish(r); + scr_http_queue_res_finish(r); return; } if (!r->head_sent) { @@ -1466,8 +1545,13 @@ static void scr_http_res_end_raw(ScrHttpRes *r, const char *data, size_t len) { scr_http_res_write_raw(r, data, len); if (r->chunked && r->sock) scr_http_send_chunk_end(r->sock, &r->trailers); } + if (scr_http_res_expected_length(r, &expected) && + (double)r->strict_bytes_written != expected) { + scr_http_res_length_mismatch(r, expected); + return; + } r->finished = true; - if (r->finish_ls.n > 0) scr_http_queue_res_finish(r); + scr_http_queue_res_finish(r); if (r->conn) scr_http_conn_response_finished(r->conn, r->keep_alive); } @@ -1734,6 +1818,7 @@ static void scr_http_proto_sweep(void) { if (!res->close_emitted) { res->close_emitted = true; res->destroyed = true; /* Node: destroyed reads true inside 'close' */ + res->socket_detached = true; scr_net_fire0_this(&res->close_ls, res, SCR_DYNH_HTTP_RES); scr_net_ls_drop(&res->close_ls); } @@ -1792,6 +1877,7 @@ static void scr_http_proto_sweep(void) { case SCR_HTTP_EMIT_RES_FINISH: { ScrHttpRes *res = (ScrHttpRes *)e.h; res->finish_queued = false; /* a later end(cb) on the finished res re-queues */ + res->socket_detached = true; scr_net_fire0_this(&res->finish_ls, res, SCR_DYNH_HTTP_RES); break; } @@ -4910,6 +4996,7 @@ static ScrDyn *scr_http_dynh_res_get(void *h, const char *key, size_t key_len) { if (strcmp(key, "statusCode") == 0) return scr_dyn_new_num(scr_http_res_status_get(r)); if (strcmp(key, "statusMessage") == 0) { ScrStr *s = scr_http_res_status_msg_get(r); + if (!s) return scr_dyn_retain(scr_dyn_undefined()); ScrDyn *d = scr_dyn_new_str(s); scr_str_release(s); return d; @@ -4921,8 +5008,11 @@ static ScrDyn *scr_http_dynh_res_get(void *h, const char *key, size_t key_len) { return scr_dyn_new_bool(r->finished); } if (strcmp(key, "socket") == 0 || strcmp(key, "connection") == 0) { - if (!r->sock) return scr_dyn_new_null(); /* destroyed: Node nulls it */ - return scr_dyn_new_handle(r->sock, SCR_DYNH_NET_SOCKET); + ScrNetSocket *sock = scr_http_res_socket(r); + if (!sock) return scr_dyn_new_null(); + ScrDyn *view = scr_dyn_new_handle(sock, SCR_DYNH_NET_SOCKET); + scr_net_sock_release(sock); + return view; } if (strcmp(key, "req") == 0) { if (r->req_cleared || r->req_ref == NULL) return scr_dyn_new_null(); diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 9f91c8235..76fc7395b 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -6116,6 +6116,13 @@ void scr_http_res_write_dynv(ScrHttpRes *r, const ScrDyn *d /*borrowed*/); void scr_http_res_end_dynv(ScrHttpRes *r, const ScrDyn *d /*borrowed*/); bool scr_http_res_headers_sent(ScrHttpRes *r); bool scr_http_res_writable_ended(ScrHttpRes *r); +bool scr_http_res_send_date(ScrHttpRes *r); +void scr_http_res_set_send_date(ScrHttpRes *r, bool value); +bool scr_http_res_strict_content_length(ScrHttpRes *r); +void scr_http_res_set_strict_content_length(ScrHttpRes *r, bool value); +ScrHttpReq *scr_http_res_request(ScrHttpRes *r); /* +1 */ +ScrNetSocket *scr_http_res_socket(ScrHttpRes *r); /* +1 or NULL after finish */ +bool scr_http_res_writable_finished(ScrHttpRes *r); /* The res member surface: statusCode (200 until assigned; inert once the * head went out), statusMessage (the reason phrase — assigned value, or * the code's default), the header CRUD trio, and end(cb)'s finish slot @@ -6177,6 +6184,7 @@ double scr_http_res_writable_corked(ScrHttpRes *r); bool scr_http_res_destroyed_flag(ScrHttpRes *r); void scr_http_res_set_req(ScrHttpRes *r, ScrHttpReq *req /*borrowed, nullable*/); void scr_http_res_set_timeout(ScrHttpRes *r, double ms, ScrClosure *cb /*moves, nullable*/); +void scr_http_res_set_timeout_plain(ScrHttpRes *r, double ms); void scr_http_res_on_write_flush(ScrHttpRes *r, ScrClosure *cb /*moves*/); /* req.setEncoding(enc) — the socket twin's contract; may throw. */ void scr_http_req_set_encoding(ScrHttpReq *r, ScrStr *enc /*borrowed*/); diff --git a/tests/corpus/2957-http-static-response-controls.ts b/tests/corpus/2957-http-static-response-controls.ts new file mode 100644 index 000000000..9d6148f5a --- /dev/null +++ b/tests/corpus/2957-http-static-response-controls.ts @@ -0,0 +1,77 @@ +import { createServer, get } from "node:http"; + +const server = createServer((req, res) => { + console.log("paired", res.req.method, res.req.url); + console.log("defaults", res.sendDate, res.strictContentLength, res.statusMessage === undefined, res.writableFinished); + const socket = res.socket; + console.log("socket", socket !== null, res.connection !== null, socket !== null && socket.remoteAddress === req.socket.remoteAddress); + if (req.url === "/no-date") { + res.sendDate = false; + res.strictContentLength = true; + res.setHeader("Content-Length", "2"); + console.log("configured", res.sendDate, res.strictContentLength); + res.setTimeout(1000).end("ok", () => { + console.log("finished", res.statusMessage, res.writableFinished, res.socket === null, res.connection === null); + }); + console.log("after end", res.statusMessage, res.writableFinished, res.socket !== null); + return; + } + if (req.url === "/recover") { + res.strictContentLength = true; + res.setHeader("Content-Length", "2"); + try { + res.end("a"); + } catch (error) { + if (error instanceof Error) { + console.log("mismatch", (error as NodeJS.ErrnoException).code, error.message); + } + res.end("a"); + } + return; + } + if (req.url === "/overflow") { + res.strictContentLength = true; + res.setHeader("Content-Length", "2"); + res.write("a"); + try { + res.write("bc"); + } catch (error) { + if (error instanceof Error) { + console.log("overflow", (error as NodeJS.ErrnoException).code, error.message); + } + } + res.end("d"); + return; + } + if (req.url === "/unicode") { + res.strictContentLength = true; + res.setHeader("Content-Length", "3"); + res.end("é!"); + return; + } + res.setTimeout(25, () => { + console.log("response timeout"); + res.end("late"); + }); +}); + +server.listen(0, () => { + const port = server.address().port; + const paths = ["/no-date", "/recover", "/overflow", "/unicode", "/slow"]; + const visit = (index: number): void => { + if (index === paths.length) { + server.close(() => console.log("closed")); + return; + } + const path = paths[index]!; + get({ hostname: "127.0.0.1", port, path }, (response) => { + let body = ""; + response.on("data", (chunk: Buffer) => { body += chunk.toString("utf8"); }); + response.on("end", () => { + console.log("reply", path, response.statusCode, response.headers.date !== undefined, body); + visit(index + 1); + }); + }).on("error", (error) => console.log("client error", error.message)); + }; + visit(0); +});