diff --git a/apps/hono-bridge/server.ts b/apps/hono-bridge/server.ts index 2df7fef..918d330 100644 --- a/apps/hono-bridge/server.ts +++ b/apps/hono-bridge/server.ts @@ -23,7 +23,12 @@ app.post('/body/text', async (c) => { const server = createServer(async (req, res) => { const method = req.method ?? 'GET' - const init: RequestInit = { method, headers: req.headers } + const headers: Record = {} + for (const name in req.headers) { + const value = req.headers[name] + if (value !== undefined) headers[name] = value + } + const init: RequestInit = { method, headers } // `readBodyBytes` rather than `Buffer.from(...)`: a bare `Buffer` read in the // entry script is refused (the global is installed through the global object). if (method !== 'GET' && method !== 'HEAD') init.body = req.readBodyBytes() diff --git a/apps/nest-node-probe/correctness/dictionary-missing.ts b/apps/nest-node-probe/correctness/dictionary-missing.ts new file mode 100644 index 0000000..50f8541 --- /dev/null +++ b/apps/nest-node-probe/correctness/dictionary-missing.ts @@ -0,0 +1,37 @@ +interface StringMap { + [name: string]: string +} +interface OptionalStringMap { + [name: string]: string | undefined +} +const plain: StringMap = { a: 'x' } +const optional: OptionalStringMap = { a: 'x' } +const missing = plain['b'] +console.log('plain present', plain['a'] === 'x') +console.log('plain missing === undefined', missing === undefined) +console.log('plain missing typeof', typeof missing) +console.log('plain missing !== undefined', plain['b'] !== undefined) +console.log('plain in', 'b' in plain) +console.log('optional missing === undefined', optional['b'] === undefined) +console.log('optional present', optional['a'] === 'x') +console.log('keys', Object.keys(plain).join(',')) +console.log('nullish', plain['b'] ?? 'fallback') +console.log('falsy', plain['b'] ? 'truthy' : 'falsy') +const absent = optional['b'] +console.log('optional missing typeof', typeof absent) +console.log('optional missing !== undefined', optional['b'] !== undefined) +console.log('optional nullish', optional['b'] ?? 'fallback') +console.log('optional falsy', optional['b'] ? 'truthy' : 'falsy') +console.log('optional or', optional['b'] || 'or-fallback') +console.log('optional not', !optional['b']) +console.log('optional present nullish', optional['a'] ?? 'fallback') +console.log('optional present or', optional['a'] || 'or-fallback') +const guarded = optional['a'] +console.log('optional guarded length', guarded !== undefined ? guarded.length : -1) +function describe(value: string | undefined): string { + return value === undefined ? 'none' : `some(${value})` +} +console.log('optional passed', describe(optional['a']), describe(optional['b'])) +console.log('optional keys', Object.keys(optional).join(',')) +optional['c'] = undefined +console.log('optional set undefined in', 'c' in optional, Object.keys(optional).join(',')) diff --git a/apps/nest-node-probe/correctness/http-server-options.ts b/apps/nest-node-probe/correctness/http-server-options.ts new file mode 100644 index 0000000..ef14dae --- /dev/null +++ b/apps/nest-node-probe/correctness/http-server-options.ts @@ -0,0 +1,38 @@ +import { createServer, IncomingMessage, ServerResponse } from 'node:http' +import { env } from 'node:process' + +class AppRequest extends IncomingMessage { + params: { [name: string]: string } = {} + + get path(): string { + const url = this.url ?? '/' + const query = url.indexOf('?') + return query === -1 ? url : url.slice(0, query) + } +} + +class AppResponse extends ServerResponse { + sendText(text: string): void { + this.setHeader('content-type', 'text/plain; charset=utf-8') + this.end(text) + } +} + +const server = createServer({ IncomingMessage: AppRequest, ServerResponse: AppResponse }, (incoming: IncomingMessage, outgoing: ServerResponse) => { + const req = incoming as AppRequest + const res = outgoing as AppResponse + req.params = { path: req.path } + const lines = [ + `request ${req instanceof AppRequest} ${req instanceof IncomingMessage}`, + `response ${res instanceof AppResponse} ${res instanceof ServerResponse}`, + `req ${res.req === req} ${res.req.params.path}`, + `method ${req.method} url ${req.url ?? ''} path ${req.path}`, + `headersSent ${res.headersSent}` + ] + res.sendText(lines.join('\n') + '\n') +}) + +const PORT = Number(env.PORT ?? '3316') +server.listen(PORT, () => { + console.log(`http-server-options listening on http://127.0.0.1:${PORT}`) +}) diff --git a/apps/nest-node-probe/correctness/url-can-parse.ts b/apps/nest-node-probe/correctness/url-can-parse.ts new file mode 100644 index 0000000..cc194da --- /dev/null +++ b/apps/nest-node-probe/correctness/url-can-parse.ts @@ -0,0 +1,94 @@ +const single: string[] = [ + 'http://example.com', + 'https://example.com/a/b?c#d', + 'HTTP://EXAMPLE.COM', + 'http://user:pw@host:8080/p', + 'http://[::1]:3000/', + 'http://[::1', + 'http://', + 'http:///path', + 'http:example.com', + 'http:/example.com', + 'http://exa mple.com', + 'http://a(connId: number, isHead: boolean, keepAliveRequested: boolean, http10: boolean, request: IncomingMessage): ServerResponse" ], "members": [ { diff --git a/runtime/node/globals.ts b/runtime/node/globals.ts index fa3dc47..fdb64a2 100644 --- a/runtime/node/globals.ts +++ b/runtime/node/globals.ts @@ -1126,6 +1126,29 @@ class URL { this.parse(full); } + static canParse(url: string | URL, base?: string | URL): boolean { + const text = trimUrlInput(typeof url === "string" ? url : url.href); + const scheme = urlSchemeOf(text); + if (scheme.length === 0) { + if (base === undefined) return false; + const baseText = trimUrlInput(typeof base === "string" ? base : base.href); + const baseScheme = urlSchemeOf(baseText); + if (!URL.canParse(baseText)) return false; + const baseIsSpecial = isSpecialUrlScheme(baseScheme); + if (!baseIsSpecial && baseText.charAt(baseScheme.length) !== "/") return text.charAt(0) === "#"; + if (startsWithUrlAuthority(text, baseIsSpecial)) + return !baseIsSpecial || canParseSpecialUrlAuthority(urlAuthorityOf(text.slice(2)), baseScheme === "file:"); + return true; + } + if (base !== undefined && !URL.canParse(base)) return false; + if (!isSpecialUrlScheme(scheme)) return true; + let rest = text.slice(scheme.length); + let slashes = 0; + while (slashes < rest.length && (rest.charAt(slashes) === "/" || rest.charAt(slashes) === "\\")) slashes++; + rest = rest.slice(slashes); + return canParseSpecialUrlAuthority(urlAuthorityOf(rest), scheme === "file:"); + } + private parse(full: string): void { // Minimal parse: scheme://host[:port]/path?search#hash this.protocol = ""; @@ -1408,6 +1431,171 @@ function isSpecialUrlScheme(protocol: string): boolean { ); } +function trimUrlInput(input: string): string { + let start = 0; + let end = input.length; + while (start < end && input.charCodeAt(start) <= 0x20) start++; + while (end > start && input.charCodeAt(end - 1) <= 0x20) end--; + let text = input.slice(start, end); + if (text.indexOf("\t") >= 0 || text.indexOf("\n") >= 0 || text.indexOf("\r") >= 0) + text = text.split("\t").join("").split("\n").join("").split("\r").join(""); + return text; +} + +function urlSchemeOf(text: string): string { + const first = text.charCodeAt(0); + const isLetter = (code: number): boolean => (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a); + if (text.length === 0 || !isLetter(first)) return ""; + for (let i = 1; i < text.length; i++) { + const code = text.charCodeAt(i); + if (code === 0x3a) return text.slice(0, i + 1).toLowerCase(); + const isDigit = code >= 0x30 && code <= 0x39; + if (!isLetter(code) && !isDigit && code !== 0x2b && code !== 0x2d && code !== 0x2e) return ""; + } + return ""; +} + +function startsWithUrlAuthority(text: string, special: boolean): boolean { + if (text.length < 2) return false; + const isSlash = (ch: string): boolean => ch === "/" || (special && ch === "\\"); + return isSlash(text.charAt(0)) && isSlash(text.charAt(1)); +} + +function urlAuthorityOf(rest: string): string { + for (let i = 0; i < rest.length; i++) { + const ch = rest.charAt(i); + if (ch === "/" || ch === "\\" || ch === "?" || ch === "#") return rest.slice(0, i); + } + return rest; +} + +function canParseSpecialUrlAuthority(authority: string, isFile: boolean): boolean { + const at = authority.lastIndexOf("@"); + if (isFile && at >= 0) return false; + const hostPort = at >= 0 ? authority.slice(at + 1) : authority; + if (hostPort.length === 0) return isFile; + let host = hostPort; + let port = ""; + if (hostPort.charAt(0) === "[") { + const close = hostPort.indexOf("]"); + if (close < 0) return false; + host = hostPort.slice(1, close); + const after = hostPort.slice(close + 1); + if (after.length > 0) { + if (after.charAt(0) !== ":") return false; + port = after.slice(1); + } + if (!isValidIPv6UrlHost(host)) return false; + } else { + const colon = hostPort.lastIndexOf(":"); + if (colon >= 0) { + host = hostPort.slice(0, colon); + port = hostPort.slice(colon + 1); + } + if (host.length === 0 || !isValidUrlDomain(host)) return false; + } + if (isFile) return port.length === 0; + return isValidUrlPort(port); +} + +function isValidIPv6UrlHost(inner: string): boolean { + if (inner.indexOf(":") < 0) return false; + for (let i = 0; i < inner.length; i++) { + const code = inner.charCodeAt(i); + const isHex = (code >= 0x30 && code <= 0x39) || (code >= 0x41 && code <= 0x46) || (code >= 0x61 && code <= 0x66); + if (!isHex && code !== 0x3a && code !== 0x2e) return false; + } + return true; +} + +function isValidUrlPort(port: string): boolean { + if (port.length === 0) return true; + for (let i = 0; i < port.length; i++) { + const code = port.charCodeAt(i); + if (code < 0x30 || code > 0x39) return false; + } + return Number(port) <= 65535; +} + +function isValidUrlDomain(host: string): boolean { + let decoded = ""; + for (let i = 0; i < host.length; i++) { + const ch = host.charAt(i); + if (ch === "%" && isHexPair(host, i + 1)) { + decoded += String.fromCharCode(parseInt(host.slice(i + 1, i + 3), 16)); + i += 2; + } else { + decoded += ch; + } + } + for (let i = 0; i < decoded.length; i++) { + const code = decoded.charCodeAt(i); + if (code <= 0x20 || code === 0x7f) return false; + const ch = decoded.charAt(i); + if ( + ch === "#" || ch === "%" || ch === "/" || ch === ":" || ch === "<" || ch === ">" || ch === "?" || + ch === "@" || ch === "[" || ch === "\\" || ch === "]" || ch === "^" || ch === "|" + ) + return false; + } + const labels = decoded.split("."); + if (labels.length > 1 && labels[labels.length - 1] === "") labels.pop(); + const last = labels[labels.length - 1]; + if (!isUrlIPv4NumberText(last)) return true; + if (labels.length > 4) return false; + let limit = 256; + for (let i = labels.length; i < 4; i++) limit *= 256; + for (let i = 0; i < labels.length; i++) { + const value = urlIPv4NumberOf(labels[i]); + if (value < 0) return false; + if (i < labels.length - 1 ? value > 255 : value >= limit) return false; + } + return true; +} + +function isHexPair(text: string, at: number): boolean { + if (at + 1 >= text.length) return false; + for (let i = at; i < at + 2; i++) { + const code = text.charCodeAt(i); + const isHex = (code >= 0x30 && code <= 0x39) || (code >= 0x41 && code <= 0x46) || (code >= 0x61 && code <= 0x66); + if (!isHex) return false; + } + return true; +} + +function isUrlIPv4NumberText(label: string): boolean { + if (label.length === 0) return false; + const hex = label.length >= 2 && label.charAt(0) === "0" && (label.charAt(1) === "x" || label.charAt(1) === "X"); + for (let i = hex ? 2 : 0; i < label.length; i++) { + const code = label.charCodeAt(i); + const isDigit = code >= 0x30 && code <= 0x39; + const isHexLetter = (code >= 0x41 && code <= 0x46) || (code >= 0x61 && code <= 0x66); + if (!isDigit && !(hex && isHexLetter)) return false; + } + return true; +} + +function urlIPv4NumberOf(label: string): number { + if (label.length === 0) return -1; + let digits = label; + let radix = 10; + if (digits.length >= 2 && digits.charAt(0) === "0" && (digits.charAt(1) === "x" || digits.charAt(1) === "X")) { + digits = digits.slice(2); + radix = 16; + } else if (digits.length >= 2 && digits.charAt(0) === "0") { + digits = digits.slice(1); + radix = 8; + } + if (digits.length === 0) return 0; + let value = 0; + for (let i = 0; i < digits.length; i++) { + const digit = parseInt(digits.charAt(i), radix); + if (Number.isNaN(digit)) return -1; + value = value * radix + digit; + } + return value; +} + // WHATWG URL's path state for a special scheme: a `.` segment (or `%2e`) is // dropped and a `..` one removes the segment before it, never above the root; // either one LAST leaves an empty segment behind, so `/x/..` is `/` and `/x/.` diff --git a/runtime/node/http.ts b/runtime/node/http.ts index dff22cf..88ee3ea 100644 --- a/runtime/node/http.ts +++ b/runtime/node/http.ts @@ -247,9 +247,14 @@ export function statusText(code: number): string { // Node joins duplicate request headers per header semantics: singleton headers // keep the FIRST value, `cookie` joins with "; ", everything else with ", ". -function addIncomingHeader(headers: { [name: string]: string }, lower: string, value: string): void { - if (!headers.hasOwnProperty(lower)) headers[lower] = value - else if (!isSingletonHeader(lower)) headers[lower] = headers[lower] + (lower === 'cookie' ? '; ' : ', ') + value +function addIncomingHeader(headers: IncomingHttpHeaders, lower: string, value: string): void { + const existing = headers[lower] + if (existing === undefined) headers[lower] = value + else if (!isSingletonHeader(lower)) headers[lower] = existing + (lower === 'cookie' ? '; ' : ', ') + value +} + +export interface IncomingHttpHeaders { + [name: string]: string | undefined } function isSingletonHeader(lower: string): boolean { @@ -363,12 +368,6 @@ function connectionSocket(connId: number): Socket { return socket } -// Shared placeholder: ServerResponse's socket slot is overwritten with the -// request's socket right after construction (Node shares ONE socket object -// between req and res), so constructing a fresh Socket per response would be -// a wasted allocation. -const placeholderSocket = new Socket(0) - // Body/lifecycle listeners are stored as TYPED native closures β€” a // `(chunk: string) => void` slot covers 'data' (one string arg) and the // no-arg 'end'/'close' listeners (an arrow with fewer params adapts, exactly @@ -432,7 +431,7 @@ export class IncomingMessage extends Readable { private connId_: number private socket_: Socket | undefined - private headersMap_: { [name: string]: string } | undefined + private headersMap_: IncomingHttpHeaders | undefined private rawHead_: string private rawHeaders_: string[] | undefined // Dedupes the deferred-resume microtask newListenerAdded schedules below -- 'data' and 'end' @@ -514,7 +513,7 @@ export class IncomingMessage extends Readable { // Lazy joined headers β€” built on first access so requests that never read // `headers` (the common hot path) pay nothing. - get headers(): { [name: string]: string } { + get headers(): IncomingHttpHeaders { let headers = this.headersMap_ if (headers === undefined) { headers = {} @@ -645,14 +644,14 @@ export type ResponseListener = () => void // rewrite of code with a byte-for-byte parity requirement, for no behavioural gain -- Writable's // own `_write`/`_writev`/`_final`/`_destroy` defaults are simply never called here. `destroyed`, // `closed` and `errored` are Writable's real fields now (no more private `destroyed_` shadow). -export class ServerResponse extends Writable { +export class ServerResponse extends Writable { statusCode: number statusMessage: string sendDate: boolean finished: boolean private connId_: number - private request_: IncomingMessage | undefined + private request_: IncomingMessage private isHead_: boolean private http10_: boolean private keepAliveRequested_: boolean @@ -701,8 +700,12 @@ export class ServerResponse extends Writable { this.extraHeaderRawNames_ = undefined } + get req(): Request { + return this.request_ as Request + } + get socket(): NetSocket { - return this.request_ === undefined ? placeholderSocket : this.request_.socket + return this.request_.socket } get headersSent(): boolean { @@ -819,7 +822,7 @@ export class ServerResponse extends Writable { } // Node-compatible surface: accepts a string, number, or array of strings. - setHeader(name: string, value: OutgoingHttpHeader): ServerResponse { + setHeader(name: string, value: OutgoingHttpHeader): this { if (Array.isArray(value)) { // Bind the narrowed array into its own `readonly string[]`-declared // local. The manifest deliberately carries no computed `get` recipe @@ -837,7 +840,7 @@ export class ServerResponse extends Writable { return this } - appendHeader(name: string, value: OutgoingHttpHeader): ServerResponse { + appendHeader(name: string, value: OutgoingHttpHeader): this { if (Array.isArray(value)) { // Same narrowing local as setHeader above, for the same reason: `value[i]` // on the mixed array/primitive union has no manifest recipe. @@ -990,7 +993,7 @@ export class ServerResponse extends Writable { if (suffix.length > 0) __gea_http_body(this.connId_, suffix) } - writeHead(status: number, headers: OutgoingHttpHeaders = {}): ServerResponse { + writeHead(status: number, headers: OutgoingHttpHeaders = {}): this { this.statusCode = status if (!this.headersCommitted_ && !this.firstHeaderPresent_) { // Nothing was staged with `setHeader`, so there is nothing for these @@ -1150,26 +1153,35 @@ export type ConnectionListener = (socket: NetSocket) => void type ServerListener = EventHandler | RequestListener | ConnectionListener /** - * `node:http`'s server-construction options -- deliberately EMPTY. + * `node:http`'s server-construction options: the two this target honours. + * + * `IncomingMessage` and `ServerResponse` are the classes the server + * constructs for each request, as in Node: a framework hands its own + * subclasses (`@nestjs/platform-node`'s `NodeRequest`/`NodeResponse`, Express + * reparents prototypes instead) so that the objects its handlers receive carry + * its members from construction. Either defaults to this module's class. The + * subclass is constructed with the base class's own constructor arguments, + * which is what a subclass declaring no constructor inherits; `typeof + * IncomingMessage`/`typeof ServerResponse` state exactly that. * - * Node's own `ServerOptions` carries fourteen members (`maxHeaderSize`, - * `keepAlive`, `keepAliveInitialDelay`, `insecureHTTPParser`, - * `joinDuplicateHeaders`, `IncomingMessage`, `ServerResponse`, ...) and this - * target honors none of them: the reactor behind `__gea_http_serve` has fixed - * parsing, fixed keep-alive behaviour, and constructs its own - * `IncomingMessage`/`ServerResponse`. Declaring those members and ignoring - * them is the failure this runtime avoids everywhere else -- the program would - * compile a setting it silently does not get. Left empty, an object literal - * that sets one is an excess-property error at the call site, naming the - * member: the same fail-closed answer, in the checker instead of in - * production. + * Node's other twelve members (`maxHeaderSize`, `keepAlive`, + * `keepAliveInitialDelay`, `insecureHTTPParser`, `joinDuplicateHeaders`, ...) + * are deliberately ABSENT: the reactor behind `__gea_http_serve` has fixed + * parsing and fixed keep-alive behaviour. Declaring them and ignoring them is + * the failure this runtime avoids everywhere else -- the program would compile + * a setting it silently does not get. Left out, an object literal that sets + * one is an excess-property error at the call site, naming the member: the + * same fail-closed answer, in the checker instead of in production. * * The name itself has to exist regardless of what is in it: * an HTTP framework's Node server adapter imports it (`ServerOptions as * HttpServerOptions`) to type its own `serverOptions?` field, so an absent * export is a `TS2614` before any option is ever set. */ -export interface ServerOptions {} +export interface ServerOptions { + IncomingMessage?: typeof IncomingMessage + ServerResponse?: typeof ServerResponse +} // `connection`/`clientError` carry `NetSocket`, not this file's own `Socket` // subclass: real Node types both as `net.Socket`, and `IncomingMessage.socket` @@ -1314,7 +1326,7 @@ export class Server extends EventEmitter { override listenerCount(name: EventName, listener?: unknown): number { const base = super.listenerCount(name, listener) if (listener !== undefined) return base - if (name === 'request') return base + this.requestListeners_.length + 1 + if (name === 'request') return base + this.requestListeners_.length + (this.requestListener_ === undefined ? 0 : 1) if (name === 'connection') return base + this.connectionListeners_.length return base } @@ -1324,13 +1336,21 @@ export class Server extends EventEmitter { private requestOnce_: boolean[] private connectionListeners_: ConnectionListener[] private connectionOnce_: boolean[] - private requestListener_: RequestListener + private requestListener_: RequestListener | undefined private port_: number + private incomingMessageClass_: typeof IncomingMessage + private serverResponseClass_: typeof ServerResponse | undefined - constructor(requestListener: RequestListener) { + constructor(optionsOrListener?: ServerOptions | RequestListener, requestListener?: RequestListener) { super() + let options: ServerOptions | undefined = undefined + let listener = requestListener + if (typeof optionsOrListener === 'function') listener = optionsOrListener + else options = optionsOrListener this.listening = false - this.requestListener_ = requestListener + this.requestListener_ = listener + this.incomingMessageClass_ = options === undefined || options.IncomingMessage === undefined ? IncomingMessage : options.IncomingMessage + this.serverResponseClass_ = options === undefined ? undefined : options.ServerResponse this.requestListeners_ = [] this.requestOnce_ = [] this.connectionListeners_ = [] @@ -1362,7 +1382,8 @@ export class Server extends EventEmitter { // reactor stores it by value; shared_from_this would dangle). The arrays // are captured by reference, so listeners registered after `listen()` are // still seen. Every listener below is called through its own typed - // carrier -- nothing here boxes `req`, `res` or the socket. + const requestClass = this.incomingMessageClass_ + const responseClass = this.serverResponseClass_ const listener = this.requestListener_ const extraListeners = this.requestListeners_ const extraOnce = this.requestOnce_ @@ -1372,8 +1393,11 @@ export class Server extends EventEmitter { port, (connId: number, flags: number, method: string, url: string, httpVersion: string, rawHead: string, body: string): void => { const keepAlive = (flags & FLAG_KEEP_ALIVE) !== 0 - const req = new IncomingMessage(connId, method, url, httpVersion, rawHead, body) - const res = new ServerResponse(connId, method === 'HEAD', keepAlive, httpVersion === 'HTTP/1.0', req) + const req = new requestClass(connId, method, url, httpVersion, rawHead, body) + const res = + responseClass === undefined + ? new ServerResponse(connId, method === 'HEAD', keepAlive, httpVersion === 'HTTP/1.0', req) + : new responseClass(connId, method === 'HEAD', keepAlive, httpVersion === 'HTTP/1.0', req) if ((flags & FLAG_FIRST_ON_CONNECTION) !== 0 && connectionListeners.length > 0) { // `req.socket` is built lazily (its object bridge is the cost the // old listener-count gate existed to avoid); read once, here, only @@ -1389,7 +1413,7 @@ export class Server extends EventEmitter { onConnection(socket) } } - listener(req, res) + if (listener !== undefined) listener(req, res) for (let i = 0; i < extraListeners.length; i++) { const extra = extraListeners[i] if (extraOnce[i]) { @@ -1458,9 +1482,8 @@ export function get( // need an adapter into a callable (there is none) and whose generic // fallbacks would box `req`/`res` (see `Server`'s registration methods). /** @gea-exact-arms */ -export function createServer(optionsOrListener: ServerOptions | RequestListener, requestListener?: RequestListener): Server { - if (requestListener !== undefined) return new Server(requestListener) - return new Server(optionsOrListener as RequestListener) +export function createServer(optionsOrListener?: ServerOptions | RequestListener, requestListener?: RequestListener): Server { + return new Server(optionsOrListener, requestListener) } // A CommonJS-style default namespace: every member here is already a diff --git a/runtime/node/net.ts b/runtime/node/net.ts index 5f64e07..5246fc3 100644 --- a/runtime/node/net.ts +++ b/runtime/node/net.ts @@ -516,6 +516,8 @@ export class Socket extends EventEmitter { bytesWritten: number timeout: number readyState: string + readable: boolean + writable: boolean remoteAddress: string remotePort: number remoteFamily: string @@ -562,6 +564,8 @@ export class Socket extends EventEmitter { this.bytesWritten = 0 this.timeout = 0 this.readyState = 'opening' + this.readable = false + this.writable = false this.remoteAddress = '' this.remotePort = 0 this.remoteFamily = '' @@ -779,6 +783,7 @@ export class Socket extends EventEmitter { return this } this.readyState = 'readOnly' + this.writable = false __gea_node_net_end(this.nativeId_) return this } @@ -845,6 +850,8 @@ export class Socket extends EventEmitter { this.destroyed = false this.pending = false this.readyState = 'open' + this.readable = true + this.writable = true this.remoteAddress = __gea_node_net_remote_address(nativeId) this.remotePort = __gea_node_net_remote_port(nativeId) this.remoteFamily = __gea_node_net_remote_family(nativeId) @@ -911,6 +918,8 @@ export class Socket extends EventEmitter { this.connecting = false this.pending = false this.readyState = 'closed' + this.readable = false + this.writable = false this.clearSocketTimeout() this.bufferSize = 0 this.abandonAttempts() @@ -1011,6 +1020,8 @@ export class Socket extends EventEmitter { this.pending = false this.destroyed = true this.readyState = 'closed' + this.readable = false + this.writable = false this.emitClose() } else if (event === EVENT_DRAIN) { this.bufferSize = __gea_node_net_buffer_size(this.nativeId_) @@ -1023,6 +1034,7 @@ export class Socket extends EventEmitter { if (this.destroyAfterFinish_) this.destroy() } else if (event === EVENT_END) { if (this.readyState !== 'readOnly') this.readyState = 'writeOnly' + this.readable = false this.emit('end') if (!this.destroyed && this.readyState === 'writeOnly') this.end() } @@ -1033,6 +1045,8 @@ export class Socket extends EventEmitter { this.connecting = false this.pending = false this.readyState = 'open' + this.readable = true + this.writable = !this.connectingEnd_ this.remoteAddress = __gea_node_net_remote_address(this.nativeId_) this.remotePort = __gea_node_net_remote_port(this.nativeId_) this.remoteFamily = __gea_node_net_remote_family(this.nativeId_) @@ -1368,6 +1382,7 @@ export class Socket extends EventEmitter { if (this.connectingEnd_ && !this.destroyed) { this.connectingEnd_ = false this.readyState = 'readOnly' + this.writable = false __gea_node_net_end(this.nativeId_) } if (!this.destroyed) this.dispatchNativeEvents() diff --git a/scripts/test-dictionary-missing.mjs b/scripts/test-dictionary-missing.mjs new file mode 100644 index 0000000..0da33df --- /dev/null +++ b/scripts/test-dictionary-missing.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict' +import { execFileSync, spawnSync } from 'node:child_process' +import { mkdirSync } from 'node:fs' +import { resolve } from 'node:path' + +const root = resolve(import.meta.dirname, '..') +const output = resolve(root, 'apps/nest-node-probe/dist') +const source = resolve(root, 'apps/nest-node-probe/correctness/dictionary-missing.ts') +const executable = resolve(output, 'dictionary-missing') +const options = { cwd: root, encoding: 'utf8', env: { ...process.env, TMPDIR: output } } +mkdirSync(output, { recursive: true }) + +execFileSync(process.execPath, [resolve(root, 'scripts/build.mjs'), source, '--out', output, '--exe', executable], { + ...options, + stdio: 'inherit' +}) +const expected = spawnSync(process.execPath, [source], { ...options, timeout: 15000 }) +const actual = spawnSync(executable, [], { ...options, timeout: 15000 }) +assert.equal(expected.status, 0, `node status\n${expected.stderr}`) +assert.equal(actual.status, 0, `compiled status\n${actual.stderr}`) +const expectedLines = expected.stdout.trim().split('\n') +const actualLines = actual.stdout.trim().split('\n') +const deviating = new Set(['plain missing typeof string', 'nullish ']) +assert.equal(actualLines.length, expectedLines.length, `line count\n--- node\n${expected.stdout}\n--- compiled\n${actual.stdout}`) +let same = 0 +for (let i = 0; i < expectedLines.length; i++) { + if (deviating.has(actualLines[i])) continue + assert.equal(actualLines[i], expectedLines[i], `line ${i + 1}`) + same++ +} +assert.ok(expectedLines.includes('optional passed some(x) none'), 'the oracle covered the optional map') +console.log(`DICTIONARY_MISSING_PARITY_OK:${same}`) diff --git a/scripts/test-http-server-options.mjs b/scripts/test-http-server-options.mjs new file mode 100644 index 0000000..cc56e4e --- /dev/null +++ b/scripts/test-http-server-options.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict' +import { execFileSync, spawn } from 'node:child_process' +import { once } from 'node:events' +import { mkdirSync } from 'node:fs' +import http from 'node:http' +import { resolve } from 'node:path' + +const root = resolve(import.meta.dirname, '..') +const output = resolve(root, 'apps/nest-node-probe/dist') +const source = resolve(root, 'apps/nest-node-probe/correctness/http-server-options.ts') +const executable = resolve(output, 'http-server-options') +mkdirSync(output, { recursive: true }) +execFileSync(process.execPath, [resolve(root, 'scripts/build.mjs'), source, '--out', output, '--exe', executable], { + cwd: root, + env: { ...process.env, TMPDIR: output }, + stdio: ['ignore', 'ignore', 'inherit'] +}) + +const get = (port, path) => + new Promise((accept, reject) => { + const req = http.request({ hostname: '127.0.0.1', port, path, method: path === '/head' ? 'HEAD' : 'GET', agent: false, timeout: 5000 }, (res) => { + let body = '' + res.setEncoding('utf8') + res.on('data', (chunk) => (body += chunk)) + res.on('end', () => accept({ status: res.statusCode, type: res.headers['content-type'], body })) + }) + req.on('timeout', () => req.destroy(new Error(`${path}: no response in 5s`))) + req.on('error', reject) + req.end() + }) + +const paths = ['/', '/users/7?x=1&y=2', '/a%20b/../c', '/head', '/deep/path/with?query=only'] +const serve = async (command, args, port) => { + const child = spawn(command, args, { cwd: root, env: { ...process.env, PORT: String(port) }, stdio: ['ignore', 'pipe', 'inherit'] }) + const exited = once(child, 'exit') + await new Promise((accept, reject) => { + child.stdout.on('data', (chunk) => String(chunk).includes('listening') && accept()) + child.on('exit', (code) => reject(new Error(`${command} exited with ${code} before listening`))) + }) + try { + const answers = [] + for (const path of paths) answers.push(await get(port, path)) + return answers + } finally { + child.kill() + await exited + } +} + +const expected = await serve(process.execPath, [source], 3316) +const actual = await serve(executable, [], 3317) +for (const [i, path] of paths.entries()) { + assert.deepEqual(actual[i], expected[i], `${path}\n--- node\n${JSON.stringify(expected[i])}\n--- compiled\n${JSON.stringify(actual[i])}`) +} +assert.match(expected[0].body, /^request true true\nresponse true true\nreq true \/\n/) +console.log(`HTTP_SERVER_OPTIONS_PARITY_OK:${paths.length}`) diff --git a/scripts/test-url-can-parse.mjs b/scripts/test-url-can-parse.mjs new file mode 100644 index 0000000..9c780f7 --- /dev/null +++ b/scripts/test-url-can-parse.mjs @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { mkdirSync } from 'node:fs' +import { resolve } from 'node:path' + +const root = resolve(import.meta.dirname, '..') +const output = resolve(root, 'apps/nest-node-probe/dist') +const source = resolve(root, 'apps/nest-node-probe/correctness/url-can-parse.ts') +const executable = resolve(output, 'url-can-parse') +const options = { cwd: root, encoding: 'utf8', env: { ...process.env, TMPDIR: output } } +mkdirSync(output, { recursive: true }) + +execFileSync(process.execPath, [resolve(root, 'scripts/build.mjs'), source, '--out', output, '--exe', executable], { + ...options, + stdio: 'inherit' +}) +const expected = execFileSync(process.execPath, [source], options) +const actual = execFileSync(executable, [], { ...options, timeout: 15000 }) + +const expectedLines = expected.trim().split('\n') +const actualLines = actual.trim().split('\n') +assert.equal(actualLines.length, expectedLines.length, `line count\n--- node\n${expected}\n--- compiled\n${actual}`) +for (let i = 0; i < expectedLines.length; i++) assert.equal(actualLines[i], expectedLines[i], `line ${i + 1}`) +assert.ok(expectedLines.length >= 80, 'the probe printed the whole table') +console.log(`URL_CAN_PARSE_PARITY_OK:${expectedLines.length}`)