diff --git a/src/js/internal/test/binding.ts b/src/js/internal/test/binding.ts index 589fec5bad0b..e4bc37f5229f 100644 --- a/src/js/internal/test/binding.ts +++ b/src/js/internal/test/binding.ts @@ -21,6 +21,11 @@ function internalBinding(name: string) { TRACE_EVENT_PHASE_NESTABLE_ASYNC_END: 101, }, }; + // The slot node:http2 populates for the --expose-internals tests + // (constants, nghttp2ErrorString, optionsBuffer). Required lazily so + // unrelated internalBinding() callers don't pay for node:http2. + case "http2": + return require("node:http2")[Symbol.for("::bunhttp2internals::")].binding; default: throw new Error(`internalBinding("${name}") is not implemented in Bun`); } diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index 5b2f2b522974..862994e22af7 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -588,15 +588,18 @@ function assertValidHeader(name, value) { connectionHeaderMessageWarn(); } } -function assertIsObject(value: any, name: string, types?: string): asserts value is object { +// Node's ERR_INVALID_ARG_TYPE normalizes a string `expected` to a one-element +// list; Bun's single-string $ERR_INVALID_ARG_TYPE overload renders its input +// literally, so pass a list here to get Node's type classification. +function assertIsObject(value: any, name: string, types?: string | string[]): asserts value is object { if (value !== undefined && (!$isObject(value) || $isArray(value))) { - throw $ERR_INVALID_ARG_TYPE(name, types || "object", value); + throw $ERR_INVALID_ARG_TYPE(name, $isArray(types) ? types : [types || "Object"], value); } } -function assertIsArray(value: any, name: string, types?: string): asserts value is any[] { +function assertIsArray(value: any, name: string, types?: string | string[]): asserts value is any[] { if (value !== undefined && !$isArray(value)) { - throw $ERR_INVALID_ARG_TYPE(name, types || "Array", value); + throw $ERR_INVALID_ARG_TYPE(name, $isArray(types) ? types : [types || "Array"], value); } } hideFromStack(assertIsObject); @@ -1410,6 +1413,8 @@ const nameForErrorCode = [ ]; const constants = { NGHTTP2_ERR_FRAME_SIZE_ERROR: -522, + NGHTTP2_NV_FLAG_NONE: 0, + NGHTTP2_NV_FLAG_NO_INDEX: 1, NGHTTP2_SESSION_SERVER: 0, NGHTTP2_SESSION_CLIENT: 1, NGHTTP2_STREAM_STATE_IDLE: 1, @@ -1956,8 +1961,298 @@ function assertValidPseudoHeader(key) { } hideFromStack(assertValidPseudoHeader); +function assertValidPseudoHeaderResponse(key) { + if (key !== ":status") { + throw $ERR_HTTP2_INVALID_PSEUDOHEADER(key); + } +} +hideFromStack(assertValidPseudoHeaderResponse); + +function assertValidPseudoHeaderTrailer(key) { + throw $ERR_HTTP2_INVALID_PSEUDOHEADER(key); +} +hideFromStack(assertValidPseudoHeaderTrailer); + +function assertWithinRange(name, value, min = 0, max = Infinity) { + if (value !== undefined && (typeof value !== "number" || value < min || value > max)) { + throw $ERR_HTTP2_INVALID_SETTING_VALUE_RangeError(`Invalid value for setting "${name}": ${value}`); + } +} +hideFromStack(assertWithinRange); + +function isIllegalConnectionSpecificHeader(name, value) { + switch (name) { + case HTTP2_HEADER_CONNECTION: + case HTTP2_HEADER_UPGRADE: + case HTTP2_HEADER_HTTP2_SETTINGS: + case HTTP2_HEADER_KEEP_ALIVE: + case HTTP2_HEADER_PROXY_CONNECTION: + case HTTP2_HEADER_TRANSFER_ENCODING: + return true; + case HTTP2_HEADER_TE: + return value !== "trailers"; + default: + return false; + } +} + +function sessionName(type) { + switch (type) { + case NGHTTP2_SESSION_CLIENT: + return "client"; + case NGHTTP2_SESSION_SERVER: + return "server"; + default: + return ""; + } +} + +// nghttp2 library error codes. Transcribed from nghttp2_strerror() +// (lib/nghttp2_helper.c) keyed by the integer values from +// lib/includes/nghttp2/nghttp2.h so NghttpError gives the same +// messages as Node. +const kNghttp2ErrorMessages = { + "0": "Success", + "-501": "Invalid argument", + "-502": "Out of buffer space", + "-503": "Unsupported SPDY version", + "-504": "Operation would block", + "-505": "Protocol error", + "-506": "Invalid frame octets", + "-507": "EOF", + "-508": "Data transfer deferred", + "-509": "No more Stream ID available", + "-510": "Stream was already closed or invalid", + "-511": "Stream is closing", + "-512": "The transmission is not allowed for this stream", + "-513": "Stream ID is invalid", + "-514": "Invalid stream state", + "-515": "Another DATA frame has already been deferred", + "-516": "request HEADERS is not allowed", + "-517": "GOAWAY has already been sent", + "-518": "Invalid header block", + "-519": "Invalid state", + "-521": "The user callback function failed due to the temporal error", + "-522": "The length of the frame is invalid", + "-523": "Header compression/decompression error", + "-524": "Flow control error", + "-525": "Insufficient buffer size given to function", + "-526": "Callback was paused by the application", + "-527": "Too many inflight SETTINGS", + "-528": "Server push is disabled by peer", + "-529": "DATA or HEADERS frame has already been submitted for the stream", + "-530": "The current session is closing", + "-531": "Invalid HTTP header field was received", + "-532": "Violation in HTTP messaging rule", + "-533": "Stream was refused", + "-534": "Internal error", + "-535": "Cancel", + "-536": "When a local endpoint expects to receive SETTINGS frame, it receives an other type of frame", + "-537": "SETTINGS frame contained more than the maximum allowed entries", + "-901": "Out of memory", + "-902": "The user callback function failed", + "-903": "Received bad client magic byte string", + "-904": "Flooding was detected in this HTTP/2 session, and it must be closed", + "-905": "Too many CONTINUATION frames following a HEADER frame", +}; + +function nghttp2ErrorString(code) { + return kNghttp2ErrorMessages[`${code}`] ?? "Unknown error code"; +} + +class NghttpError extends Error { + code: string; + errno: number; + constructor(integerCode: number, customErrorCode?: string) { + super(customErrorCode ? String(customErrorCode) : nghttp2ErrorString(integerCode)); + this.code = customErrorCode || "ERR_HTTP2_ERROR"; + this.errno = integerCode; + } + + toString() { + return `${this.name} [${this.code}]: ${this.message}`; + } +} + +const NGHTTP2_NV_FLAG_NONE = 0; +const NGHTTP2_NV_FLAG_NO_INDEX = 1; +const kNeverIndexFlag = String.fromCharCode(NGHTTP2_NV_FLAG_NO_INDEX); +const kNoHeaderFlags = String.fromCharCode(NGHTTP2_NV_FLAG_NONE); +const emptyArray = []; + +// Builds an NgHeader string + header count value, validating the header key +// format, rejecting illegal header configurations, and marking sensitive +// headers that should not be indexed en route. Takes either a flat array of +// raw headers ([k1, v1, k2, v2]) or a header object ({ k1: v1, k2: [v2, v3] }). +function buildNgHeaderString( + arrayOrMap, + validatePseudoHeaderValue = assertValidPseudoHeader, + strictSingleValueFields = true, +) { + let headers = ""; + let pseudoHeaders = ""; + let count = 0; + + const singles = new SafeSet(); + const sensitive = arrayOrMap[sensitiveHeaders] || emptyArray; + const neverIndex = sensitive.map(v => v.toLowerCase()); + + function processHeader(key, value) { + key = key.toLowerCase(); + const isStrictSingleValueField = strictSingleValueFields && kSingleValueHeaders.has(key); + let isArray = ArrayIsArray(value); + if (isArray) { + switch (value.length) { + case 0: + return; + case 1: + value = String(value[0]); + isArray = false; + break; + default: + if (isStrictSingleValueField) { + throw $ERR_HTTP2_HEADER_SINGLE_VALUE(`Header field "${key}" must only have a single value`); + } + } + } else { + value = String(value); + } + if (isStrictSingleValueField) { + if (singles.has(key)) { + throw $ERR_HTTP2_HEADER_SINGLE_VALUE(`Header field "${key}" must only have a single value`); + } + singles.add(key); + } + const flags = neverIndex.includes(key) ? kNeverIndexFlag : kNoHeaderFlags; + if (key[0] === ":") { + const err = validatePseudoHeaderValue(key); + if (err !== undefined) throw err; + pseudoHeaders += `${key}\0${value}\0${flags}`; + count++; + return; + } + if (!checkIsHttpToken(key)) { + throw $ERR_INVALID_HTTP_TOKEN("Header name", key); + } + if (isIllegalConnectionSpecificHeader(key, value)) { + throw $ERR_HTTP2_INVALID_CONNECTION_HEADERS(`HTTP/1 Connection specific headers are forbidden: "${key}"`); + } + if (isArray) { + for (let j = 0; j < value.length; ++j) { + const val = String(value[j]); + headers += `${key}\0${val}\0${flags}`; + } + count += value.length; + return; + } + headers += `${key}\0${value}\0${flags}`; + count++; + } + + if (ArrayIsArray(arrayOrMap)) { + for (let i = 0; i < arrayOrMap.length; i += 2) { + const key = arrayOrMap[i]; + const value = arrayOrMap[i + 1]; + if (value === undefined || key === "") continue; + processHeader(key, value); + } + } else { + const keys = ObjectKeys(arrayOrMap); + for (let i = 0; i < keys.length; ++i) { + const key = keys[i]; + const value = arrayOrMap[key]; + if (value === undefined || key === "") continue; + processHeader(key, value); + } + } + + return [pseudoHeaders + headers, count]; +} + +// Bun does not use a native options buffer; this array mirrors the layout +// Node.js uses in `internalBinding('http2').optionsBuffer` so that +// `updateOptionsBuffer` and tests exercising it behave identically. +const IDX_OPTIONS_MAX_DEFLATE_DYNAMIC_TABLE_SIZE = 0; +const IDX_OPTIONS_MAX_RESERVED_REMOTE_STREAMS = 1; +const IDX_OPTIONS_MAX_SEND_HEADER_BLOCK_LENGTH = 2; +const IDX_OPTIONS_PEER_MAX_CONCURRENT_STREAMS = 3; +const IDX_OPTIONS_PADDING_STRATEGY = 4; +const IDX_OPTIONS_MAX_HEADER_LIST_PAIRS = 5; +const IDX_OPTIONS_MAX_OUTSTANDING_PINGS = 6; +const IDX_OPTIONS_MAX_OUTSTANDING_SETTINGS = 7; +const IDX_OPTIONS_MAX_SESSION_MEMORY = 8; +const IDX_OPTIONS_MAX_SETTINGS = 9; +const IDX_OPTIONS_STREAM_RESET_RATE = 10; +const IDX_OPTIONS_STREAM_RESET_BURST = 11; +const IDX_OPTIONS_STRICT_HTTP_FIELD_WHITESPACE_VALIDATION = 12; +const IDX_OPTIONS_FLAGS = 13; +const optionsBuffer = new Uint32Array(IDX_OPTIONS_FLAGS + 1); +const MathMax = Math.max; + +function updateOptionsBuffer(options) { + let flags = 0; + if (typeof options.maxDeflateDynamicTableSize === "number") { + flags |= 1 << IDX_OPTIONS_MAX_DEFLATE_DYNAMIC_TABLE_SIZE; + optionsBuffer[IDX_OPTIONS_MAX_DEFLATE_DYNAMIC_TABLE_SIZE] = options.maxDeflateDynamicTableSize; + } + if (typeof options.maxReservedRemoteStreams === "number") { + flags |= 1 << IDX_OPTIONS_MAX_RESERVED_REMOTE_STREAMS; + optionsBuffer[IDX_OPTIONS_MAX_RESERVED_REMOTE_STREAMS] = options.maxReservedRemoteStreams; + } + if (typeof options.maxSendHeaderBlockLength === "number") { + flags |= 1 << IDX_OPTIONS_MAX_SEND_HEADER_BLOCK_LENGTH; + optionsBuffer[IDX_OPTIONS_MAX_SEND_HEADER_BLOCK_LENGTH] = options.maxSendHeaderBlockLength; + } + if (typeof options.peerMaxConcurrentStreams === "number") { + flags |= 1 << IDX_OPTIONS_PEER_MAX_CONCURRENT_STREAMS; + optionsBuffer[IDX_OPTIONS_PEER_MAX_CONCURRENT_STREAMS] = options.peerMaxConcurrentStreams; + } + if (typeof options.paddingStrategy === "number") { + flags |= 1 << IDX_OPTIONS_PADDING_STRATEGY; + optionsBuffer[IDX_OPTIONS_PADDING_STRATEGY] = options.paddingStrategy; + } + if (typeof options.maxHeaderListPairs === "number") { + flags |= 1 << IDX_OPTIONS_MAX_HEADER_LIST_PAIRS; + optionsBuffer[IDX_OPTIONS_MAX_HEADER_LIST_PAIRS] = options.maxHeaderListPairs; + } + if (typeof options.maxOutstandingPings === "number") { + flags |= 1 << IDX_OPTIONS_MAX_OUTSTANDING_PINGS; + optionsBuffer[IDX_OPTIONS_MAX_OUTSTANDING_PINGS] = options.maxOutstandingPings; + } + if (typeof options.maxOutstandingSettings === "number") { + flags |= 1 << IDX_OPTIONS_MAX_OUTSTANDING_SETTINGS; + optionsBuffer[IDX_OPTIONS_MAX_OUTSTANDING_SETTINGS] = MathMax(1, options.maxOutstandingSettings); + } + if (typeof options.maxSessionMemory === "number") { + flags |= 1 << IDX_OPTIONS_MAX_SESSION_MEMORY; + optionsBuffer[IDX_OPTIONS_MAX_SESSION_MEMORY] = MathMax(1, options.maxSessionMemory); + } + if (typeof options.maxSettings === "number") { + flags |= 1 << IDX_OPTIONS_MAX_SETTINGS; + optionsBuffer[IDX_OPTIONS_MAX_SETTINGS] = MathMax(1, options.maxSettings); + } + if (typeof options.streamResetRate === "number") { + flags |= 1 << IDX_OPTIONS_STREAM_RESET_RATE; + optionsBuffer[IDX_OPTIONS_STREAM_RESET_RATE] = MathMax(1, options.streamResetRate); + } + if (typeof options.streamResetBurst === "number") { + flags |= 1 << IDX_OPTIONS_STREAM_RESET_BURST; + optionsBuffer[IDX_OPTIONS_STREAM_RESET_BURST] = MathMax(1, options.streamResetBurst); + } + if (typeof options.strictFieldWhitespaceValidation === "boolean") { + flags |= 1 << IDX_OPTIONS_STRICT_HTTP_FIELD_WHITESPACE_VALIDATION; + optionsBuffer[IDX_OPTIONS_STRICT_HTTP_FIELD_WHITESPACE_VALIDATION] = + options.strictFieldWhitespaceValidation === true ? 0 : 1; + } + optionsBuffer[IDX_OPTIONS_FLAGS] = flags; +} + const NoPayloadMethods = new Set([HTTP2_METHOD_DELETE, HTTP2_METHOD_GET, HTTP2_METHOD_HEAD]); +function isPayloadMeaningless(method) { + return NoPayloadMethods.has(method); +} + type Settings = { headerTableSize: number; enablePush: boolean; @@ -5894,7 +6189,35 @@ export default { ServerHttp2Stream, ClientHttp2Stream, }, - util: {}, + util: { + assertIsObject, + assertIsArray, + assertValidPseudoHeader, + assertValidPseudoHeaderResponse, + assertValidPseudoHeaderTrailer, + assertWithinRange, + buildNgHeaderString, + getAuthority, + isIllegalConnectionSpecificHeader, + isPayloadMeaningless, + kAuthority: Symbol("authority"), + kProtocol: Symbol("protocol"), + kProxySocket, + kRequest, + kSensitiveHeaders: sensitiveHeaders, + kSocket: bunHTTP2Socket, + MAX_ADDITIONAL_SETTINGS, + NghttpError, + sessionName, + toHeaderObject, + updateOptionsBuffer, + }, + // Exposed as internalBinding('http2') by the --expose-internals shim. + binding: { + constants, + nghttp2ErrorString, + optionsBuffer, + }, }, }; diff --git a/src/jsc/ErrorCode.rs b/src/jsc/ErrorCode.rs index 7e5d18b0b575..a8ecb4761e7a 100644 --- a/src/jsc/ErrorCode.rs +++ b/src/jsc/ErrorCode.rs @@ -717,9 +717,11 @@ impl ErrorCode { pub const TRACE_EVENTS_CATEGORY_REQUIRED: ErrorCode = ErrorCode(329); /// `ERR_TRACE_EVENTS_UNAVAILABLE` (instanceof Error) pub const TRACE_EVENTS_UNAVAILABLE: ErrorCode = ErrorCode(330); + /// `ERR_HTTP2_INVALID_CONNECTION_HEADERS` (instanceof TypeError) + pub const HTTP2_INVALID_CONNECTION_HEADERS: ErrorCode = ErrorCode(331); /// == C++ `NODE_ERROR_COUNT`. - pub const COUNT: u16 = 331; + pub const COUNT: u16 = 332; } // ────────────────────────────────────────────────────────────────────────── @@ -1442,6 +1444,7 @@ static CODE_STR: [&str; ErrorCode::COUNT as usize] = [ "ERR_INVALID_BUFFER_SIZE", "ERR_TRACE_EVENTS_CATEGORY_REQUIRED", "ERR_TRACE_EVENTS_UNAVAILABLE", + "ERR_HTTP2_INVALID_CONNECTION_HEADERS", ]; // ────────────────────────────────────────────────────────────────────────── diff --git a/src/jsc/bindings/ErrorCode.ts b/src/jsc/bindings/ErrorCode.ts index acd959d9bfc3..e5515a4a2eea 100644 --- a/src/jsc/bindings/ErrorCode.ts +++ b/src/jsc/bindings/ErrorCode.ts @@ -342,5 +342,6 @@ const errors: ErrorCodeMapping = [ ["ERR_INVALID_BUFFER_SIZE", RangeError], ["ERR_TRACE_EVENTS_CATEGORY_REQUIRED", TypeError], ["ERR_TRACE_EVENTS_UNAVAILABLE", Error], + ["ERR_HTTP2_INVALID_CONNECTION_HEADERS", TypeError], ]; export default errors; diff --git a/test/expectations.txt b/test/expectations.txt index 24e09b7de2ad..ee430066df06 100644 --- a/test/expectations.txt +++ b/test/expectations.txt @@ -163,18 +163,15 @@ test/js/node/test/parallel/test-http2-client-destroy.js [ FAIL ] # not yet passi test/js/node/test/parallel/test-http2-client-http1-server.js [ FAIL ] # not yet passing test/js/node/test/parallel/test-http2-client-onconnect-errors.js [ FAIL ] # not yet passing test/js/node/test/parallel/test-http2-client-set-priority.js [ FAIL ] # not yet passing -test/js/node/test/parallel/test-http2-client-socket-destroy.js [ FAIL ] # not yet passing test/js/node/test/parallel/test-http2-compat-serverresponse-drain.js [ FAIL ] # not yet passing test/js/node/test/parallel/test-http2-compat-socket.js [ FAIL ] # not yet passing test/js/node/test/parallel/test-http2-connect-method-extended-cant-turn-off.js [ FAIL ] # not yet passing test/js/node/test/parallel/test-http2-connect-method-extended.js [ FAIL ] # not yet passing -test/js/node/test/parallel/test-http2-create-client-secure-session.js [ FAIL ] # not yet passing test/js/node/test/parallel/test-http2-debug.js [ FAIL ] # not yet passing test/js/node/test/parallel/test-http2-https-fallback.js [ FAIL ] # not yet passing test/js/node/test/parallel/test-http2-info-headers-errors.js [ FAIL ] # not yet passing test/js/node/test/parallel/test-http2-invalid-last-stream-id.js [ FAIL ] # not yet passing test/js/node/test/parallel/test-http2-misbehaving-multiplex.js [ FAIL ] # not yet passing -test/js/node/test/parallel/test-http2-misc-util.js [ FAIL ] # not yet passing test/js/node/test/parallel/test-http2-multi-content-length.js [ FAIL ] # not yet passing test/js/node/test/parallel/test-http2-options-max-headers-exceeds-nghttp2.js [ FAIL ] # not yet passing test/js/node/test/parallel/test-http2-padding-aligned.js [ FAIL ] # not yet passing @@ -191,19 +188,12 @@ test/js/node/test/parallel/test-http2-sensitive-headers.js [ FAIL ] # not yet pa test/js/node/test/parallel/test-http2-server-http1-client.js [ FAIL ] # not yet passing test/js/node/test/parallel/test-http2-server-push-stream-errors.js [ FAIL ] # not yet passing test/js/node/test/parallel/test-http2-server-push-stream-head.js [ FAIL ] # not yet passing -test/js/node/test/parallel/test-http2-server-sessionerror.js [ FAIL ] # not yet passing -test/js/node/test/parallel/test-http2-server-socket-destroy.js [ FAIL ] # not yet passing test/js/node/test/parallel/test-http2-server-stream-session-destroy.js [ FAIL ] # not yet passing test/js/node/test/parallel/test-http2-settings-unsolicited-ack.js [ FAIL ] # not yet passing test/js/node/test/parallel/test-http2-single-headers-validation-disabled.js [ FAIL ] # not yet passing test/js/node/test/parallel/test-http2-socket-proxy.js [ FAIL ] # not yet passing test/js/node/test/parallel/test-http2-stream-client.js [ FAIL ] # not yet passing test/js/node/test/parallel/test-http2-too-many-settings.js [ FAIL ] # not yet passing -test/js/node/test/parallel/test-http2-util-assert-valid-pseudoheader.js [ FAIL ] # not yet passing -test/js/node/test/parallel/test-http2-util-asserts.js [ FAIL ] # not yet passing -test/js/node/test/parallel/test-http2-util-headers-list.js [ FAIL ] # not yet passing -test/js/node/test/parallel/test-http2-util-nghttp2error.js [ FAIL ] # not yet passing -test/js/node/test/parallel/test-http2-util-update-options-buffer.js [ FAIL ] # not yet passing test/js/node/test/sequential/test-http2-timeout-large-write-file.js [ FAIL ] # not yet passing test/js/node/test/sequential/test-http2-timeout-large-write.js [ FAIL ] # not yet passing test/js/node/test/parallel/test-http2-autoselect-protocol.js [ SKIP ] # hangs; skip until the underlying feature lands diff --git a/test/js/node/http2/node-http2.test.js b/test/js/node/http2/node-http2.test.js index 44ee6b0be956..11514ce09948 100644 --- a/test/js/node/http2/node-http2.test.js +++ b/test/js/node/http2/node-http2.test.js @@ -290,6 +290,8 @@ for (const nodeExecutable of [nodeExe(), bunExe()]) { it("constants", () => { expect(http2.constants).toEqual({ "NGHTTP2_ERR_FRAME_SIZE_ERROR": -522, + "NGHTTP2_NV_FLAG_NONE": 0, + "NGHTTP2_NV_FLAG_NO_INDEX": 1, "NGHTTP2_SESSION_SERVER": 0, "NGHTTP2_SESSION_CLIENT": 1, "NGHTTP2_STREAM_STATE_IDLE": 1, diff --git a/test/js/node/test/common/index.js b/test/js/node/test/common/index.js index cec35c12a36d..8e39fee3fa27 100644 --- a/test/js/node/test/common/index.js +++ b/test/js/node/test/common/index.js @@ -1286,12 +1286,6 @@ function installBunExposeInternalsShim() { } catch { // http2 may be unavailable in some builds; the shim then only provides symbols. } - class NghttpError extends Error { - constructor(message) { - super(message); - this.code = "ERR_HTTP2_ERROR"; - } - } Bun.plugin({ name: "node-test-expose-internals", setup(build) { @@ -1300,7 +1294,6 @@ function installBunExposeInternalsShim() { exports: { // The same registered symbol node:http2 stores the raw socket under. kSocket: Symbol.for("::bunhttp2socket::"), - NghttpError, ...(http2Internals.util ?? {}), }, })); @@ -1308,6 +1301,8 @@ function installBunExposeInternalsShim() { loader: "object", exports: { ...(http2Internals.core ?? {}) }, })); + // `internal/test/binding` is a real Bun built-in (src/js/internal/test/binding.ts) + // that already wins over a Bun.plugin virtual module; it handles `http2` there. build.module("internal/timers", () => ({ loader: "object", exports: { kTimeout: Symbol.for("::buntimeout::") },