@getstrata/core 2.1.0 → 2.2.1

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 (74) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/core/cache/createCacheStore.d.ts +3 -1
  3. package/dist/core/cache/redisCacheStore.d.ts +21 -8
  4. package/dist/core/cache/repository.d.ts +3 -2
  5. package/dist/core/cache/store.d.ts +4 -1
  6. package/dist/core/cache/taggedCache.d.ts +1 -1
  7. package/dist/core/contracts/di.d.ts +4 -2
  8. package/dist/core/contracts/serviceTokens.d.ts +7 -1
  9. package/dist/core/database/baseRepository.d.ts +0 -1
  10. package/dist/core/database/index.d.ts +1 -1
  11. package/dist/core/database/model.d.ts +51 -22
  12. package/dist/core/database/repositoryQuery.d.ts +16 -1
  13. package/dist/core/database/types.d.ts +25 -2
  14. package/dist/core/database/whereBuilder.d.ts +5 -5
  15. package/dist/core/events/deferredModelEvents.d.ts +2 -1
  16. package/dist/core/http/loginThrottleMiddleware.d.ts +6 -5
  17. package/dist/core/http/memoryThrottleMiddleware.d.ts +11 -3
  18. package/dist/core/http/requestMetaContext.d.ts +2 -0
  19. package/dist/core/http/scimThrottleMiddleware.d.ts +12 -3
  20. package/dist/core/http/throttleMiddleware.d.ts +24 -6
  21. package/dist/core/lifecycle/gracefulShutdown.d.ts +33 -5
  22. package/dist/core/queue/createAppQueue.d.ts +2 -1
  23. package/dist/core/queue/failedJobService.d.ts +3 -1
  24. package/dist/core/queue/index.d.ts +17 -2
  25. package/dist/core/queue/jobRunner.d.ts +7 -1
  26. package/dist/core/queue/publicQueue.d.ts +2 -1
  27. package/dist/core/queue/queueConfig.d.ts +3 -0
  28. package/dist/core/queue/redisQueue.d.ts +6 -7
  29. package/dist/core/queue/redisQueueKeys.d.ts +8 -0
  30. package/dist/core/queue/redisStreams.d.ts +73 -0
  31. package/dist/core/queue/resilientQueue.d.ts +2 -2
  32. package/dist/core/queue/types.d.ts +1 -0
  33. package/dist/core/runtime/applicationRegistry.d.ts +2 -1
  34. package/dist/core/runtime/boundedThrottleStore.d.ts +29 -0
  35. package/dist/core/scheduler/leases.d.ts +40 -0
  36. package/dist/core/scheduler/schedule.d.ts +29 -5
  37. package/dist/core/tenant/publicTenantHost.d.ts +11 -0
  38. package/dist/core/tenant/tenantContext.d.ts +5 -4
  39. package/dist/core/tenant/tenantMiddleware.d.ts +11 -1
  40. package/dist/core/tracing/otel.d.ts +28 -26
  41. package/dist/core/tracing/tracingMiddleware.d.ts +4 -1
  42. package/dist/entries/audit/exportAuditLogs.js +14 -2
  43. package/dist/entries/auth/basicAuthGuard.js +81 -0
  44. package/dist/entries/auth/jwtGuard.js +81 -0
  45. package/dist/entries/auth/scimAuthMiddleware.js +17 -7
  46. package/dist/entries/auth/sessionGuard.js +81 -0
  47. package/dist/entries/cache/createCacheStore.js +365 -119
  48. package/dist/entries/cache/repository.js +5 -0
  49. package/dist/entries/contracts/serviceTokens.js +85 -0
  50. package/dist/entries/database/factory.js +0 -3
  51. package/dist/entries/database/model.js +1 -2486
  52. package/dist/entries/database/repositoryQuery.js +67 -9
  53. package/dist/entries/http/bodySizeLimitMiddleware.js +76 -10
  54. package/dist/entries/http/memoryThrottleMiddleware.js +276 -21
  55. package/dist/entries/http/scimThrottleMiddleware.js +576 -54
  56. package/dist/entries/http/throttleMiddleware.js +65 -16
  57. package/dist/entries/jobs/exportAuditLogsJob.js +44 -7
  58. package/dist/entries/jobs/invalidateCacheTagsJob.js +30 -5
  59. package/dist/entries/queue/createAppQueue.js +748 -72
  60. package/dist/entries/queue/failedJobRepository.js +1 -1
  61. package/dist/entries/queue/failedJobService.js +5 -1
  62. package/dist/entries/queue/jobRunner.js +67 -2
  63. package/dist/entries/queue/publicQueue.js +746 -71
  64. package/dist/entries/queue/queueMetrics.js +761 -78
  65. package/dist/entries/queue/redisQueue.js +768 -148
  66. package/dist/entries/queue.js +30 -5
  67. package/dist/entries/scheduler/schedule.js +1 -72
  68. package/dist/entries/tenant/databaseTenantContext.js +14 -2
  69. package/dist/entries/tenant/tenantDatabaseScope.js +14 -2
  70. package/dist/entries/tracing/tracingMiddleware.js +186 -454
  71. package/dist/framework/public-api.d.ts +14 -10
  72. package/dist/index.js +2444 -688
  73. package/dist/types/services.d.ts +3 -3
  74. package/package.json +17 -8
@@ -815,6 +815,7 @@ class RepositoryQuery {
815
815
  queryOptions;
816
816
  eagerLoads = [];
817
817
  whereNodes = [];
818
+ protectedWhere = [];
818
819
  constructor(repository, whereClause = {}, queryOptions = {}) {
819
820
  this.repository = repository;
820
821
  this.whereClause = whereClause;
@@ -824,10 +825,23 @@ class RepositoryQuery {
824
825
  if (typeof input === "function") {
825
826
  const builder = new WhereBuilder;
826
827
  input(builder);
827
- this.whereNodes.push(...builder.nodes);
828
+ if (builder.nodes.length > 0)
829
+ this.whereNodes.push({ kind: "and", group: builder.nodes });
828
830
  return this;
829
831
  }
830
- this.whereClause = { ...this.whereClause, ...input };
832
+ this.whereNodes.push({ kind: "and", where: { ...input } });
833
+ return this;
834
+ }
835
+ protectWhere() {
836
+ const nodes = [];
837
+ if (Object.keys(this.whereClause).length > 0) {
838
+ nodes.push({ kind: "and", where: this.whereClause });
839
+ }
840
+ nodes.push(...this.whereNodes);
841
+ if (nodes.length > 0)
842
+ this.protectedWhere.push({ kind: "and", group: nodes });
843
+ this.whereClause = {};
844
+ this.whereNodes.length = 0;
831
845
  return this;
832
846
  }
833
847
  orWhere(input) {
@@ -941,9 +955,10 @@ class RepositoryQuery {
941
955
  });
942
956
  return this;
943
957
  }
944
- withMorphTo(as, relation, repositoriesByType, options = {}) {
958
+ withMorphTo(as, relation, repositoriesByType, options = {}, optionsByType) {
945
959
  this.eagerLoads.push({
946
960
  kind: "morphTo",
961
+ morphOptions: optionsByType,
947
962
  as,
948
963
  relation,
949
964
  repository: this.repository,
@@ -1014,6 +1029,19 @@ class RepositoryQuery {
1014
1029
  }
1015
1030
  return row[column];
1016
1031
  }
1032
+ async project(columns, limit) {
1033
+ if (columns.length === 0)
1034
+ throw new RangeError("A projection requires at least one column.");
1035
+ const table = this.repository.getTable();
1036
+ for (const column of columns)
1037
+ if (!table.columns.includes(column))
1038
+ throw new TypeError(`Unknown projection column: ${column}`);
1039
+ return await this.repository.findAll({
1040
+ ...this.buildOptions(),
1041
+ ...limit === undefined ? {} : { limit },
1042
+ select: uniqueColumnSelect(table.name, columns)
1043
+ });
1044
+ }
1017
1045
  async attachToRows(rows) {
1018
1046
  return await this.attach(rows);
1019
1047
  }
@@ -1024,11 +1052,24 @@ class RepositoryQuery {
1024
1052
  perPage: options.perPage
1025
1053
  });
1026
1054
  }
1055
+ async chunk(count, callback) {
1056
+ await this.repository.chunk(count, async (rows) => callback(await this.attach(rows)), this.buildOptions());
1057
+ }
1058
+ async cursorPaginate(options) {
1059
+ const page = await this.repository.cursorPaginate({ ...this.buildOptions(), ...options });
1060
+ return { ...page, data: await this.attach(page.data) };
1061
+ }
1062
+ getOptions() {
1063
+ return this.buildOptions();
1064
+ }
1027
1065
  buildOptions() {
1066
+ if (this.protectedWhere.length === 0) {
1067
+ return { ...this.queryOptions, where: this.whereClause, whereNodes: this.whereNodes };
1068
+ }
1028
1069
  return {
1029
1070
  ...this.queryOptions,
1030
- where: this.whereClause,
1031
- whereNodes: this.whereNodes
1071
+ where: {},
1072
+ whereNodes: [...this.protectedWhere, { kind: "and", group: [...this.whereNodes] }]
1032
1073
  };
1033
1074
  }
1034
1075
  addJoin(type, left, right) {
@@ -1105,10 +1146,27 @@ class RepositoryQuery {
1105
1146
  }
1106
1147
  if (load.kind === "morphTo") {
1107
1148
  const relation = load.relation;
1108
- const grouped = await this.repository.loadMorphToForChildren(rows, relation, load.morphRepositories ?? new Map, load.options);
1109
- for (const row of result) {
1110
- row[load.as] = getByRelationKey(grouped, row[relation.morphIdKey]);
1111
- }
1149
+ const repositories = load.morphRepositories ?? new Map;
1150
+ const partitions = new Map;
1151
+ rows.forEach((row, index) => {
1152
+ const type = String(row[relation.morphTypeKey]);
1153
+ const target = result[index];
1154
+ if (!target || !repositories.has(type))
1155
+ return;
1156
+ const partition = partitions.get(type) ?? { rows: [], targets: [] };
1157
+ partition.rows.push(row);
1158
+ partition.targets.push(target);
1159
+ partitions.set(type, partition);
1160
+ });
1161
+ await Promise.all([...partitions.entries()].map(async ([type, partition]) => {
1162
+ const repository = repositories.get(type);
1163
+ if (!repository)
1164
+ return;
1165
+ const grouped = await this.repository.loadMorphToForChildren(partition.rows, relation, new Map([[type, repository]]), load.morphOptions?.get(type) ?? load.options);
1166
+ for (const row of partition.targets) {
1167
+ row[load.as] = getByRelationKey(grouped, row[relation.morphIdKey]);
1168
+ }
1169
+ }));
1112
1170
  return;
1113
1171
  }
1114
1172
  const relation = load.relation;
@@ -1,29 +1,95 @@
1
1
  // @bun
2
2
  // ../../src/core/http/bodySizeLimitMiddleware.ts
3
3
  import { PayloadTooLargeError } from "@getstrata/core/errors/http";
4
+ var guards = new WeakMap;
5
+ function guardBody(request, maxBytes) {
6
+ const existing = guards.get(request);
7
+ if (existing && existing.maxBytes <= maxBytes)
8
+ return existing;
9
+ const state = { maxBytes, exceeded: false };
10
+ const body = request.body;
11
+ if (!body)
12
+ return state;
13
+ let consumed = 0;
14
+ const stream = body.pipeThrough(new TransformStream({
15
+ transform(chunk, controller) {
16
+ const bytes = typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk;
17
+ consumed += bytes.byteLength;
18
+ if (consumed > maxBytes) {
19
+ state.exceeded = true;
20
+ throw new PayloadTooLargeError(`Request body exceeds the ${maxBytes} byte limit.`);
21
+ }
22
+ controller.enqueue(bytes);
23
+ }
24
+ }));
25
+ const bounded = new Request(request, { body: stream });
26
+ const decorateClone = (clone) => {
27
+ for (const field of ["params", "cookies"]) {
28
+ if (field in request)
29
+ Object.defineProperty(clone, field, { value: Reflect.get(request, field) });
30
+ }
31
+ const nativeClone = () => Reflect.apply(Request.prototype.clone, clone, []);
32
+ Object.defineProperty(clone, "clone", {
33
+ value: () => decorateClone(nativeClone())
34
+ });
35
+ guards.set(clone, state);
36
+ return clone;
37
+ };
38
+ const descriptors = {
39
+ body: { configurable: true, get: () => bounded.body },
40
+ bodyUsed: { configurable: true, get: () => bounded.bodyUsed },
41
+ clone: {
42
+ configurable: true,
43
+ value: () => decorateClone(Reflect.apply(Request.prototype.clone, bounded, []))
44
+ }
45
+ };
46
+ for (const method of [
47
+ "text",
48
+ "json",
49
+ "formData",
50
+ "arrayBuffer",
51
+ "blob",
52
+ "bytes",
53
+ "textStream"
54
+ ]) {
55
+ descriptors[method] = { configurable: true, value: bounded[method].bind(bounded) };
56
+ }
57
+ Object.defineProperties(request, descriptors);
58
+ guards.set(request, state);
59
+ return state;
60
+ }
4
61
  var DEFAULT_MAX_BODY_BYTES = 1048576;
5
62
  function resolveMaxBodyBytes() {
6
63
  const raw = process.env.MAX_REQUEST_BODY_BYTES?.trim();
7
64
  if (!raw) {
8
65
  return DEFAULT_MAX_BODY_BYTES;
9
66
  }
10
- const parsed = Number.parseInt(raw, 10);
11
- if (!Number.isInteger(parsed) || parsed <= 0) {
67
+ const parsed = Number(raw);
68
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) {
12
69
  return DEFAULT_MAX_BODY_BYTES;
13
70
  }
14
71
  return parsed;
15
72
  }
16
73
  function createBodySizeLimitMiddleware(maxBytes = resolveMaxBodyBytes()) {
74
+ if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0)
75
+ throw new RangeError("Body limit must be a positive safe integer.");
17
76
  return async (request, next) => {
18
- const contentLength = request.headers.get("content-length");
19
- if (contentLength) {
20
- const bytes = Number.parseInt(contentLength, 10);
21
- if (Number.isInteger(bytes) && bytes > maxBytes) {
22
- const error = new PayloadTooLargeError(`Request body exceeds the ${maxBytes} byte limit.`);
23
- return Response.json({ error: error.message }, { status: error.status });
24
- }
77
+ const tooLarge = () => {
78
+ const error = new PayloadTooLargeError(`Request body exceeds the ${maxBytes} byte limit.`);
79
+ return Response.json({ error: error.message }, { status: error.status });
80
+ };
81
+ const length = Number(request.headers.get("content-length"));
82
+ if (Number.isFinite(length) && length > maxBytes)
83
+ return tooLarge();
84
+ const state = guardBody(request, maxBytes);
85
+ try {
86
+ const response = await next();
87
+ return state.exceeded ? tooLarge() : response;
88
+ } catch (error) {
89
+ if (state.exceeded || error instanceof PayloadTooLargeError)
90
+ return tooLarge();
91
+ throw error;
25
92
  }
26
- return await next();
27
93
  };
28
94
  }
29
95
  export {
@@ -1,4 +1,98 @@
1
1
  // @bun
2
+ var __esm = (fn, res, err) => () => {
3
+ if (fn)
4
+ try {
5
+ res = fn(fn = 0);
6
+ } catch (e) {
7
+ err = [e];
8
+ }
9
+ if (err)
10
+ throw err[0];
11
+ return res;
12
+ };
13
+
14
+ // ../../src/core/runtime/asyncContextStore.ts
15
+ import { AsyncLocalStorage } from "async_hooks";
16
+ function createAsyncContextStore(key) {
17
+ const symbol = Symbol.for(key);
18
+ const globalRecord = globalThis;
19
+ const existing = globalRecord[symbol];
20
+ if (existing) {
21
+ return existing;
22
+ }
23
+ const store = new AsyncLocalStorage;
24
+ globalRecord[symbol] = store;
25
+ return store;
26
+ }
27
+ var init_asyncContextStore = () => {};
28
+
29
+ // ../../src/core/runtime/boundedThrottleStore.ts
30
+ class BoundedThrottleStore {
31
+ decayMs;
32
+ now;
33
+ buckets = new Map;
34
+ maxBuckets;
35
+ pruneBatchSize;
36
+ disposed = false;
37
+ pruned = 0;
38
+ constructor(decayMs, options = {}, now = () => performance.now()) {
39
+ this.decayMs = decayMs;
40
+ this.now = now;
41
+ this.maxBuckets = options.maxBuckets ?? 1e4;
42
+ this.pruneBatchSize = options.pruneBatchSize ?? 32;
43
+ if (!Number.isFinite(decayMs) || decayMs < 0 || decayMs > 2147483647) {
44
+ throw new Error("Invalid memory throttle window.");
45
+ }
46
+ if (!Number.isSafeInteger(this.maxBuckets) || this.maxBuckets < 1 || this.maxBuckets > 1e6 || !Number.isSafeInteger(this.pruneBatchSize) || this.pruneBatchSize < 1 || this.pruneBatchSize > 1024) {
47
+ throw new Error("Invalid memory throttle storage bounds.");
48
+ }
49
+ }
50
+ consume(key) {
51
+ this.pruned = 0;
52
+ if (this.disposed)
53
+ return null;
54
+ const now = this.now();
55
+ const entries = this.buckets.entries();
56
+ for (let i = 0;i < this.pruneBatchSize; i++) {
57
+ const oldest = entries.next().value;
58
+ if (!oldest || oldest[1].resetAt > now)
59
+ break;
60
+ this.buckets.delete(oldest[0]);
61
+ this.pruned++;
62
+ }
63
+ const existing = this.buckets.get(key);
64
+ if (existing && existing.resetAt > now) {
65
+ existing.count = Math.min(Number.MAX_SAFE_INTEGER, existing.count + 1);
66
+ return existing.count;
67
+ }
68
+ this.buckets.delete(key);
69
+ if (this.buckets.size >= this.maxBuckets)
70
+ return null;
71
+ this.buckets.set(key, { count: 1, resetAt: now + this.decayMs });
72
+ return 1;
73
+ }
74
+ clear() {
75
+ this.buckets.clear();
76
+ }
77
+ dispose() {
78
+ this.disposed = true;
79
+ this.clear();
80
+ }
81
+ stats() {
82
+ return {
83
+ retainedBuckets: this.buckets.size,
84
+ prunedOnLastConsume: this.pruned,
85
+ disposed: this.disposed
86
+ };
87
+ }
88
+ }
89
+
90
+ // ../../src/core/http/throttleMiddleware.ts
91
+ import { createHash } from "crypto";
92
+ import { currentAuthUser } from "@getstrata/core/auth/authContext";
93
+ import { currentTenant } from "@getstrata/core/tenant/tenantContext";
94
+ var {RedisClient } = globalThis.Bun;
95
+
2
96
  // ../../src/core/runtime/appEnv.ts
3
97
  var NON_PRODUCTION_APP_ENVS = new Set(["local", "development", "dev", "test", "testing", "ci"]);
4
98
  function normalizeEnvValue(value) {
@@ -117,6 +211,19 @@ function readClientIp(request, env = process.env) {
117
211
  return currentRequestMeta().ipAddress ?? undefined;
118
212
  }
119
213
 
214
+ // ../../src/core/http/requestMetaContext.ts
215
+ init_asyncContextStore();
216
+ var requestMetaContext = createAsyncContextStore("@getstrata/requestMetaContext");
217
+ function runWithRequestMeta(meta, callback) {
218
+ return requestMetaContext.run(meta, callback);
219
+ }
220
+ function currentRequestMeta2() {
221
+ return requestMetaContext.getStore() ?? {
222
+ ipAddress: null,
223
+ userAgent: null
224
+ };
225
+ }
226
+
120
227
  // ../../src/core/runtime/frontendMode.ts
121
228
  var FRONTEND_MODES = ["api", "server-htmx", "spa-react", "hybrid"];
122
229
  var DEFAULT_SPA_PREFIX = "/app";
@@ -157,7 +264,7 @@ function readSpaPrefix() {
157
264
  }
158
265
 
159
266
  // ../../src/core/view/webErrorView.ts
160
- import { currentRequestMeta as currentRequestMeta2 } from "@getstrata/core/http/requestMetaContext";
267
+ import { currentRequestMeta as currentRequestMeta3 } from "@getstrata/core/http/requestMetaContext";
161
268
 
162
269
  // ../../src/core/http/safeInternalPath.ts
163
270
  var NESTED_REDIRECT_KEYS = new Set(["redirect", "next", "return", "returnTo", "return_to"]);
@@ -276,7 +383,7 @@ async function renderWebErrorHtml(input) {
276
383
  try {
277
384
  return await render({
278
385
  ...input,
279
- request: input.request ?? currentRequestMeta2().request
386
+ request: input.request ?? currentRequestMeta3().request
280
387
  });
281
388
  } catch {
282
389
  return renderKernelErrorChrome(input);
@@ -332,33 +439,181 @@ async function tooManyRequestsResponse(request, message, decaySeconds) {
332
439
  return new Response(html.body, { status: 429, headers });
333
440
  }
334
441
 
442
+ // ../../src/core/http/throttleMiddleware.ts
443
+ function resolveThrottleIdentity(request) {
444
+ const user = currentAuthUser();
445
+ if (user?.tokenId !== undefined) {
446
+ return `token:${user.tokenId}`;
447
+ }
448
+ if (user) {
449
+ return `user:${user.id}`;
450
+ }
451
+ return readClientIp(request) ?? "unknown";
452
+ }
453
+ var CONSUME_THROTTLE = `
454
+ local count = redis.call('INCR', KEYS[1])
455
+ local ttl = redis.call('PTTL', KEYS[1])
456
+ if ttl < 0 then
457
+ redis.call('PEXPIRE', KEYS[1], ARGV[1])
458
+ end
459
+ return count
460
+ `;
461
+ function redisThrottleKey(request, prefix, identity, tenantId = currentTenant()?.id ?? null) {
462
+ const bucket = JSON.stringify([
463
+ tenantId,
464
+ request.method,
465
+ currentRequestMeta2().routeTemplate ?? "__unmatched__",
466
+ identity
467
+ ]);
468
+ return namespacedRedisKey(`${prefix}:${createHash("sha256").update(bucket).digest("hex")}`);
469
+ }
470
+ async function consumeRedisThrottle(client, key, decaySeconds, timeoutMs = 1000, signal) {
471
+ const expiryMs = Math.max(1, Math.ceil(decaySeconds * 1000));
472
+ if (!Number.isFinite(decaySeconds) || decaySeconds < 0 || !Number.isSafeInteger(expiryMs) || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
473
+ throw new Error("Invalid throttle expiry or command timeout.");
474
+ }
475
+ if (signal?.aborted)
476
+ throw new Error("Throttle consumer is disposed.");
477
+ let onAbort;
478
+ let timer;
479
+ try {
480
+ const cancelled = new Promise((_resolve, reject) => {
481
+ onAbort = () => reject(new Error("Throttle consumer is disposed."));
482
+ signal?.addEventListener("abort", onAbort, { once: true });
483
+ });
484
+ const result = await Promise.race([
485
+ cancelled,
486
+ client.send("EVAL", [CONSUME_THROTTLE, "1", key, String(expiryMs)]),
487
+ new Promise((_resolve, reject) => {
488
+ timer = setTimeout(() => reject(new Error("Throttle store deadline exceeded.")), timeoutMs);
489
+ })
490
+ ]);
491
+ const count = Number(result);
492
+ if (!Number.isSafeInteger(count) || count < 1)
493
+ throw new Error("Invalid throttle store response.");
494
+ return count;
495
+ } finally {
496
+ clearTimeout(timer);
497
+ if (onAbort)
498
+ signal?.removeEventListener("abort", onAbort);
499
+ }
500
+ }
501
+ function createRedisThrottleConsumer(options) {
502
+ let ownedClient;
503
+ const controller = new AbortController;
504
+ const consume = async (key, decaySeconds) => {
505
+ if (controller.signal.aborted)
506
+ throw new Error("Throttle consumer is disposed.");
507
+ if (!options.redisClient && !ownedClient) {
508
+ ownedClient = new RedisClient(options.redisUrl, { autoReconnect: false, maxRetries: 0 });
509
+ }
510
+ const client = options.redisClient ?? ownedClient;
511
+ if (!client)
512
+ throw new Error("Throttle client is required.");
513
+ try {
514
+ const attempts = await consumeRedisThrottle(client, key, decaySeconds, options.commandTimeoutMs, controller.signal);
515
+ if (controller.signal.aborted)
516
+ throw new Error("Throttle consumer is disposed.");
517
+ return attempts;
518
+ } catch (error) {
519
+ if (client === ownedClient) {
520
+ ownedClient.close();
521
+ ownedClient = undefined;
522
+ }
523
+ throw error;
524
+ }
525
+ };
526
+ return Object.assign(consume, {
527
+ isDisposed: () => controller.signal.aborted,
528
+ dispose() {
529
+ if (controller.signal.aborted)
530
+ return;
531
+ controller.abort();
532
+ const client = ownedClient;
533
+ ownedClient = undefined;
534
+ client?.close();
535
+ }
536
+ });
537
+ }
538
+ function throttleUnavailableResponse() {
539
+ return Response.json({ error: "Rate limiting is temporarily unavailable." }, {
540
+ status: 503,
541
+ headers: { "retry-after": "1" }
542
+ });
543
+ }
544
+ function resolveThrottleQuota(request, options, identity = resolveThrottleIdentity(request)) {
545
+ const limit = options.quotaPolicy ? options.quotaPolicy({
546
+ request,
547
+ tenant: currentTenant(),
548
+ identity,
549
+ routeTemplate: currentRequestMeta2().routeTemplate ?? "__unmatched__",
550
+ maxAttempts: options.maxAttempts
551
+ }) : options.maxAttempts;
552
+ if (!Number.isSafeInteger(limit) || limit < 0) {
553
+ throw new Error("Invalid application quota.");
554
+ }
555
+ return limit;
556
+ }
557
+ function createThrottleMiddleware(options) {
558
+ if (!Number.isSafeInteger(options.maxAttempts) || options.maxAttempts < 0) {
559
+ throw new Error("Throttle maxAttempts must be a non-negative safe integer.");
560
+ }
561
+ const consume = createRedisThrottleConsumer(options);
562
+ const prefix = options.keyPrefix ?? "throttle";
563
+ const middleware = async (request, next) => {
564
+ const identity = resolveThrottleIdentity(request);
565
+ let maxAttempts;
566
+ try {
567
+ maxAttempts = resolveThrottleQuota(request, options, identity);
568
+ } catch {
569
+ return throttleUnavailableResponse();
570
+ }
571
+ const key = redisThrottleKey(request, prefix, identity);
572
+ let attempts;
573
+ try {
574
+ attempts = await consume(key, options.decaySeconds);
575
+ } catch {
576
+ return throttleUnavailableResponse();
577
+ }
578
+ if (consume.isDisposed())
579
+ return throttleUnavailableResponse();
580
+ if (attempts > maxAttempts) {
581
+ return await tooManyRequestsResponse(request, "Too many requests.", options.decaySeconds);
582
+ }
583
+ return await next();
584
+ };
585
+ return Object.assign(middleware, { dispose: () => consume.dispose() });
586
+ }
587
+
335
588
  // ../../src/core/http/memoryThrottleMiddleware.ts
336
- var throttleBucketRegistries = new Set;
589
+ var resetGeneration = 0;
337
590
  function createMemoryThrottleMiddleware(options) {
338
- const prefix = options.keyPrefix ?? namespacedRedisKey("memory-throttle:");
339
- const buckets = new Map;
340
- throttleBucketRegistries.add(buckets);
341
- return async (request, next) => {
342
- const path = new URL(request.url).pathname;
343
- const identity = readClientIp(request) ?? request.headers.get("authorization")?.slice(0, 32) ?? "unknown";
344
- const key = `${prefix}${identity}:${path}`;
345
- const now = Date.now();
346
- const existing = buckets.get(key);
347
- if (!existing || existing.resetAt <= now) {
348
- buckets.set(key, { count: 1, resetAt: now + options.decaySeconds * 1000 });
349
- return await next();
591
+ const store = new BoundedThrottleStore(options.decaySeconds * 1000, options);
592
+ if (!Number.isSafeInteger(options.maxAttempts) || options.maxAttempts < 0)
593
+ throw new Error("Invalid memory throttle limit.");
594
+ let generation = resetGeneration;
595
+ const middleware = async (request, next) => {
596
+ if (generation !== resetGeneration) {
597
+ store.clear();
598
+ generation = resetGeneration;
350
599
  }
351
- existing.count += 1;
352
- if (existing.count > options.maxAttempts) {
353
- return await tooManyRequestsResponse(request, "Too many requests.", options.decaySeconds);
600
+ let maxAttempts;
601
+ try {
602
+ maxAttempts = resolveThrottleQuota(request, options);
603
+ } catch {
604
+ return throttleUnavailableResponse();
354
605
  }
606
+ const attempts = store.consume(redisThrottleKey(request, options.keyPrefix ?? "memory-throttle", resolveThrottleIdentity(request)));
607
+ if (attempts === null)
608
+ return throttleUnavailableResponse();
609
+ if (attempts > maxAttempts)
610
+ return await tooManyRequestsResponse(request, "Too many requests.", options.decaySeconds);
355
611
  return await next();
356
612
  };
613
+ return Object.assign(middleware, { dispose: () => store.dispose(), stats: () => store.stats() });
357
614
  }
358
615
  function resetMemoryThrottleForTests() {
359
- for (const buckets of throttleBucketRegistries) {
360
- buckets.clear();
361
- }
616
+ resetGeneration++;
362
617
  }
363
618
  export {
364
619
  createMemoryThrottleMiddleware,