@getstrata/core 2.0.2 → 2.0.3

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 (32) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/core/database/dialect.d.ts +4 -0
  3. package/dist/core/database/mysqlConnection.d.ts +12 -2
  4. package/dist/core/database/sqliteConnection.d.ts +2 -1
  5. package/dist/core/database/transaction.d.ts +2 -2
  6. package/dist/core/database/transactionControl.d.ts +2 -1
  7. package/dist/core/events/outbox/index.d.ts +54 -0
  8. package/dist/core/events/outbox/migration.d.ts +7 -0
  9. package/dist/core/http/requestMetaContext.d.ts +2 -0
  10. package/dist/core/http/response.d.ts +2 -1
  11. package/dist/core/security/safeFetch.d.ts +1 -0
  12. package/dist/entries/audit/exportAuditLogs.js +119 -17
  13. package/dist/entries/auth/basicAuthGuard.js +18 -0
  14. package/dist/entries/auth/oauth/oidcProvider.js +100 -17
  15. package/dist/entries/auth/oauth/providers.js +100 -17
  16. package/dist/entries/auth/oauth/samlProvider.js +18 -0
  17. package/dist/entries/auth/oneTimeToken.js +18 -0
  18. package/dist/entries/auth/passwordLogin.js +18 -0
  19. package/dist/entries/auth/saml/samlServiceProvider.js +18 -0
  20. package/dist/entries/auth/scimAuthMiddleware.js +19 -0
  21. package/dist/entries/database/migrations.js +18 -0
  22. package/dist/entries/database/sqliteConnection.js +101 -7
  23. package/dist/entries/events/outbox.js +1 -0
  24. package/dist/entries/http/response.js +39 -5
  25. package/dist/entries/jobs/exportAuditLogsJob.js +119 -17
  26. package/dist/entries/security/safeFetch.js +100 -17
  27. package/dist/entries/tenant/databaseTenantContext.js +19 -0
  28. package/dist/entries/tenant/tenantDatabaseScope.js +19 -0
  29. package/dist/entries/tracing/tracingMiddleware.js +100 -17
  30. package/dist/framework/public-api.d.ts +3 -1
  31. package/dist/index.js +591 -50
  32. package/package.json +6 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # @getstrata/core changelog
2
2
 
3
+ ## 2.0.3
4
+
5
+ - Persist transactional events with leased tenant-aware SQL outbox workers
6
+
3
7
  ## 2.0.2
4
8
 
5
9
  - Adopt TypeScript 7.0.2 in framework and generated apps; preserve tested TypeScript 5.9 and 6.x consumer compatibility.
@@ -4,6 +4,10 @@ interface SqlDialect {
4
4
  placeholder(index: number): string;
5
5
  quoteIdentifier(identifier: string): string;
6
6
  nowExpression(): string;
7
+ /** Authoritative database wall clock for persisted lease/retry deadlines. */
8
+ epochMillisecondsExpression(): string;
9
+ /** Current reads/row locks; SQLite serializes writes at transaction admission. */
10
+ rowLockClause(skipLocked?: boolean): string;
7
11
  /** Render a Date as a literal this engine accepts for a timestamp column. */
8
12
  timestampValue(value: Date): string;
9
13
  returningClause(columns: string): string;
@@ -1,18 +1,28 @@
1
- import type { ActiveDatabaseHandle } from "./connectionContext.ts";
1
+ import { type ActiveDatabaseHandle } from "./connectionContext.ts";
2
2
  type MysqlExecutable = {
3
3
  execute: (sql: string, params?: unknown[]) => Promise<[unknown, unknown]>;
4
+ query?: (sql: string) => Promise<[unknown, unknown]>;
4
5
  end?: () => Promise<void>;
6
+ getConnection?: () => Promise<MysqlPoolConnection>;
5
7
  };
6
8
  type MysqlPool = MysqlExecutable & {
7
9
  end(): Promise<void>;
8
10
  on(event: "connection", listener: (connection: unknown) => void): void;
9
11
  };
12
+ type MysqlPoolConnection = MysqlExecutable & {
13
+ beginTransaction(): Promise<void>;
14
+ commit(): Promise<void>;
15
+ rollback(): Promise<void>;
16
+ release(): void;
17
+ destroy(): void;
18
+ };
10
19
  type MysqlConnection = ActiveDatabaseHandle & {
20
+ begin<T>(callback: (transaction: ActiveDatabaseHandle) => Promise<T>): Promise<T>;
11
21
  close(): Promise<void>;
12
22
  };
13
23
  declare function resetMysqlLoaderForTests(importer?: () => Promise<unknown>): void;
14
24
  declare function createMysqlConnectionFromPool(pool: MysqlExecutable): MysqlConnection;
15
25
  declare function createMysqlPool(url: string): Promise<MysqlPool>;
16
26
  declare function createMysqlConnection(url: string): MysqlConnection;
17
- export type { MysqlConnection, MysqlExecutable, MysqlPool };
27
+ export type { MysqlConnection, MysqlExecutable, MysqlPool, MysqlPoolConnection };
18
28
  export { createMysqlConnection, createMysqlConnectionFromPool, createMysqlPool, resetMysqlLoaderForTests, };
@@ -1,5 +1,6 @@
1
- import type { ActiveDatabaseHandle } from "./connectionContext.ts";
1
+ import { type ActiveDatabaseHandle } from "./connectionContext.ts";
2
2
  type SqliteConnection = ActiveDatabaseHandle & {
3
+ begin<T>(callback: (transaction: ActiveDatabaseHandle) => Promise<T>): Promise<T>;
3
4
  close(): void;
4
5
  };
5
6
  declare function createSqliteConnection(filename: string): SqliteConnection;
@@ -1,4 +1,4 @@
1
1
  import type { DatabaseConnection } from "./baseRepository.ts";
2
- import { requestTransactionRollback } from "./transactionControl.ts";
2
+ import { hasActiveTransaction, requestTransactionRollback } from "./transactionControl.ts";
3
3
  declare function runInTransaction<TValue>(operation: (connection: DatabaseConnection) => Promise<TValue>): Promise<TValue>;
4
- export { requestTransactionRollback, runInTransaction };
4
+ export { hasActiveTransaction, requestTransactionRollback, runInTransaction };
@@ -1,5 +1,6 @@
1
+ declare function hasActiveTransaction(): boolean;
1
2
  declare function requestTransactionRollback(): void;
2
3
  declare function commitOrRollbackScope<T>(result: T): Promise<T>;
3
4
  declare function settleTransaction<T>(work: () => Promise<T>): Promise<T>;
4
5
  declare function runWithTransactionScope<T>(callback: () => T | Promise<T>): Promise<T>;
5
- export { commitOrRollbackScope, requestTransactionRollback, runWithTransactionScope, settleTransaction, };
6
+ export { commitOrRollbackScope, hasActiveTransaction, requestTransactionRollback, runWithTransactionScope, settleTransaction, };
@@ -0,0 +1,54 @@
1
+ import { type TenantContext } from "../../tenant/tenantContext";
2
+ interface DurableEvent {
3
+ readonly id: string;
4
+ readonly name: string;
5
+ readonly version: number;
6
+ readonly tenantId: number | null;
7
+ readonly payload: unknown;
8
+ }
9
+ interface DurableListener {
10
+ readonly name: string;
11
+ readonly event: string;
12
+ handle(event: DurableEvent, context: {
13
+ signal: AbortSignal;
14
+ }): Promise<void>;
15
+ }
16
+ interface SqlOutboxOptions {
17
+ listeners: readonly DurableListener[];
18
+ leaseMs?: number;
19
+ maxAttempts?: number;
20
+ retryDelayMs?: number;
21
+ maxPayloadBytes?: number;
22
+ resolveTenant?: (id: number) => Promise<TenantContext | null>;
23
+ }
24
+ /** Opt-in SQL effects. Publishing writes only; workers execute listeners at least once. */
25
+ declare class SqlOutbox {
26
+ private readonly listeners;
27
+ private readonly leaseMs;
28
+ private readonly maxAttempts;
29
+ private readonly retryDelayMs;
30
+ private readonly maxPayloadBytes;
31
+ private readonly tenantResolver;
32
+ constructor(options: SqlOutboxOptions);
33
+ publish(name: string, payload: unknown, options?: {
34
+ id?: string;
35
+ version?: number;
36
+ }): Promise<string>;
37
+ private persist;
38
+ private claim;
39
+ private renew;
40
+ private finish;
41
+ processNext(options?: {
42
+ signal?: AbortSignal;
43
+ }): Promise<boolean>;
44
+ /** Stop admission on cancellation; handlers must observe the supplied signal to drain. */
45
+ work(options: {
46
+ signal: AbortSignal;
47
+ pollMs?: number;
48
+ }): Promise<void>;
49
+ /** Operator operation: retains the event and atomically makes one failed delivery eligible. */
50
+ replay(eventId: string, listenerName: string): Promise<void>;
51
+ }
52
+ export { createOutboxMigration } from "./migration";
53
+ export type { DurableEvent, DurableListener, SqlOutboxOptions };
54
+ export { SqlOutbox };
@@ -0,0 +1,7 @@
1
+ import type { Migration } from "../../database/migrations/types";
2
+ import type { DatabaseDriver } from "../../database/schema/driver";
3
+ /** Infrastructure tables belong to core; apps choose the file migration name and RLS mode. */
4
+ declare function createOutboxMigration(name: string, driver: DatabaseDriver, options?: {
5
+ rls?: boolean;
6
+ }): Migration;
7
+ export { createOutboxMigration };
@@ -4,6 +4,8 @@ type RequestMeta = {
4
4
  request?: Request;
5
5
  /** Registered route template; never a URL supplied by the client. */
6
6
  routeTemplate?: string;
7
+ /** API error contracts take precedence over client content negotiation. */
8
+ errorFormat?: "json";
7
9
  flash?: {
8
10
  level: string;
9
11
  message: string;
@@ -6,4 +6,5 @@ declare function errorResponse(error: unknown): Response;
6
6
  declare function withErrorHandling<TArgs extends unknown[]>(handler: (...args: TArgs) => Response | Promise<Response>): (...args: TArgs) => Promise<Response>;
7
7
  declare function withJsonErrorHandling<TArgs extends unknown[]>(handler: (...args: TArgs) => Response | Promise<Response>): (...args: TArgs) => Promise<Response>;
8
8
  declare function createJsonErrorMiddleware(): Middleware;
9
- export { createdResponse, createJsonErrorMiddleware, errorResponse, jsonResponse, noContentResponse, withErrorHandling, withJsonErrorHandling, };
9
+ declare function createWebErrorMiddleware(): Middleware;
10
+ export { createdResponse, createJsonErrorMiddleware, createWebErrorMiddleware, errorResponse, jsonResponse, noContentResponse, withErrorHandling, withJsonErrorHandling, };
@@ -1,5 +1,6 @@
1
1
  declare const DEFAULT_FETCH_TIMEOUT_MS = 10000;
2
2
  interface SafeFetchOptions {
3
+ /** One deadline covering DNS, redirects, headers, and response body consumption. */
3
4
  timeoutMs?: number;
4
5
  maxRedirects?: number;
5
6
  allowHttp?: boolean;
@@ -164,6 +164,7 @@ async function runWithTransactionScope(callback) {
164
164
  var TransactionRollback, transactionScopes;
165
165
  var init_transactionControl = __esm(() => {
166
166
  init_asyncContextStore();
167
+ init_connectionContext();
167
168
  TransactionRollback = class TransactionRollback extends Error {
168
169
  result;
169
170
  constructor(result) {
@@ -452,6 +453,12 @@ var init_dialect = __esm(() => {
452
453
  nowExpression() {
453
454
  return "NOW()";
454
455
  },
456
+ epochMillisecondsExpression() {
457
+ return "CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)";
458
+ },
459
+ rowLockClause(skipLocked = false) {
460
+ return ` FOR UPDATE${skipLocked ? " SKIP LOCKED" : ""}`;
461
+ },
455
462
  timestampValue(value) {
456
463
  return value.toISOString();
457
464
  },
@@ -490,6 +497,12 @@ var init_dialect = __esm(() => {
490
497
  nowExpression() {
491
498
  return "CURRENT_TIMESTAMP";
492
499
  },
500
+ epochMillisecondsExpression() {
501
+ return "CAST(UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000 AS UNSIGNED)";
502
+ },
503
+ rowLockClause(skipLocked = false) {
504
+ return ` FOR UPDATE${skipLocked ? " SKIP LOCKED" : ""}`;
505
+ },
493
506
  timestampValue(value) {
494
507
  return value.toISOString().slice(0, 19).replace("T", " ");
495
508
  },
@@ -528,6 +541,12 @@ var init_dialect = __esm(() => {
528
541
  nowExpression() {
529
542
  return "strftime('%Y-%m-%dT%H:%M:%fZ', 'now')";
530
543
  },
544
+ epochMillisecondsExpression() {
545
+ return "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)";
546
+ },
547
+ rowLockClause() {
548
+ return "";
549
+ },
531
550
  timestampValue(value) {
532
551
  return value.toISOString();
533
552
  },
@@ -1077,50 +1096,133 @@ async function resolveSafeOutboundTarget(rawUrl, options = {}) {
1077
1096
 
1078
1097
  // ../../src/core/security/safeFetch.ts
1079
1098
  var DEFAULT_FETCH_TIMEOUT_MS = 1e4;
1099
+ function untilAborted(work, signal) {
1100
+ signal.throwIfAborted();
1101
+ return new Promise((resolve, reject) => {
1102
+ const onAbort = () => reject(signal.reason);
1103
+ signal.addEventListener("abort", onAbort, { once: true });
1104
+ work.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
1105
+ });
1106
+ }
1107
+ function preserveResponseMetadata(response, original) {
1108
+ Object.defineProperties(response, {
1109
+ url: { value: original.url },
1110
+ redirected: { value: original.redirected },
1111
+ type: { value: original.type }
1112
+ });
1113
+ const clone = response.clone.bind(response);
1114
+ response.clone = () => preserveResponseMetadata(clone(), original);
1115
+ return response;
1116
+ }
1117
+ function withBodyDeadline(response, signal, release) {
1118
+ if (!response.body) {
1119
+ release();
1120
+ return response;
1121
+ }
1122
+ const reader = response.body.getReader();
1123
+ let stopped = false;
1124
+ let bodyController;
1125
+ const stop = (reason, errorBody) => {
1126
+ if (stopped)
1127
+ return;
1128
+ stopped = true;
1129
+ release();
1130
+ signal.removeEventListener("abort", onAbort);
1131
+ if (errorBody)
1132
+ bodyController.error(reason);
1133
+ reader.cancel(reason).catch(() => {}).finally(() => reader.releaseLock());
1134
+ };
1135
+ const onAbort = () => stop(signal.reason, true);
1136
+ const body = new ReadableStream({
1137
+ start(controller) {
1138
+ bodyController = controller;
1139
+ signal.addEventListener("abort", onAbort, { once: true });
1140
+ if (signal.aborted)
1141
+ onAbort();
1142
+ },
1143
+ async pull(controller) {
1144
+ try {
1145
+ const result = await untilAborted(reader.read(), signal);
1146
+ if (stopped)
1147
+ return;
1148
+ if (result.done) {
1149
+ stopped = true;
1150
+ release();
1151
+ signal.removeEventListener("abort", onAbort);
1152
+ reader.releaseLock();
1153
+ controller.close();
1154
+ } else {
1155
+ controller.enqueue(result.value);
1156
+ }
1157
+ } catch (error) {
1158
+ stop(error, true);
1159
+ }
1160
+ },
1161
+ cancel(reason) {
1162
+ stop(reason, false);
1163
+ }
1164
+ }, { highWaterMark: 0 });
1165
+ return preserveResponseMetadata(new Response(body, {
1166
+ status: response.status,
1167
+ statusText: response.statusText,
1168
+ headers: response.headers
1169
+ }), response);
1170
+ }
1080
1171
  async function safeFetch(input, init = {}, options = {}) {
1172
+ init.signal?.throwIfAborted();
1081
1173
  const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
1174
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
1175
+ throw new RangeError("safeFetch timeoutMs must be a positive finite number.");
1176
+ }
1082
1177
  const maxRedirects = options.maxRedirects ?? 0;
1083
- const resolveDns = options.resolveDns ?? true;
1084
1178
  const urlOptions = {
1085
1179
  allowHttp: options.allowHttp,
1086
- resolveDns,
1180
+ resolveDns: options.resolveDns ?? true,
1087
1181
  allowPrivate: options.allowPrivate
1088
1182
  };
1089
1183
  const controller = new AbortController;
1090
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
1184
+ const forwardAbort = () => controller.abort(init.signal?.reason);
1185
+ init.signal?.addEventListener("abort", forwardAbort, { once: true });
1186
+ const timeout = setTimeout(() => controller.abort(new DOMException("The operation timed out.", "TimeoutError")), timeoutMs);
1187
+ const release = () => {
1188
+ clearTimeout(timeout);
1189
+ init.signal?.removeEventListener("abort", forwardAbort);
1190
+ };
1091
1191
  try {
1092
- let current = await resolveSafeOutboundTarget(input, urlOptions);
1192
+ let current = await untilAborted(resolveSafeOutboundTarget(input, urlOptions), controller.signal);
1093
1193
  let redirectCount = 0;
1094
1194
  while (true) {
1195
+ controller.signal.throwIfAborted();
1095
1196
  const address = current.addresses[0];
1096
1197
  const fetchUrl = address ? pinUrlToAddress(current.url, address).toString() : current.url.toString();
1097
1198
  const headers = new Headers(init.headers);
1098
- if (address && !headers.has("host")) {
1199
+ if (address && !headers.has("host"))
1099
1200
  headers.set("Host", current.url.host);
1100
- }
1101
1201
  const fetchInit = {
1102
1202
  ...init,
1103
1203
  headers,
1104
1204
  signal: controller.signal,
1105
1205
  redirect: "manual"
1106
1206
  };
1107
- if (address) {
1207
+ if (address)
1108
1208
  fetchInit.tls = { serverName: current.url.hostname };
1109
- }
1110
- const response = await fetch(fetchUrl, fetchInit);
1209
+ const response = await untilAborted(fetch(fetchUrl, fetchInit), controller.signal);
1111
1210
  if (response.status >= 300 && response.status < 400) {
1112
1211
  const location = response.headers.get("location");
1113
- if (!location || redirectCount >= maxRedirects) {
1114
- return response;
1212
+ if (location && redirectCount < maxRedirects) {
1213
+ if (response.body)
1214
+ await untilAborted(response.body.cancel(), controller.signal);
1215
+ current = await untilAborted(resolveSafeOutboundTarget(new URL(location, current.url).toString(), urlOptions), controller.signal);
1216
+ redirectCount++;
1217
+ continue;
1115
1218
  }
1116
- current = await resolveSafeOutboundTarget(new URL(location, current.url).toString(), urlOptions);
1117
- redirectCount += 1;
1118
- continue;
1119
1219
  }
1120
- return response;
1220
+ return withBodyDeadline(response, controller.signal, release);
1121
1221
  }
1122
- } finally {
1123
- clearTimeout(timeout);
1222
+ } catch (error) {
1223
+ controller.abort(error);
1224
+ release();
1225
+ throw error;
1124
1226
  }
1125
1227
  }
1126
1228
 
@@ -171,6 +171,12 @@ var init_dialect = __esm(() => {
171
171
  nowExpression() {
172
172
  return "NOW()";
173
173
  },
174
+ epochMillisecondsExpression() {
175
+ return "CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)";
176
+ },
177
+ rowLockClause(skipLocked = false) {
178
+ return ` FOR UPDATE${skipLocked ? " SKIP LOCKED" : ""}`;
179
+ },
174
180
  timestampValue(value) {
175
181
  return value.toISOString();
176
182
  },
@@ -209,6 +215,12 @@ var init_dialect = __esm(() => {
209
215
  nowExpression() {
210
216
  return "CURRENT_TIMESTAMP";
211
217
  },
218
+ epochMillisecondsExpression() {
219
+ return "CAST(UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000 AS UNSIGNED)";
220
+ },
221
+ rowLockClause(skipLocked = false) {
222
+ return ` FOR UPDATE${skipLocked ? " SKIP LOCKED" : ""}`;
223
+ },
212
224
  timestampValue(value) {
213
225
  return value.toISOString().slice(0, 19).replace("T", " ");
214
226
  },
@@ -247,6 +259,12 @@ var init_dialect = __esm(() => {
247
259
  nowExpression() {
248
260
  return "strftime('%Y-%m-%dT%H:%M:%fZ', 'now')";
249
261
  },
262
+ epochMillisecondsExpression() {
263
+ return "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)";
264
+ },
265
+ rowLockClause() {
266
+ return "";
267
+ },
250
268
  timestampValue(value) {
251
269
  return value.toISOString();
252
270
  },
@@ -264,50 +264,133 @@ async function resolveSafeOutboundTarget(rawUrl, options = {}) {
264
264
 
265
265
  // ../../src/core/security/safeFetch.ts
266
266
  var DEFAULT_FETCH_TIMEOUT_MS = 1e4;
267
+ function untilAborted(work, signal) {
268
+ signal.throwIfAborted();
269
+ return new Promise((resolve, reject) => {
270
+ const onAbort = () => reject(signal.reason);
271
+ signal.addEventListener("abort", onAbort, { once: true });
272
+ work.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
273
+ });
274
+ }
275
+ function preserveResponseMetadata(response, original) {
276
+ Object.defineProperties(response, {
277
+ url: { value: original.url },
278
+ redirected: { value: original.redirected },
279
+ type: { value: original.type }
280
+ });
281
+ const clone = response.clone.bind(response);
282
+ response.clone = () => preserveResponseMetadata(clone(), original);
283
+ return response;
284
+ }
285
+ function withBodyDeadline(response, signal, release) {
286
+ if (!response.body) {
287
+ release();
288
+ return response;
289
+ }
290
+ const reader = response.body.getReader();
291
+ let stopped = false;
292
+ let bodyController;
293
+ const stop = (reason, errorBody) => {
294
+ if (stopped)
295
+ return;
296
+ stopped = true;
297
+ release();
298
+ signal.removeEventListener("abort", onAbort);
299
+ if (errorBody)
300
+ bodyController.error(reason);
301
+ reader.cancel(reason).catch(() => {}).finally(() => reader.releaseLock());
302
+ };
303
+ const onAbort = () => stop(signal.reason, true);
304
+ const body = new ReadableStream({
305
+ start(controller) {
306
+ bodyController = controller;
307
+ signal.addEventListener("abort", onAbort, { once: true });
308
+ if (signal.aborted)
309
+ onAbort();
310
+ },
311
+ async pull(controller) {
312
+ try {
313
+ const result = await untilAborted(reader.read(), signal);
314
+ if (stopped)
315
+ return;
316
+ if (result.done) {
317
+ stopped = true;
318
+ release();
319
+ signal.removeEventListener("abort", onAbort);
320
+ reader.releaseLock();
321
+ controller.close();
322
+ } else {
323
+ controller.enqueue(result.value);
324
+ }
325
+ } catch (error) {
326
+ stop(error, true);
327
+ }
328
+ },
329
+ cancel(reason) {
330
+ stop(reason, false);
331
+ }
332
+ }, { highWaterMark: 0 });
333
+ return preserveResponseMetadata(new Response(body, {
334
+ status: response.status,
335
+ statusText: response.statusText,
336
+ headers: response.headers
337
+ }), response);
338
+ }
267
339
  async function safeFetch(input, init = {}, options = {}) {
340
+ init.signal?.throwIfAborted();
268
341
  const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
342
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
343
+ throw new RangeError("safeFetch timeoutMs must be a positive finite number.");
344
+ }
269
345
  const maxRedirects = options.maxRedirects ?? 0;
270
- const resolveDns = options.resolveDns ?? true;
271
346
  const urlOptions = {
272
347
  allowHttp: options.allowHttp,
273
- resolveDns,
348
+ resolveDns: options.resolveDns ?? true,
274
349
  allowPrivate: options.allowPrivate
275
350
  };
276
351
  const controller = new AbortController;
277
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
352
+ const forwardAbort = () => controller.abort(init.signal?.reason);
353
+ init.signal?.addEventListener("abort", forwardAbort, { once: true });
354
+ const timeout = setTimeout(() => controller.abort(new DOMException("The operation timed out.", "TimeoutError")), timeoutMs);
355
+ const release = () => {
356
+ clearTimeout(timeout);
357
+ init.signal?.removeEventListener("abort", forwardAbort);
358
+ };
278
359
  try {
279
- let current = await resolveSafeOutboundTarget(input, urlOptions);
360
+ let current = await untilAborted(resolveSafeOutboundTarget(input, urlOptions), controller.signal);
280
361
  let redirectCount = 0;
281
362
  while (true) {
363
+ controller.signal.throwIfAborted();
282
364
  const address = current.addresses[0];
283
365
  const fetchUrl = address ? pinUrlToAddress(current.url, address).toString() : current.url.toString();
284
366
  const headers = new Headers(init.headers);
285
- if (address && !headers.has("host")) {
367
+ if (address && !headers.has("host"))
286
368
  headers.set("Host", current.url.host);
287
- }
288
369
  const fetchInit = {
289
370
  ...init,
290
371
  headers,
291
372
  signal: controller.signal,
292
373
  redirect: "manual"
293
374
  };
294
- if (address) {
375
+ if (address)
295
376
  fetchInit.tls = { serverName: current.url.hostname };
296
- }
297
- const response = await fetch(fetchUrl, fetchInit);
377
+ const response = await untilAborted(fetch(fetchUrl, fetchInit), controller.signal);
298
378
  if (response.status >= 300 && response.status < 400) {
299
379
  const location = response.headers.get("location");
300
- if (!location || redirectCount >= maxRedirects) {
301
- return response;
380
+ if (location && redirectCount < maxRedirects) {
381
+ if (response.body)
382
+ await untilAborted(response.body.cancel(), controller.signal);
383
+ current = await untilAborted(resolveSafeOutboundTarget(new URL(location, current.url).toString(), urlOptions), controller.signal);
384
+ redirectCount++;
385
+ continue;
302
386
  }
303
- current = await resolveSafeOutboundTarget(new URL(location, current.url).toString(), urlOptions);
304
- redirectCount += 1;
305
- continue;
306
387
  }
307
- return response;
388
+ return withBodyDeadline(response, controller.signal, release);
308
389
  }
309
- } finally {
310
- clearTimeout(timeout);
390
+ } catch (error) {
391
+ controller.abort(error);
392
+ release();
393
+ throw error;
311
394
  }
312
395
  }
313
396