@layervai/qurl 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +261 -11
  2. package/dist/cjs/client.d.ts +22 -12
  3. package/dist/cjs/client.js +675 -100
  4. package/dist/cjs/errors.d.ts +11 -11
  5. package/dist/cjs/errors.js +25 -20
  6. package/dist/cjs/index.d.ts +1 -1
  7. package/dist/cjs/internal.d.ts +4 -0
  8. package/dist/cjs/internal.js +16 -0
  9. package/dist/cjs/node/deployment.d.ts +44 -0
  10. package/dist/cjs/node/deployment.js +167 -0
  11. package/dist/cjs/node/native-udp.d.ts +35 -0
  12. package/dist/cjs/node/native-udp.js +294 -0
  13. package/dist/cjs/node/nhp-wire.d.ts +35 -0
  14. package/dist/cjs/node/nhp-wire.js +261 -0
  15. package/dist/cjs/node/portal-opener.d.ts +114 -0
  16. package/dist/cjs/node/portal-opener.js +1070 -0
  17. package/dist/cjs/node/qv2.d.ts +38 -0
  18. package/dist/cjs/node/qv2.js +292 -0
  19. package/dist/cjs/node/strict-json.d.ts +12 -0
  20. package/dist/cjs/node/strict-json.js +174 -0
  21. package/dist/cjs/node.d.ts +3 -0
  22. package/dist/cjs/node.js +18 -0
  23. package/dist/cjs/types.d.ts +17 -20
  24. package/dist/cjs/version.d.ts +1 -1
  25. package/dist/cjs/version.js +1 -1
  26. package/dist/esm/client.d.ts +22 -12
  27. package/dist/esm/client.d.ts.map +1 -1
  28. package/dist/esm/client.js +673 -98
  29. package/dist/esm/client.js.map +1 -1
  30. package/dist/esm/errors.d.ts +11 -11
  31. package/dist/esm/errors.d.ts.map +1 -1
  32. package/dist/esm/errors.js +25 -20
  33. package/dist/esm/errors.js.map +1 -1
  34. package/dist/esm/index.d.ts +1 -1
  35. package/dist/esm/index.d.ts.map +1 -1
  36. package/dist/esm/internal.d.ts +5 -0
  37. package/dist/esm/internal.d.ts.map +1 -0
  38. package/dist/esm/internal.js +14 -0
  39. package/dist/esm/internal.js.map +1 -0
  40. package/dist/esm/node/deployment.d.ts +45 -0
  41. package/dist/esm/node/deployment.d.ts.map +1 -0
  42. package/dist/esm/node/deployment.js +163 -0
  43. package/dist/esm/node/deployment.js.map +1 -0
  44. package/dist/esm/node/native-udp.d.ts +36 -0
  45. package/dist/esm/node/native-udp.d.ts.map +1 -0
  46. package/dist/esm/node/native-udp.js +291 -0
  47. package/dist/esm/node/native-udp.js.map +1 -0
  48. package/dist/esm/node/nhp-wire.d.ts +36 -0
  49. package/dist/esm/node/nhp-wire.d.ts.map +1 -0
  50. package/dist/esm/node/nhp-wire.js +257 -0
  51. package/dist/esm/node/nhp-wire.js.map +1 -0
  52. package/dist/esm/node/portal-opener.d.ts +115 -0
  53. package/dist/esm/node/portal-opener.d.ts.map +1 -0
  54. package/dist/esm/node/portal-opener.js +1053 -0
  55. package/dist/esm/node/portal-opener.js.map +1 -0
  56. package/dist/esm/node/qv2.d.ts +39 -0
  57. package/dist/esm/node/qv2.d.ts.map +1 -0
  58. package/dist/esm/node/qv2.js +286 -0
  59. package/dist/esm/node/qv2.js.map +1 -0
  60. package/dist/esm/node/strict-json.d.ts +13 -0
  61. package/dist/esm/node/strict-json.d.ts.map +1 -0
  62. package/dist/esm/node/strict-json.js +171 -0
  63. package/dist/esm/node/strict-json.js.map +1 -0
  64. package/dist/esm/node.d.ts +4 -0
  65. package/dist/esm/node.d.ts.map +1 -0
  66. package/dist/esm/node.js +2 -0
  67. package/dist/esm/node.js.map +1 -0
  68. package/dist/esm/types.d.ts +17 -20
  69. package/dist/esm/types.d.ts.map +1 -1
  70. package/dist/esm/version.d.ts +1 -1
  71. package/dist/esm/version.js +1 -1
  72. package/package.json +26 -7
@@ -0,0 +1,1070 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.portalOpenerTesting = exports.PortalInvalidReplyError = exports.PortalBusyError = exports.PortalTooManyRedirectsError = exports.PortalRedirectError = exports.PortalTargetChangedError = exports.PortalOpenTimeoutError = exports.PortalOpenerClosedError = exports.PortalOpenerNotReadyError = exports.PortalOpenerNotStartedError = exports.PortalStateError = exports.PortalDenyError = exports.PortalVerificationError = exports.PortalConfigurationError = void 0;
4
+ exports.createPortalOpener = createPortalOpener;
5
+ exports.createPortalOpenerWithRuntime = createPortalOpenerWithRuntime;
6
+ const node_crypto_1 = require("node:crypto");
7
+ const node_events_1 = require("node:events");
8
+ const nhp_wire_js_1 = require("./nhp-wire.js");
9
+ const deployment_js_1 = require("./deployment.js");
10
+ const native_udp_js_1 = require("./native-udp.js");
11
+ const strict_json_js_1 = require("./strict-json.js");
12
+ const qv2_js_1 = require("./qv2.js");
13
+ const SESSION_COOKIE = "qurl_vsession";
14
+ const MAX_REDIRECT_REQUESTS = 10;
15
+ const DEFAULT_OPEN_TIMEOUT_MS = 15_000;
16
+ const MAX_OPEN_TIMEOUT_MS = 60_000;
17
+ const MIN_RENEWAL_GAP_MS = 5_000;
18
+ const MIN_RENEWAL_LEAD_MS = 5_000;
19
+ const MAX_RENEWAL_LEAD_MS = 60_000;
20
+ const INITIAL_RETRY_MS = 500;
21
+ const MAX_RETRY_MS = 2_000;
22
+ // Node changes a larger setTimeout delay to 1 ms. Clamp long grants so an
23
+ // authenticated but unexpected lifetime cannot create a hot renewal loop.
24
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
25
+ const MAX_ACK_BODY_BYTES = 4_096;
26
+ const UINT64_MAX = (1n << 64n) - 1n;
27
+ class PortalConfigurationError extends Error {
28
+ constructor(message, options) {
29
+ super(message, options);
30
+ this.name = "PortalConfigurationError";
31
+ }
32
+ }
33
+ exports.PortalConfigurationError = PortalConfigurationError;
34
+ class PortalVerificationError extends Error {
35
+ constructor(message, options) {
36
+ super(message, options);
37
+ this.name = "PortalVerificationError";
38
+ }
39
+ }
40
+ exports.PortalVerificationError = PortalVerificationError;
41
+ class PortalDenyError extends Error {
42
+ errCode;
43
+ constructor(errCode) {
44
+ super(`qURL platform denied native access (errCode=${JSON.stringify(errCode)})`);
45
+ this.name = "PortalDenyError";
46
+ this.errCode = errCode;
47
+ }
48
+ }
49
+ exports.PortalDenyError = PortalDenyError;
50
+ class PortalStateError extends Error {
51
+ constructor(message, options) {
52
+ super(message, options);
53
+ this.name = "PortalStateError";
54
+ }
55
+ }
56
+ exports.PortalStateError = PortalStateError;
57
+ class PortalOpenerNotStartedError extends PortalStateError {
58
+ constructor() {
59
+ super("portal opener has not started");
60
+ this.name = "PortalOpenerNotStartedError";
61
+ }
62
+ }
63
+ exports.PortalOpenerNotStartedError = PortalOpenerNotStartedError;
64
+ class PortalOpenerNotReadyError extends PortalStateError {
65
+ constructor(options) {
66
+ super("portal opener has no active session", options);
67
+ this.name = "PortalOpenerNotReadyError";
68
+ }
69
+ }
70
+ exports.PortalOpenerNotReadyError = PortalOpenerNotReadyError;
71
+ class PortalOpenerClosedError extends PortalStateError {
72
+ constructor(options) {
73
+ super("portal opener is closed", options);
74
+ this.name = "PortalOpenerClosedError";
75
+ }
76
+ }
77
+ exports.PortalOpenerClosedError = PortalOpenerClosedError;
78
+ class PortalOpenTimeoutError extends PortalStateError {
79
+ constructor() {
80
+ super("native NHP open exceeded its overall deadline");
81
+ this.name = "PortalOpenTimeoutError";
82
+ }
83
+ }
84
+ exports.PortalOpenTimeoutError = PortalOpenTimeoutError;
85
+ class PortalTargetChangedError extends PortalStateError {
86
+ constructor() {
87
+ super("portal renewal changed the authenticated target");
88
+ this.name = "PortalTargetChangedError";
89
+ }
90
+ }
91
+ exports.PortalTargetChangedError = PortalTargetChangedError;
92
+ class PortalRedirectError extends PortalStateError {
93
+ constructor(message = "protected request redirect refused", options) {
94
+ super(message, options);
95
+ this.name = "PortalRedirectError";
96
+ }
97
+ }
98
+ exports.PortalRedirectError = PortalRedirectError;
99
+ class PortalTooManyRedirectsError extends PortalStateError {
100
+ constructor() {
101
+ super(`portal fetch stopped at the ${MAX_REDIRECT_REQUESTS}-request redirect limit`);
102
+ this.name = "PortalTooManyRedirectsError";
103
+ }
104
+ }
105
+ exports.PortalTooManyRedirectsError = PortalTooManyRedirectsError;
106
+ class PortalBusyError extends PortalStateError {
107
+ constructor() {
108
+ super("qURL platform is busy; retry start later");
109
+ this.name = "PortalBusyError";
110
+ }
111
+ }
112
+ exports.PortalBusyError = PortalBusyError;
113
+ class PortalInvalidReplyError extends PortalStateError {
114
+ constructor(message, options) {
115
+ super(message, options);
116
+ this.name = "PortalInvalidReplyError";
117
+ }
118
+ }
119
+ exports.PortalInvalidReplyError = PortalInvalidReplyError;
120
+ const defaultContentFetch = (input, init) => {
121
+ // Resolve lazily so importing the Node entry point does not require global
122
+ // Fetch when a consumer supplies its own protected-content implementation.
123
+ const implementation = globalThis.fetch;
124
+ if (typeof implementation !== "function") {
125
+ return Promise.reject(new PortalConfigurationError("portal fetch requires global Fetch or CreatePortalOpenerOptions.fetch"));
126
+ }
127
+ return implementation.call(globalThis, input, init);
128
+ };
129
+ const defaultRuntime = {
130
+ knock: native_udp_js_1.nativeKnock,
131
+ fetch: defaultContentFetch,
132
+ nowNanos: () => process.hrtime.bigint(),
133
+ nowEpochMs: () => Date.now(),
134
+ setTimer: (callback, delayMs) => setTimeout(callback, delayMs),
135
+ clearTimer: (timer) => clearTimeout(timer),
136
+ setDeadlineTimer: (callback, delayMs) => setTimeout(callback, delayMs),
137
+ clearDeadlineTimer: (timer) => clearTimeout(timer),
138
+ };
139
+ function createPortalOpener(options) {
140
+ return createPortalOpenerWithRuntime(options, defaultRuntime);
141
+ }
142
+ function createPortalOpenerWithRuntime(options, runtime) {
143
+ if (!options || typeof options !== "object") {
144
+ throw new PortalConfigurationError("native portal opener options are required");
145
+ }
146
+ if (typeof options.qurl !== "string" || options.qurl.trim() === "") {
147
+ throw new PortalConfigurationError("native portal opener qurl must be a non-empty string");
148
+ }
149
+ if (options.openTimeoutMs !== undefined &&
150
+ (!Number.isFinite(options.openTimeoutMs) ||
151
+ options.openTimeoutMs < 1 ||
152
+ options.openTimeoutMs > MAX_OPEN_TIMEOUT_MS)) {
153
+ throw new PortalConfigurationError("native portal opener openTimeoutMs must be from 1 to 60000");
154
+ }
155
+ if (options.fetch !== undefined && typeof options.fetch !== "function") {
156
+ throw new PortalConfigurationError("native portal opener fetch must be a function");
157
+ }
158
+ return new NativePortalOpener(options, runtime);
159
+ }
160
+ class NativePortalOpener {
161
+ #runtime;
162
+ #fetch;
163
+ #explicitDeployment;
164
+ #openTimeoutMs;
165
+ #qurl;
166
+ #resolvedDeployment;
167
+ #sessionSecret;
168
+ #grant;
169
+ #boundResourceUrl;
170
+ #startPromise;
171
+ #openPromise;
172
+ #openController;
173
+ #closePromise;
174
+ #renewalTimer;
175
+ #renewalCycle;
176
+ #lifecycleController = new AbortController();
177
+ #state = "new";
178
+ #startingRecovery = false;
179
+ #expiresAtEpochMs;
180
+ #renewAtEpochMs;
181
+ #lastOpenSucceededAt;
182
+ #lastFailureClass = "";
183
+ #consecutiveFailures = 0;
184
+ constructor(options, runtime) {
185
+ this.#qurl = options.qurl;
186
+ this.#explicitDeployment = options.deployment;
187
+ this.#runtime = runtime;
188
+ this.#fetch = options.fetch ?? runtime.fetch;
189
+ this.#openTimeoutMs = options.openTimeoutMs ?? DEFAULT_OPEN_TIMEOUT_MS;
190
+ // Native Fetch attaches one abort listener per in-flight request. This
191
+ // signal is private and intentionally shared so close can stop all work.
192
+ (0, node_events_1.setMaxListeners)(0, this.#lifecycleController.signal);
193
+ }
194
+ async start(options = {}) {
195
+ this.#requireOpen();
196
+ if (this.#state === "running" && this.#grant && this.#isGrantReady(this.#grant))
197
+ return;
198
+ if (options.signal?.aborted) {
199
+ throw options.signal.reason ?? new DOMException("Aborted", "AbortError");
200
+ }
201
+ if (this.#state === "running" && !this.#isGrantReady(this.#grant) && this.#renewalCycle) {
202
+ await waitForPromise(this.#renewalCycle, options.signal);
203
+ return this.start(options);
204
+ }
205
+ const sharedStart = this.#startPromise;
206
+ if (sharedStart)
207
+ return waitForPromise(sharedStart, options.signal);
208
+ let attempt;
209
+ attempt = this.#runStart(options.signal).finally(() => {
210
+ if (this.#startPromise === attempt)
211
+ this.#startPromise = undefined;
212
+ });
213
+ this.#startPromise = attempt;
214
+ return attempt;
215
+ }
216
+ async #runStart(signal) {
217
+ // A pending renewal wait always has a ready grant, so public start() returns
218
+ // before reaching this explicit-open path. Keep that invariant if start()
219
+ // admission rules change: clearing its timer would strand #renewalCycle.
220
+ if (this.#renewalTimer)
221
+ this.#runtime.clearTimer(this.#renewalTimer);
222
+ this.#renewalTimer = undefined;
223
+ const recovery = this.#state === "degraded" || this.#state === "running";
224
+ this.#startingRecovery = recovery;
225
+ this.#state = "starting";
226
+ try {
227
+ await this.#openSingleFlight(signal);
228
+ }
229
+ catch (error) {
230
+ if (this.#isClosed())
231
+ throw new PortalOpenerClosedError({ cause: error });
232
+ this.#clearGrant();
233
+ if (signal?.aborted && error === signal.reason) {
234
+ this.#state = recovery ? "degraded" : "new";
235
+ }
236
+ else {
237
+ this.#state = "degraded";
238
+ this.#recordFailure(error);
239
+ }
240
+ throw error;
241
+ }
242
+ finally {
243
+ this.#startingRecovery = false;
244
+ }
245
+ }
246
+ fetch(input = {}, options = {}) {
247
+ return this.#dispatch([], false, input, options);
248
+ }
249
+ fetchDescendant(pathSegments, input = {}, options = {}) {
250
+ return this.#dispatch(pathSegments, true, input, options);
251
+ }
252
+ async #dispatch(pathSegments, descendant, input, options) {
253
+ let body = undefined;
254
+ try {
255
+ // A direct RequestInit already belongs to this call. Capture its body
256
+ // before any option or lifecycle rejection so every pre-fetch exit can
257
+ // release a caller-owned stream. Builders remain lazy until a grant is
258
+ // ready and therefore have no body to release on earlier exits.
259
+ if (typeof input !== "function" && input && typeof input === "object") {
260
+ body = input.body;
261
+ }
262
+ let descendantSegments;
263
+ if (descendant) {
264
+ validatePortalDescendantSegments(pathSegments);
265
+ descendantSegments = pathSegments;
266
+ }
267
+ if (this.#state === "closed")
268
+ throw new PortalOpenerClosedError();
269
+ if (options.redirects !== undefined &&
270
+ options.redirects !== "follow" &&
271
+ options.redirects !== "error") {
272
+ throw new PortalConfigurationError("portal fetch redirects must be follow or error");
273
+ }
274
+ if (this.#state === "new" || (this.#state === "starting" && !this.#startingRecovery)) {
275
+ throw new PortalOpenerNotStartedError();
276
+ }
277
+ const grant = this.#grant;
278
+ if (this.#state !== "running" || !this.#isGrantReady(grant)) {
279
+ throw new PortalOpenerNotReadyError();
280
+ }
281
+ // A successful renewal or close wipes the mutable grant token. Keep the
282
+ // unavoidable request string local to this fetch so every redirect leg has
283
+ // one stable credential snapshot.
284
+ const sessionToken = grant.token.toString("ascii");
285
+ const requestTarget = descendantSegments
286
+ ? appendPortalDescendantSegments(new URL(grant.resourceUrl), descendantSegments)
287
+ : new URL(grant.resourceUrl);
288
+ const requestTargetRaw = requestTarget.href;
289
+ const init = typeof input === "function" ? input(new URL(requestTargetRaw)) : input;
290
+ if (!init || typeof init !== "object") {
291
+ throw new PortalConfigurationError("portal request builder must return RequestInit");
292
+ }
293
+ if (typeof input === "function")
294
+ body = init.body;
295
+ if (Object.prototype.hasOwnProperty.call(init, "redirect") && init.redirect !== undefined) {
296
+ throw new PortalConfigurationError("portal fetch owns redirect handling; redirect overrides are not allowed");
297
+ }
298
+ // The builder receives a disposable URL copy. Pin the actual request URL
299
+ // and its Fetch-derived Host to the trusted value after the builder runs.
300
+ let currentUrl = requestTargetRaw;
301
+ let method = (init.method ?? "GET").toUpperCase();
302
+ let headers = new Headers(init.headers);
303
+ if (headers.has("host")) {
304
+ throw new PortalConfigurationError("portal fetch owns the Host derived from the authenticated target");
305
+ }
306
+ // Use the lifecycle signal directly when there is no caller signal. When
307
+ // both are present, native composition avoids listener fanout and remains
308
+ // active after headers arrive, preserving Fetch response-body semantics.
309
+ const requestSignal = init.signal
310
+ ? AbortSignal.any([init.signal, this.#lifecycleController.signal])
311
+ : this.#lifecycleController.signal;
312
+ for (let requestCount = 1;; requestCount++) {
313
+ this.#requireOpen();
314
+ throwIfAborted(requestSignal);
315
+ const requestHeaders = authorizeHeaders(headers, sessionToken);
316
+ const response = await this.#fetch.call(globalThis, currentUrl, {
317
+ ...init,
318
+ method,
319
+ body,
320
+ headers: requestHeaders,
321
+ redirect: "manual",
322
+ signal: requestSignal,
323
+ });
324
+ if (requestSignal.aborted) {
325
+ await discardResponseBody(response);
326
+ throwIfAborted(requestSignal);
327
+ }
328
+ if (response.redirected === true || !responseMatchesRequestUrl(response.url, currentUrl)) {
329
+ await discardResponseBody(response);
330
+ throw new PortalRedirectError("portal fetch refused a response that bypassed its manual redirect policy");
331
+ }
332
+ if (![301, 302, 303, 307, 308].includes(response.status))
333
+ return response;
334
+ const location = response.headers.get("location");
335
+ if (!location)
336
+ return response;
337
+ if (options.redirects === "error") {
338
+ await discardResponseBody(response);
339
+ throw new PortalRedirectError("portal fetch refused a redirect for a fixed signed request");
340
+ }
341
+ if (requestCount >= MAX_REDIRECT_REQUESTS) {
342
+ await discardResponseBody(response);
343
+ throw new PortalTooManyRedirectsError();
344
+ }
345
+ let next;
346
+ try {
347
+ next = new URL(location, currentUrl);
348
+ }
349
+ catch {
350
+ await discardResponseBody(response);
351
+ throw new PortalRedirectError("portal fetch refused an invalid redirect target");
352
+ }
353
+ // Fragments are never part of an HTTP request target, and Fetch omits
354
+ // them from Response.url. Normalize before the next request and its
355
+ // manual-redirect bypass check.
356
+ next.hash = "";
357
+ let nextOrigin;
358
+ try {
359
+ nextOrigin = normalizedHttpsOrigin(next);
360
+ }
361
+ catch {
362
+ await discardResponseBody(response);
363
+ throw new PortalRedirectError("portal fetch refused an invalid redirect target");
364
+ }
365
+ if (nextOrigin !== grant.origin) {
366
+ await discardResponseBody(response);
367
+ throw new PortalRedirectError("portal fetch refused a redirect outside the authenticated origin");
368
+ }
369
+ if ([301, 302, 303].includes(response.status) && method !== "GET" && method !== "HEAD") {
370
+ method = "GET";
371
+ body = undefined;
372
+ headers = new Headers(headers);
373
+ headers.delete("content-length");
374
+ headers.delete("content-type");
375
+ }
376
+ else if ((response.status === 307 || response.status === 308) && body !== undefined) {
377
+ if (!isReplayableBody(body))
378
+ return response;
379
+ }
380
+ await discardResponseBody(response);
381
+ currentUrl = next.href;
382
+ }
383
+ }
384
+ catch (error) {
385
+ await discardRequestBody(body);
386
+ if (error instanceof PortalOpenerClosedError)
387
+ throw error;
388
+ if (this.#isClosed())
389
+ throw new PortalOpenerClosedError({ cause: error });
390
+ throw error;
391
+ }
392
+ }
393
+ health() {
394
+ const ready = this.#state === "running" && this.#isGrantReady(this.#grant);
395
+ let state;
396
+ if (this.#state === "closed")
397
+ state = "closed";
398
+ else if (this.#state === "new")
399
+ state = "new";
400
+ else if (this.#state === "starting" && !this.#startingRecovery)
401
+ state = "starting";
402
+ else if (ready)
403
+ state = "ready";
404
+ else
405
+ state = "degraded";
406
+ return {
407
+ state,
408
+ ready,
409
+ expiresAt: dateFromEpoch(this.#expiresAtEpochMs),
410
+ renewAt: dateFromEpoch(this.#renewAtEpochMs),
411
+ lastOpenSucceededAt: dateFromEpoch(this.#lastOpenSucceededAt),
412
+ lastFailureClass: this.#lastFailureClass,
413
+ consecutiveFailures: this.#consecutiveFailures,
414
+ };
415
+ }
416
+ close() {
417
+ if (this.#closePromise)
418
+ return this.#closePromise;
419
+ this.#state = "closed";
420
+ if (this.#renewalTimer)
421
+ this.#runtime.clearTimer(this.#renewalTimer);
422
+ this.#renewalTimer = undefined;
423
+ this.#expiresAtEpochMs = undefined;
424
+ this.#renewAtEpochMs = undefined;
425
+ this.#lastOpenSucceededAt = undefined;
426
+ this.#lastFailureClass = "";
427
+ this.#consecutiveFailures = 0;
428
+ this.#startingRecovery = false;
429
+ this.#lifecycleController.abort(new PortalOpenerClosedError());
430
+ this.#openController?.abort(new PortalOpenerClosedError());
431
+ const active = this.#openPromise;
432
+ const renewal = this.#renewalCycle;
433
+ const closing = (async () => {
434
+ await Promise.allSettled([active, renewal].filter((item) => item !== undefined));
435
+ this.#clearGrant();
436
+ this.#boundResourceUrl = undefined;
437
+ this.#qurl = "";
438
+ this.#sessionSecret?.fill(0);
439
+ this.#sessionSecret = undefined;
440
+ this.#resolvedDeployment = undefined;
441
+ })();
442
+ this.#closePromise = closing;
443
+ return closing;
444
+ }
445
+ #openSingleFlight(signal, expiresAtNanos) {
446
+ this.#requireOpen();
447
+ const active = this.#openPromise;
448
+ if (active)
449
+ return waitForPromise(active, signal);
450
+ const controller = new AbortController();
451
+ const removeAbortListener = linkAbortSignal(signal, controller);
452
+ const remainingMs = expiresAtNanos === undefined
453
+ ? this.#openTimeoutMs
454
+ : nanosToCeilingMilliseconds(expiresAtNanos - this.#runtime.nowNanos());
455
+ const deadlineMs = Math.min(this.#openTimeoutMs, remainingMs);
456
+ if (deadlineMs <= 0) {
457
+ removeAbortListener();
458
+ return Promise.reject(new PortalOpenTimeoutError());
459
+ }
460
+ const deadline = this.#runtime.setDeadlineTimer(() => {
461
+ controller.abort(new PortalOpenTimeoutError());
462
+ }, deadlineMs);
463
+ deadline.unref?.();
464
+ const current = this.#performOpen(controller.signal).finally(() => {
465
+ this.#runtime.clearDeadlineTimer(deadline);
466
+ removeAbortListener();
467
+ if (this.#openPromise === current) {
468
+ this.#openPromise = undefined;
469
+ this.#openController = undefined;
470
+ }
471
+ });
472
+ this.#openController = controller;
473
+ this.#openPromise = current;
474
+ return current;
475
+ }
476
+ async #performOpen(signal) {
477
+ this.#requireOpen();
478
+ const deployment = this.#loadDeployment();
479
+ const link = this.#verifyLink(deployment);
480
+ try {
481
+ const cell = validatedCellForLink(link, deployment);
482
+ this.#sessionSecret ??= (0, node_crypto_1.randomBytes)(32);
483
+ const body = this.#knockBody(link);
484
+ // Start the local validity bound before DNS and UDP I/O. The cell starts
485
+ // its grant no earlier than this, so this client never assumes extra RTT.
486
+ const startedAtNanos = this.#runtime.nowNanos();
487
+ const startedAtEpochMs = this.#runtime.nowEpochMs();
488
+ try {
489
+ const reply = await this.#runtime.knock(cell, link.devicePrivateKey, body, {
490
+ signal,
491
+ });
492
+ try {
493
+ this.#requireOpen();
494
+ if (reply.type === nhp_wire_js_1.NHP_TYPE_COOKIE)
495
+ throw new PortalBusyError();
496
+ if (reply.type !== nhp_wire_js_1.NHP_TYPE_ACK) {
497
+ throw new PortalInvalidReplyError("native NHP returned an unexpected reply type");
498
+ }
499
+ const openedAtNanos = this.#runtime.nowNanos();
500
+ const openedAtEpochMs = this.#runtime.nowEpochMs();
501
+ const grant = parseGrant(reply.body, startedAtNanos, startedAtEpochMs, openedAtNanos, openedAtEpochMs);
502
+ if (this.#boundResourceUrl !== undefined &&
503
+ grant.resourceUrl !== this.#boundResourceUrl) {
504
+ grant.token.fill(0);
505
+ throw new PortalTargetChangedError();
506
+ }
507
+ if (this.#runtime.nowNanos() >= grant.expiresAtNanos) {
508
+ grant.token.fill(0);
509
+ throw new PortalInvalidReplyError("native NHP admission expired before it became usable");
510
+ }
511
+ this.#resolvedDeployment ??= deployment;
512
+ this.#installGrant(grant);
513
+ }
514
+ finally {
515
+ // ACK and deny bodies can contain a bearer. Wipe them on every parse path.
516
+ reply.body.fill(0);
517
+ }
518
+ }
519
+ finally {
520
+ body.fill(0);
521
+ }
522
+ }
523
+ finally {
524
+ link.devicePrivateKey.fill(0);
525
+ }
526
+ }
527
+ #scheduleRenewal(grant) {
528
+ if (this.#renewalTimer)
529
+ this.#runtime.clearTimer(this.#renewalTimer);
530
+ const delayMs = Math.min(MAX_TIMER_DELAY_MS, Math.max(0, nanosToCeilingMilliseconds(grant.renewAtNanos - this.#runtime.nowNanos())));
531
+ const timer = this.#runtime.setTimer(() => {
532
+ if (this.#renewalTimer === timer)
533
+ this.#renewalTimer = undefined;
534
+ if (this.#state !== "running" || this.#grant !== grant)
535
+ return;
536
+ if (this.#runtime.nowNanos() < grant.renewAtNanos) {
537
+ this.#scheduleRenewal(grant);
538
+ return;
539
+ }
540
+ const cycle = this.#runRenewalCycle(grant)
541
+ .catch((error) => {
542
+ // A background task must never create an unhandled rejection. An
543
+ // unexpected internal failure loses readiness instead of risking the
544
+ // host process or leaving an unsupervised running state.
545
+ if (this.#isClosed() || this.#grant !== grant)
546
+ return;
547
+ this.#recordFailure(error);
548
+ this.#expireGrant(grant);
549
+ })
550
+ .finally(() => {
551
+ if (this.#renewalCycle === cycle)
552
+ this.#renewalCycle = undefined;
553
+ });
554
+ this.#renewalCycle = cycle;
555
+ }, delayMs);
556
+ this.#renewalTimer = timer;
557
+ timer.unref?.();
558
+ }
559
+ async #runRenewalCycle(grant) {
560
+ let delayMs = INITIAL_RETRY_MS;
561
+ while (this.#state === "running" && this.#grant === grant) {
562
+ if (!this.#isGrantReady(grant)) {
563
+ this.#expireGrant(grant);
564
+ return;
565
+ }
566
+ try {
567
+ await this.#openSingleFlight(this.#lifecycleController.signal, grant.expiresAtNanos);
568
+ return;
569
+ }
570
+ catch (error) {
571
+ if (this.#isClosed())
572
+ return;
573
+ if (this.#grant !== grant)
574
+ return;
575
+ this.#recordFailure(error);
576
+ if (error instanceof PortalTargetChangedError) {
577
+ if (!(await this.#waitUntil(grant.expiresAtNanos)))
578
+ return;
579
+ this.#expireGrant(grant);
580
+ return;
581
+ }
582
+ const remaining = nanosToCeilingMilliseconds(grant.expiresAtNanos - this.#runtime.nowNanos());
583
+ if (remaining <= 0)
584
+ continue;
585
+ if (!(await this.#waitForRenewal(Math.min(delayMs, remaining))))
586
+ return;
587
+ delayMs = Math.min(delayMs * 2, MAX_RETRY_MS);
588
+ }
589
+ }
590
+ }
591
+ #waitForRenewal(delayMs) {
592
+ const signal = this.#lifecycleController.signal;
593
+ if (signal.aborted)
594
+ return Promise.resolve(false);
595
+ return new Promise((resolve) => {
596
+ let settled = false;
597
+ let timer;
598
+ const finish = (result) => {
599
+ if (settled)
600
+ return;
601
+ settled = true;
602
+ signal.removeEventListener("abort", abort);
603
+ if (timer && this.#renewalTimer === timer)
604
+ this.#renewalTimer = undefined;
605
+ resolve(result);
606
+ };
607
+ const abort = () => {
608
+ if (timer)
609
+ this.#runtime.clearTimer(timer);
610
+ finish(false);
611
+ };
612
+ timer = this.#runtime.setTimer(() => finish(true), delayMs);
613
+ if (settled) {
614
+ this.#runtime.clearTimer(timer);
615
+ return;
616
+ }
617
+ this.#renewalTimer = timer;
618
+ timer.unref?.();
619
+ if (signal.aborted)
620
+ abort();
621
+ else
622
+ signal.addEventListener("abort", abort, { once: true });
623
+ });
624
+ }
625
+ async #waitUntil(deadlineNanos) {
626
+ while (this.#runtime.nowNanos() < deadlineNanos) {
627
+ const remaining = nanosToCeilingMilliseconds(deadlineNanos - this.#runtime.nowNanos());
628
+ if (!(await this.#waitForRenewal(Math.min(MAX_TIMER_DELAY_MS, remaining))))
629
+ return false;
630
+ }
631
+ return true;
632
+ }
633
+ #installGrant(grant) {
634
+ this.#clearGrant();
635
+ this.#grant = grant;
636
+ this.#boundResourceUrl ??= grant.resourceUrl;
637
+ this.#expiresAtEpochMs = grant.expiresAtEpochMs;
638
+ this.#renewAtEpochMs = grant.renewAtEpochMs;
639
+ this.#lastOpenSucceededAt = grant.openedAtEpochMs;
640
+ this.#lastFailureClass = "";
641
+ this.#consecutiveFailures = 0;
642
+ this.#state = "running";
643
+ this.#scheduleRenewal(grant);
644
+ }
645
+ #recordFailure(error) {
646
+ this.#lastFailureClass =
647
+ error instanceof PortalTargetChangedError ? "target_changed" : "open_failed";
648
+ this.#consecutiveFailures++;
649
+ }
650
+ #expireGrant(grant) {
651
+ if (this.#grant !== grant || this.#state !== "running")
652
+ return;
653
+ this.#clearGrant();
654
+ this.#state = "degraded";
655
+ }
656
+ #clearGrant() {
657
+ this.#grant?.token.fill(0);
658
+ this.#grant = undefined;
659
+ }
660
+ #isGrantReady(grant) {
661
+ return grant !== undefined && this.#runtime.nowNanos() < grant.expiresAtNanos;
662
+ }
663
+ #loadDeployment() {
664
+ if (this.#resolvedDeployment)
665
+ return this.#resolvedDeployment;
666
+ try {
667
+ return (0, deployment_js_1.loadPortalDeployment)(this.#explicitDeployment);
668
+ }
669
+ catch (error) {
670
+ throw new PortalConfigurationError("native qURL deployment trust is invalid", {
671
+ cause: error,
672
+ });
673
+ }
674
+ }
675
+ #verifyLink(deployment) {
676
+ try {
677
+ return (0, qv2_js_1.verifyQv2Link)(this.#qurl, deployment.issuers);
678
+ }
679
+ catch (error) {
680
+ throw new PortalVerificationError("native qURL credential validation failed", {
681
+ cause: error,
682
+ });
683
+ }
684
+ }
685
+ #knockBody(link) {
686
+ const sessionSecret = this.#sessionSecret;
687
+ if (!sessionSecret)
688
+ throw new PortalStateError("portal opener session is not initialized");
689
+ // Immutable JS strings cannot be zeroized. Keep their lifetime to this one
690
+ // serialization and retain private capability material only in mutable
691
+ // buffers between opens.
692
+ const encoded = JSON.stringify({
693
+ headerType: 1,
694
+ aspId: "qurl",
695
+ resId: link.claims.resourcePublicKeyB64,
696
+ usrData: {
697
+ qurl_claims_b64: link.claimsB64,
698
+ qurl_issuer_sig_b64: link.signatureB64,
699
+ qurl_session_secret: sessionSecret.toString("base64url"),
700
+ },
701
+ });
702
+ const body = Buffer.from(encoded, "utf8");
703
+ if (body.byteLength > nhp_wire_js_1.NHP_MAX_BODY_SIZE) {
704
+ body.fill(0);
705
+ throw new PortalVerificationError("verified qURL cannot fit the native NHP knock envelope");
706
+ }
707
+ return body;
708
+ }
709
+ #requireOpen() {
710
+ if (this.#state === "closed")
711
+ throw new PortalOpenerClosedError();
712
+ }
713
+ #isClosed() {
714
+ return this.#state === "closed";
715
+ }
716
+ }
717
+ function validatePortalDescendantSegments(value) {
718
+ if (!Array.isArray(value) || value.length === 0) {
719
+ throw new PortalConfigurationError("portal descendant fetch requires at least one path segment");
720
+ }
721
+ for (let index = 0; index < value.length; index++) {
722
+ const segment = value[index];
723
+ if (typeof segment !== "string") {
724
+ throw new PortalConfigurationError(`portal descendant path segment ${index} must be a string`);
725
+ }
726
+ if (segment === "") {
727
+ throw new PortalConfigurationError(`portal descendant path segment ${index} is empty`);
728
+ }
729
+ if (segment === "." || segment === "..") {
730
+ throw new PortalConfigurationError(`portal descendant path segment ${index} is a dot segment`);
731
+ }
732
+ if (/[\\/?#]/u.test(segment)) {
733
+ throw new PortalConfigurationError(`portal descendant path segment ${index} contains a reserved delimiter`);
734
+ }
735
+ }
736
+ }
737
+ function appendPortalDescendantSegments(target, pathSegments) {
738
+ const separator = target.pathname.endsWith("/") ? "" : "/";
739
+ target.pathname += separator + pathSegments.map(escapePortalDescendantSegment).join("/");
740
+ return target;
741
+ }
742
+ function escapePortalDescendantSegment(segment) {
743
+ const escaped = [];
744
+ for (const byte of Buffer.from(segment, "utf8")) {
745
+ const character = String.fromCharCode(byte);
746
+ // Match Go's url.PathEscape path-segment allow set exactly.
747
+ if ((byte >= 0x41 && byte <= 0x5a) ||
748
+ (byte >= 0x61 && byte <= 0x7a) ||
749
+ (byte >= 0x30 && byte <= 0x39) ||
750
+ "-._~$&+:=@".includes(character)) {
751
+ escaped.push(character);
752
+ }
753
+ else {
754
+ escaped.push(`%${byte.toString(16).toUpperCase().padStart(2, "0")}`);
755
+ }
756
+ }
757
+ return escaped.join("");
758
+ }
759
+ function parseGrant(body, startedAtNanos, startedAtEpochMs, openedAtNanos, openedAtEpochMs) {
760
+ let value;
761
+ try {
762
+ value = (0, strict_json_js_1.parseStrictJson)(body, MAX_ACK_BODY_BYTES);
763
+ }
764
+ catch (error) {
765
+ throw new PortalInvalidReplyError("native NHP ACK body is malformed", { cause: error });
766
+ }
767
+ if (!(0, strict_json_js_1.isStrictJsonObject)(value))
768
+ throw new PortalInvalidReplyError("native NHP ACK must be an object");
769
+ const errCode = value.errCode === undefined ? "" : requirePossiblyEmptyString(value.errCode, "ACK errCode");
770
+ if (errCode !== "" && errCode !== "0") {
771
+ // Match qurl-go's canonical knock-deny grammar: decimal digits, no leading
772
+ // zero, and no allowlist that could hide a new authenticated server code.
773
+ if (!/^[1-9][0-9]*$/.test(errCode))
774
+ throw new PortalInvalidReplyError("native NHP deny code is not canonical decimal");
775
+ // The native opener's deny profile deliberately requires an explicit
776
+ // canonical opnTime:0 and forbids the Go-defined success capabilities.
777
+ if ("sessId" in value) {
778
+ throw new PortalInvalidReplyError("native NHP deny ACK contains success capability fields");
779
+ }
780
+ if (value.opnTime !== 0n) {
781
+ throw new PortalInvalidReplyError("native NHP deny ACK opnTime must be canonical zero");
782
+ }
783
+ if (value.aspToken !== undefined && value.aspToken !== "") {
784
+ throw new PortalInvalidReplyError("native NHP deny ACK contains success capability fields");
785
+ }
786
+ throw new PortalDenyError(errCode);
787
+ }
788
+ const sessionId = requireUnsignedInteger(value.sessId, "ACK session id", UINT64_MAX);
789
+ if (sessionId === 0n)
790
+ throw new PortalInvalidReplyError("native NHP ACK session id must be positive");
791
+ const openSecondsBig = requireUnsignedInteger(value.opnTime, "ACK open time", 0xffffffffn);
792
+ if (openSecondsBig === 0n)
793
+ throw new PortalInvalidReplyError("native NHP ACK open time must be positive");
794
+ const openSeconds = Number(openSecondsBig);
795
+ const resourceUrl = requireString(value.redirectUrl, "ACK resource URL");
796
+ let parsed;
797
+ try {
798
+ parsed = new URL(resourceUrl);
799
+ }
800
+ catch {
801
+ throw new PortalInvalidReplyError("ACK resource URL is invalid");
802
+ }
803
+ if (parsed.hash !== "")
804
+ throw new PortalInvalidReplyError("ACK resource URL must not contain a fragment");
805
+ let origin;
806
+ try {
807
+ origin = normalizedHttpsOrigin(parsed);
808
+ }
809
+ catch (error) {
810
+ throw new PortalInvalidReplyError(error instanceof Error ? error.message : "ACK resource URL has an invalid HTTPS origin", { cause: error });
811
+ }
812
+ const tokenString = requireString(value.aspToken, "ACK application token");
813
+ validateSessionToken(tokenString);
814
+ const token = Buffer.from(tokenString, "ascii");
815
+ const lifetimeNanos = BigInt(openSeconds) * 1000000000n;
816
+ const expiresAtNanos = startedAtNanos + lifetimeNanos;
817
+ let renewAtNanos = expiresAtNanos - renewalLeadNanos(lifetimeNanos);
818
+ const minimumRenewAt = openedAtNanos + BigInt(MIN_RENEWAL_GAP_MS) * 1000000n;
819
+ if (renewAtNanos < minimumRenewAt)
820
+ renewAtNanos = minimumRenewAt;
821
+ if (renewAtNanos > expiresAtNanos)
822
+ renewAtNanos = expiresAtNanos;
823
+ return {
824
+ resourceUrl: parsed.href,
825
+ origin,
826
+ expiresAtNanos,
827
+ renewAtNanos,
828
+ expiresAtEpochMs: startedAtEpochMs + openSeconds * 1_000,
829
+ renewAtEpochMs: openedAtEpochMs + Number(renewAtNanos - openedAtNanos) / 1_000_000,
830
+ openedAtEpochMs,
831
+ token,
832
+ };
833
+ }
834
+ function renewalLeadNanos(lifetimeNanos) {
835
+ let lead = lifetimeNanos / 5n;
836
+ const minimum = BigInt(MIN_RENEWAL_LEAD_MS) * 1000000n;
837
+ const maximum = BigInt(MAX_RENEWAL_LEAD_MS) * 1000000n;
838
+ if (lead < minimum)
839
+ lead = minimum;
840
+ if (lead > maximum)
841
+ lead = maximum;
842
+ if (lead >= lifetimeNanos)
843
+ lead = lifetimeNanos / 2n;
844
+ return lead;
845
+ }
846
+ function validatedCellForLink(link, deployment) {
847
+ const cell = deployment.cells.get((0, deployment_js_1.fingerprintKey)(link.claims.cellPublicKey));
848
+ if (!cell) {
849
+ throw new PortalConfigurationError("verified qURL names a cell outside the native catalog");
850
+ }
851
+ // The 64-bit fingerprint is only an efficient map index. The signed cell
852
+ // identity must still match the exact deployment key used by Noise.
853
+ if (!(0, node_crypto_1.timingSafeEqual)(cell.serverPublicKey, link.claims.cellPublicKey)) {
854
+ throw new PortalConfigurationError("deployment cell key does not match the signed cell key");
855
+ }
856
+ return cell;
857
+ }
858
+ function nanosToCeilingMilliseconds(value) {
859
+ if (value <= 0n)
860
+ return 0;
861
+ return Number((value + 999999n) / 1000000n);
862
+ }
863
+ function dateFromEpoch(value) {
864
+ return value === undefined ? undefined : new Date(value);
865
+ }
866
+ function requireString(value, name) {
867
+ if (typeof value !== "string" || value === "")
868
+ throw new PortalInvalidReplyError(`${name} is missing or invalid`);
869
+ return value;
870
+ }
871
+ function requirePossiblyEmptyString(value, name) {
872
+ if (typeof value !== "string")
873
+ throw new PortalInvalidReplyError(`${name} is missing or invalid`);
874
+ return value;
875
+ }
876
+ function requireUnsignedInteger(value, name, maximum) {
877
+ if (typeof value !== "bigint") {
878
+ throw new PortalInvalidReplyError(`${name} must be an unsigned decimal integer`);
879
+ }
880
+ if (value < 0n || value > maximum)
881
+ throw new PortalInvalidReplyError(`${name} is outside its range`);
882
+ return value;
883
+ }
884
+ function normalizedHttpsOrigin(url) {
885
+ if (url.protocol !== "https:" ||
886
+ url.hostname === "" ||
887
+ url.username !== "" ||
888
+ url.password !== "") {
889
+ throw new PortalStateError("ACK resource URL must have a valid HTTPS origin");
890
+ }
891
+ const port = url.port === "" ? "443" : url.port;
892
+ if (!/^[0-9]+$/.test(port) || Number(port) < 1 || Number(port) > 65_535) {
893
+ throw new PortalStateError("ACK resource URL has an invalid port");
894
+ }
895
+ return `https://${url.hostname.toLowerCase()}:${Number(port)}`;
896
+ }
897
+ function validateSessionToken(value, decode = (part) => Buffer.from(part, "base64url")) {
898
+ // This is a defense-in-depth capability bound. The ACK envelope limit above
899
+ // currently makes it unreachable, but this validator also has direct tests.
900
+ if (value.length > 4_096 || value.trim() !== value || hasUnsafeTokenByte(value)) {
901
+ throw new PortalInvalidReplyError("ACK application token has an invalid shape");
902
+ }
903
+ const parts = value.split(".");
904
+ if (parts.length !== 2 || parts.some((part) => part === "" || !/^[A-Za-z0-9_-]+$/.test(part))) {
905
+ throw new PortalInvalidReplyError("ACK application token has an invalid shape");
906
+ }
907
+ let signatureLength = 0;
908
+ for (let index = 0; index < parts.length; index++) {
909
+ const part = parts[index];
910
+ const decoded = decode(part);
911
+ try {
912
+ if (decoded.toString("base64url") !== part) {
913
+ throw new PortalInvalidReplyError("ACK application token is not canonical base64url");
914
+ }
915
+ if (index === 1)
916
+ signatureLength = decoded.byteLength;
917
+ }
918
+ finally {
919
+ // The token is a bearer capability. Wipe mutable validation copies even
920
+ // though its unavoidable parsed string cannot be zeroized in JavaScript.
921
+ decoded.fill(0);
922
+ }
923
+ }
924
+ if (signatureLength !== 32) {
925
+ throw new PortalInvalidReplyError("ACK application token signature has an invalid length");
926
+ }
927
+ }
928
+ function hasUnsafeTokenByte(value) {
929
+ for (let index = 0; index < value.length; index++) {
930
+ const code = value.charCodeAt(index);
931
+ if (code <= 0x20 || code === 0x7f || code > 0x7f)
932
+ return true;
933
+ }
934
+ return false;
935
+ }
936
+ function authorizeHeaders(input, token) {
937
+ const headers = new Headers(input);
938
+ const preserved = [];
939
+ for (const raw of (headers.get("cookie") ?? "").split(";")) {
940
+ const item = raw.trim();
941
+ if (item === "")
942
+ continue;
943
+ const equals = item.indexOf("=");
944
+ if (equals <= 0)
945
+ continue;
946
+ const name = item.slice(0, equals).trim();
947
+ const value = item.slice(equals + 1).trim();
948
+ if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name))
949
+ continue;
950
+ if (name === SESSION_COOKIE)
951
+ continue;
952
+ if (!isValidCookieValue(value))
953
+ continue;
954
+ preserved.push(`${name}=${value}`);
955
+ }
956
+ preserved.push(`${SESSION_COOKIE}=${token}`);
957
+ headers.set("cookie", preserved.join("; "));
958
+ return headers;
959
+ }
960
+ function isValidCookieValue(value) {
961
+ const unquoted = value.length >= 2 && value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value;
962
+ for (let index = 0; index < unquoted.length; index++) {
963
+ const code = unquoted.charCodeAt(index);
964
+ if (code < 0x21 ||
965
+ code > 0x7e ||
966
+ code === 0x22 ||
967
+ code === 0x2c ||
968
+ code === 0x3b ||
969
+ code === 0x5c) {
970
+ return false;
971
+ }
972
+ }
973
+ return true;
974
+ }
975
+ function isReplayableBody(body) {
976
+ return (body === null ||
977
+ typeof body === "string" ||
978
+ body instanceof URLSearchParams ||
979
+ body instanceof ArrayBuffer ||
980
+ ArrayBuffer.isView(body) ||
981
+ body instanceof Blob ||
982
+ body instanceof FormData);
983
+ }
984
+ async function discardResponseBody(response) {
985
+ try {
986
+ await response.body?.cancel();
987
+ }
988
+ catch {
989
+ // Redirect refusal must keep its stable error even for a broken body shim.
990
+ }
991
+ }
992
+ async function discardRequestBody(body) {
993
+ try {
994
+ if (body instanceof ReadableStream) {
995
+ await body.cancel();
996
+ return;
997
+ }
998
+ if (!body || typeof body !== "object")
999
+ return;
1000
+ const destroy = Reflect.get(body, "destroy");
1001
+ if (typeof destroy === "function") {
1002
+ Reflect.apply(destroy, body, []);
1003
+ return;
1004
+ }
1005
+ const asyncIterator = Reflect.get(body, Symbol.asyncIterator);
1006
+ if (typeof asyncIterator === "function") {
1007
+ const iterator = Reflect.apply(asyncIterator, body, []);
1008
+ await iterator.return?.();
1009
+ return;
1010
+ }
1011
+ const iteratorFactory = Reflect.get(body, Symbol.iterator);
1012
+ if (typeof iteratorFactory === "function") {
1013
+ const iterator = Reflect.apply(iteratorFactory, body, []);
1014
+ iterator.return?.();
1015
+ }
1016
+ }
1017
+ catch {
1018
+ // Invalid request handling must keep its stable error for a broken stream.
1019
+ }
1020
+ }
1021
+ function responseMatchesRequestUrl(responseUrl, requestUrl) {
1022
+ if (responseUrl === "" || responseUrl === requestUrl)
1023
+ return true;
1024
+ try {
1025
+ return new URL(responseUrl).href === new URL(requestUrl).href;
1026
+ }
1027
+ catch {
1028
+ return false;
1029
+ }
1030
+ }
1031
+ function throwIfAborted(signal) {
1032
+ if (signal.aborted)
1033
+ throw signal.reason ?? new DOMException("Aborted", "AbortError");
1034
+ }
1035
+ function linkAbortSignal(signal, controller) {
1036
+ if (!signal)
1037
+ return () => undefined;
1038
+ if (signal.aborted) {
1039
+ controller.abort(signal.reason);
1040
+ return () => undefined;
1041
+ }
1042
+ const abort = () => controller.abort(signal.reason);
1043
+ signal.addEventListener("abort", abort, { once: true });
1044
+ return () => signal.removeEventListener("abort", abort);
1045
+ }
1046
+ function waitForPromise(promise, signal) {
1047
+ if (!signal)
1048
+ return promise;
1049
+ if (signal.aborted)
1050
+ return Promise.reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
1051
+ return new Promise((resolve, reject) => {
1052
+ const abort = () => {
1053
+ cleanup();
1054
+ reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
1055
+ };
1056
+ const cleanup = () => signal.removeEventListener("abort", abort);
1057
+ signal.addEventListener("abort", abort, { once: true });
1058
+ promise.then(() => {
1059
+ cleanup();
1060
+ resolve();
1061
+ }, (error) => {
1062
+ cleanup();
1063
+ reject(error);
1064
+ });
1065
+ });
1066
+ }
1067
+ exports.portalOpenerTesting = {
1068
+ validatedCellForLink,
1069
+ validateSessionToken,
1070
+ };