@veryfront/ext-redis 0.1.1186 → 0.1.1189
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.
- package/README.md +12 -0
- package/esm/_dnt.polyfills.d.ts +12 -0
- package/esm/_dnt.polyfills.d.ts.map +1 -0
- package/esm/_dnt.polyfills.js +15 -0
- package/esm/_dnt.shims.d.ts +11 -0
- package/esm/_dnt.shims.d.ts.map +1 -0
- package/esm/_dnt.shims.js +68 -0
- package/esm/agent-memory.d.ts +60 -0
- package/esm/agent-memory.d.ts.map +1 -0
- package/esm/agent-memory.js +173 -0
- package/esm/cache-administration.d.ts +5 -0
- package/esm/cache-administration.d.ts.map +1 -0
- package/esm/cache-administration.js +113 -0
- package/esm/cache-backend.d.ts +46 -0
- package/esm/cache-backend.d.ts.map +1 -0
- package/esm/cache-backend.js +778 -0
- package/esm/index.d.ts +7 -0
- package/esm/index.d.ts.map +1 -1
- package/esm/index.js +7 -0
- package/esm/rate-limit-store.d.ts +40 -0
- package/esm/rate-limit-store.d.ts.map +1 -0
- package/esm/rate-limit-store.js +306 -0
- package/esm/redis-client-manager.d.ts +8 -0
- package/esm/redis-client-manager.d.ts.map +1 -1
- package/esm/redis-client-manager.js +22 -1
- package/esm/redis-runtime-provider.js +1 -1
- package/esm/render-cache-store.d.ts +34 -0
- package/esm/render-cache-store.d.ts.map +1 -0
- package/esm/render-cache-store.js +191 -0
- package/esm/revisioned-cache-record.d.ts +18 -0
- package/esm/revisioned-cache-record.d.ts.map +1 -0
- package/esm/revisioned-cache-record.js +68 -0
- package/esm/routing-invalidation-bus.d.ts +33 -0
- package/esm/routing-invalidation-bus.d.ts.map +1 -0
- package/esm/routing-invalidation-bus.js +760 -0
- package/package.json +6 -3
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
import * as dntShim from "./_dnt.shims.js";
|
|
2
|
+
import { createClient } from "redis";
|
|
3
|
+
import { getEnv } from "veryfront/platform/env";
|
|
4
|
+
import { getErrorMessage } from "veryfront/errors";
|
|
5
|
+
import { hasProjectIdentityControlCharacters, isCanonicalOpaqueProjectIdentifier, } from "veryfront/extensions/distributed/routing-invalidation-support";
|
|
6
|
+
import { parseProxyRoutingInvalidationEvent } from "veryfront/extensions/distributed/routing-invalidation-support";
|
|
7
|
+
const ROUTING_INVALIDATION_CHANNEL = "vf-proxy-routing-invalidations-v1";
|
|
8
|
+
const ROUTING_INVALIDATION_ACK_PREFIX = `${ROUTING_INVALIDATION_CHANNEL}:ack:`;
|
|
9
|
+
const DEFAULT_ACKNOWLEDGEMENT_TIMEOUT_MS = 1_500;
|
|
10
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 3_000;
|
|
11
|
+
const MAX_RECONNECT_ATTEMPTS = 5;
|
|
12
|
+
const MAX_RECENT_EVENT_IDS = 1_000;
|
|
13
|
+
const MAX_ACTIVE_EVENT_PROCESSING = 100;
|
|
14
|
+
const MAX_ACTIVE_PUBLISHES = 100;
|
|
15
|
+
const MAX_REPLICA_COUNT = 10_000;
|
|
16
|
+
const MAX_ACKNOWLEDGEMENT_TIMEOUT_MS = 60_000;
|
|
17
|
+
const MAX_REDIS_URL_CODE_UNITS = 4_096;
|
|
18
|
+
const MIN_INTEGRITY_SECRET_BYTES = 32;
|
|
19
|
+
const MAX_INTEGRITY_SECRET_CODE_UNITS = 64 * 1_024;
|
|
20
|
+
const MAX_SIGNED_ENVELOPE_BYTES = 24 * 1024;
|
|
21
|
+
const MAX_SIGNED_PAYLOAD_BYTES = 16 * 1024;
|
|
22
|
+
const DEFAULT_MAX_ENVELOPE_AGE_MS = 60_000;
|
|
23
|
+
const DEFAULT_MAX_ENVELOPE_FUTURE_MS = 5_000;
|
|
24
|
+
const INTEGRITY_SECRET_ENV_VAR = "VERYFRONT_PROXY_ROUTING_INVALIDATION_SECRET";
|
|
25
|
+
const EVENT_SIGNATURE_DOMAIN = "vf-proxy-routing-invalidation:event:v1";
|
|
26
|
+
const ACK_SIGNATURE_DOMAIN = "vf-proxy-routing-invalidation:ack:v1";
|
|
27
|
+
const HMAC_SHA256_SIGNATURE_BYTES = 32;
|
|
28
|
+
const HMAC_SHA256_SIGNATURE_BASE64URL_CODE_UNITS = 43;
|
|
29
|
+
function encodeBase64Url(bytes) {
|
|
30
|
+
let binary = "";
|
|
31
|
+
for (const byte of bytes)
|
|
32
|
+
binary += String.fromCharCode(byte);
|
|
33
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
34
|
+
}
|
|
35
|
+
const START_OPTION_NAMES = new Set([
|
|
36
|
+
"acknowledgementTimeoutMs",
|
|
37
|
+
"createClient",
|
|
38
|
+
"expectedReplicas",
|
|
39
|
+
"now",
|
|
40
|
+
"logger",
|
|
41
|
+
"onInvalidate",
|
|
42
|
+
"integritySecret",
|
|
43
|
+
"redisUrl",
|
|
44
|
+
"replicaId",
|
|
45
|
+
]);
|
|
46
|
+
function assertPlainStartOptions(options) {
|
|
47
|
+
if (!options || typeof options !== "object" || Array.isArray(options)) {
|
|
48
|
+
throw new TypeError("Routing invalidation bus options must be an object");
|
|
49
|
+
}
|
|
50
|
+
let prototype;
|
|
51
|
+
let keys;
|
|
52
|
+
try {
|
|
53
|
+
prototype = Object.getPrototypeOf(options);
|
|
54
|
+
keys = Reflect.ownKeys(options);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
throw new TypeError("Routing invalidation bus options are unreadable", {
|
|
58
|
+
cause: error,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
62
|
+
throw new TypeError("Routing invalidation bus options must be a plain object");
|
|
63
|
+
}
|
|
64
|
+
if (keys.some((key) => typeof key !== "string" || !START_OPTION_NAMES.has(key))) {
|
|
65
|
+
throw new TypeError("Routing invalidation bus options contain an unknown option");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function readOwnOption(options, key) {
|
|
69
|
+
const descriptor = Object.getOwnPropertyDescriptor(options, key);
|
|
70
|
+
if (!descriptor)
|
|
71
|
+
return undefined;
|
|
72
|
+
if (!("value" in descriptor)) {
|
|
73
|
+
throw new TypeError(`Routing invalidation bus option ${key} must be a data property`);
|
|
74
|
+
}
|
|
75
|
+
return descriptor.value;
|
|
76
|
+
}
|
|
77
|
+
function resolveInteger(value, fallback, label, minimum, maximum) {
|
|
78
|
+
if (value === undefined || value === "")
|
|
79
|
+
return fallback;
|
|
80
|
+
let parsed;
|
|
81
|
+
if (typeof value === "number") {
|
|
82
|
+
parsed = value;
|
|
83
|
+
}
|
|
84
|
+
else if (typeof value === "string" && /^\d+$/.test(value)) {
|
|
85
|
+
parsed = Number(value);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
throw new TypeError(`${label} must be a decimal integer`);
|
|
89
|
+
}
|
|
90
|
+
if (!Number.isSafeInteger(parsed) ||
|
|
91
|
+
parsed < minimum ||
|
|
92
|
+
parsed > maximum) {
|
|
93
|
+
throw new RangeError(`${label} must be between ${minimum} and ${maximum}`);
|
|
94
|
+
}
|
|
95
|
+
return parsed;
|
|
96
|
+
}
|
|
97
|
+
function requireRedisUrl(value) {
|
|
98
|
+
if (value === undefined || value === "")
|
|
99
|
+
return null;
|
|
100
|
+
if (typeof value !== "string" ||
|
|
101
|
+
value.length > MAX_REDIS_URL_CODE_UNITS ||
|
|
102
|
+
value !== value.trim() ||
|
|
103
|
+
value.includes("\\") ||
|
|
104
|
+
hasProjectIdentityControlCharacters(value)) {
|
|
105
|
+
throw new TypeError("Routing invalidation REDIS_URL is invalid");
|
|
106
|
+
}
|
|
107
|
+
let parsed;
|
|
108
|
+
try {
|
|
109
|
+
parsed = new URL(value);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
throw new TypeError("Routing invalidation REDIS_URL is invalid");
|
|
113
|
+
}
|
|
114
|
+
if ((parsed.protocol !== "redis:" && parsed.protocol !== "rediss:") ||
|
|
115
|
+
!parsed.hostname ||
|
|
116
|
+
parsed.hash) {
|
|
117
|
+
throw new TypeError("Routing invalidation REDIS_URL must use redis:// or rediss:// with a host");
|
|
118
|
+
}
|
|
119
|
+
return value;
|
|
120
|
+
}
|
|
121
|
+
function requireIntegritySecret(value) {
|
|
122
|
+
if (value === undefined || value === "")
|
|
123
|
+
return null;
|
|
124
|
+
if (typeof value !== "string" ||
|
|
125
|
+
value.length > MAX_INTEGRITY_SECRET_CODE_UNITS ||
|
|
126
|
+
hasProjectIdentityControlCharacters(value) ||
|
|
127
|
+
!isWellFormedUtf16(value) ||
|
|
128
|
+
new TextEncoder().encode(value).byteLength < MIN_INTEGRITY_SECRET_BYTES) {
|
|
129
|
+
throw new TypeError("Routing invalidation integrity secret is invalid");
|
|
130
|
+
}
|
|
131
|
+
return value;
|
|
132
|
+
}
|
|
133
|
+
function isWellFormedUtf16(value) {
|
|
134
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
135
|
+
const code = value.charCodeAt(index);
|
|
136
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
137
|
+
const next = value.charCodeAt(index + 1);
|
|
138
|
+
if (!(next >= 0xdc00 && next <= 0xdfff))
|
|
139
|
+
return false;
|
|
140
|
+
index += 1;
|
|
141
|
+
}
|
|
142
|
+
else if (code >= 0xdc00 && code <= 0xdfff) {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
function requireFunction(value, fallback, label) {
|
|
149
|
+
if (value === undefined)
|
|
150
|
+
return fallback;
|
|
151
|
+
if (typeof value !== "function") {
|
|
152
|
+
throw new TypeError(`${label} must be a function`);
|
|
153
|
+
}
|
|
154
|
+
return value;
|
|
155
|
+
}
|
|
156
|
+
function requireConfiguredFunction(value, label) {
|
|
157
|
+
if (typeof value !== "function") {
|
|
158
|
+
throw new TypeError(`${label} must be a function`);
|
|
159
|
+
}
|
|
160
|
+
return value;
|
|
161
|
+
}
|
|
162
|
+
function readClock(now) {
|
|
163
|
+
const value = now();
|
|
164
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
165
|
+
throw new TypeError("Routing invalidation clock must return a positive safe integer");
|
|
166
|
+
}
|
|
167
|
+
return value;
|
|
168
|
+
}
|
|
169
|
+
function validateReplicaId(value) {
|
|
170
|
+
if (!isCanonicalOpaqueProjectIdentifier(value)) {
|
|
171
|
+
throw new TypeError("Routing invalidation replica ID is invalid");
|
|
172
|
+
}
|
|
173
|
+
return value;
|
|
174
|
+
}
|
|
175
|
+
function safeDestroyUnknown(client) {
|
|
176
|
+
try {
|
|
177
|
+
if (!client || typeof client !== "object")
|
|
178
|
+
return;
|
|
179
|
+
const destroy = Reflect.get(client, "destroy");
|
|
180
|
+
if (typeof destroy === "function")
|
|
181
|
+
Reflect.apply(destroy, client, []);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// Construction failure remains the actionable startup error.
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function snapshotRedisClient(value, label) {
|
|
188
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
189
|
+
throw new TypeError(`${label} returned an invalid Redis client`);
|
|
190
|
+
}
|
|
191
|
+
const operation = (name, required = true) => {
|
|
192
|
+
let candidate;
|
|
193
|
+
try {
|
|
194
|
+
candidate = Reflect.get(value, name);
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
throw new TypeError(`${label} Redis client ${name} is unreadable`, {
|
|
198
|
+
cause: error,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
if (candidate === undefined && !required)
|
|
202
|
+
return undefined;
|
|
203
|
+
if (typeof candidate !== "function") {
|
|
204
|
+
throw new TypeError(`${label} Redis client ${name} must be a function`);
|
|
205
|
+
}
|
|
206
|
+
return candidate;
|
|
207
|
+
};
|
|
208
|
+
const connect = operation("connect");
|
|
209
|
+
const publish = operation("publish");
|
|
210
|
+
const subscribe = operation("subscribe");
|
|
211
|
+
const unsubscribe = operation("unsubscribe");
|
|
212
|
+
const close = operation("close");
|
|
213
|
+
const destroy = operation("destroy");
|
|
214
|
+
const on = operation("on", false);
|
|
215
|
+
const requireSubscriberCount = (result) => {
|
|
216
|
+
if (typeof result !== "number" ||
|
|
217
|
+
!Number.isSafeInteger(result) ||
|
|
218
|
+
result < 0 ||
|
|
219
|
+
result > MAX_REPLICA_COUNT) {
|
|
220
|
+
throw new TypeError(`${label} Redis client publish returned an invalid count`);
|
|
221
|
+
}
|
|
222
|
+
return result;
|
|
223
|
+
};
|
|
224
|
+
return Object.freeze({
|
|
225
|
+
async connect() {
|
|
226
|
+
await Reflect.apply(connect, value, []);
|
|
227
|
+
},
|
|
228
|
+
async publish(channel, message) {
|
|
229
|
+
return requireSubscriberCount(await Reflect.apply(publish, value, [channel, message]));
|
|
230
|
+
},
|
|
231
|
+
async subscribe(channel, listener) {
|
|
232
|
+
await Reflect.apply(subscribe, value, [channel, listener]);
|
|
233
|
+
},
|
|
234
|
+
async unsubscribe(channel) {
|
|
235
|
+
await Reflect.apply(unsubscribe, value, [channel]);
|
|
236
|
+
},
|
|
237
|
+
async close() {
|
|
238
|
+
await Reflect.apply(close, value, []);
|
|
239
|
+
},
|
|
240
|
+
destroy() {
|
|
241
|
+
Reflect.apply(destroy, value, []);
|
|
242
|
+
},
|
|
243
|
+
...(on
|
|
244
|
+
? {
|
|
245
|
+
on(event, listener) {
|
|
246
|
+
return Reflect.apply(on, value, [event, listener]);
|
|
247
|
+
},
|
|
248
|
+
}
|
|
249
|
+
: {}),
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
function encodedByteLength(value) {
|
|
253
|
+
return new TextEncoder().encode(value).byteLength;
|
|
254
|
+
}
|
|
255
|
+
function isWithinEncodedLimit(value, maximumBytes) {
|
|
256
|
+
return value.length <= maximumBytes &&
|
|
257
|
+
encodedByteLength(value) <= maximumBytes;
|
|
258
|
+
}
|
|
259
|
+
function ownDataValue(descriptors, key) {
|
|
260
|
+
const descriptor = descriptors[key];
|
|
261
|
+
return descriptor && "value" in descriptor ? descriptor.value : undefined;
|
|
262
|
+
}
|
|
263
|
+
function parseSignedEnvelope(message) {
|
|
264
|
+
if (!isWithinEncodedLimit(message, MAX_SIGNED_ENVELOPE_BYTES))
|
|
265
|
+
return null;
|
|
266
|
+
let parsed;
|
|
267
|
+
try {
|
|
268
|
+
parsed = JSON.parse(message);
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
274
|
+
return null;
|
|
275
|
+
let descriptors;
|
|
276
|
+
try {
|
|
277
|
+
descriptors = Object.getOwnPropertyDescriptors(parsed);
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
const version = ownDataValue(descriptors, "version");
|
|
283
|
+
const issuedAtMs = ownDataValue(descriptors, "issuedAtMs");
|
|
284
|
+
const payload = ownDataValue(descriptors, "payload");
|
|
285
|
+
const signature = ownDataValue(descriptors, "signature");
|
|
286
|
+
if (version !== 1 ||
|
|
287
|
+
typeof issuedAtMs !== "number" ||
|
|
288
|
+
!Number.isSafeInteger(issuedAtMs) ||
|
|
289
|
+
issuedAtMs <= 0 ||
|
|
290
|
+
typeof payload !== "string" ||
|
|
291
|
+
!isWithinEncodedLimit(payload, MAX_SIGNED_PAYLOAD_BYTES) ||
|
|
292
|
+
typeof signature !== "string" ||
|
|
293
|
+
signature.length !== HMAC_SHA256_SIGNATURE_BASE64URL_CODE_UNITS) {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
return Object.freeze({
|
|
297
|
+
version: 1,
|
|
298
|
+
issuedAtMs,
|
|
299
|
+
payload,
|
|
300
|
+
signature,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
function signatureDomainPrefix(domain) {
|
|
304
|
+
return domain === "event" ? EVENT_SIGNATURE_DOMAIN : ACK_SIGNATURE_DOMAIN;
|
|
305
|
+
}
|
|
306
|
+
function signatureInput(domain, issuedAtMs, payload) {
|
|
307
|
+
const encoded = new TextEncoder().encode(`${signatureDomainPrefix(domain)}\0${issuedAtMs}\0${payload}`);
|
|
308
|
+
return encoded.buffer.slice(encoded.byteOffset, encoded.byteOffset + encoded.byteLength);
|
|
309
|
+
}
|
|
310
|
+
function base64UrlDecode(value) {
|
|
311
|
+
if (!/^[A-Za-z0-9_-]+$/u.test(value))
|
|
312
|
+
return null;
|
|
313
|
+
const padded = value.replaceAll("-", "+").replaceAll("_", "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
|
|
314
|
+
try {
|
|
315
|
+
return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0)).buffer;
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
async function createHmacKey(secret) {
|
|
322
|
+
return dntShim.crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
|
|
323
|
+
}
|
|
324
|
+
async function signPayload(key, domain, issuedAtMs, payload) {
|
|
325
|
+
return encodeBase64Url(new Uint8Array(await dntShim.crypto.subtle.sign("HMAC", key, signatureInput(domain, issuedAtMs, payload))));
|
|
326
|
+
}
|
|
327
|
+
async function verifyPayloadSignature(key, domain, issuedAtMs, payload, signature) {
|
|
328
|
+
const signatureBytes = base64UrlDecode(signature);
|
|
329
|
+
if (!signatureBytes ||
|
|
330
|
+
signatureBytes.byteLength !== HMAC_SHA256_SIGNATURE_BYTES)
|
|
331
|
+
return false;
|
|
332
|
+
return await dntShim.crypto.subtle.verify("HMAC", key, signatureBytes, signatureInput(domain, issuedAtMs, payload));
|
|
333
|
+
}
|
|
334
|
+
async function serializeSignedEnvelope(key, domain, payload, now) {
|
|
335
|
+
if (!isWithinEncodedLimit(payload, MAX_SIGNED_PAYLOAD_BYTES)) {
|
|
336
|
+
throw new Error("Proxy routing invalidation payload is too large");
|
|
337
|
+
}
|
|
338
|
+
const issuedAtMs = readClock(now);
|
|
339
|
+
return JSON.stringify({
|
|
340
|
+
version: 1,
|
|
341
|
+
issuedAtMs,
|
|
342
|
+
payload,
|
|
343
|
+
signature: await signPayload(key, domain, issuedAtMs, payload),
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
async function verifySignedEnvelope(key, domain, message, now) {
|
|
347
|
+
const envelope = parseSignedEnvelope(message);
|
|
348
|
+
if (!envelope)
|
|
349
|
+
return null;
|
|
350
|
+
const currentTimeMs = readClock(now);
|
|
351
|
+
if (envelope.issuedAtMs < currentTimeMs - DEFAULT_MAX_ENVELOPE_AGE_MS ||
|
|
352
|
+
envelope.issuedAtMs > currentTimeMs + DEFAULT_MAX_ENVELOPE_FUTURE_MS) {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
const verified = await verifyPayloadSignature(key, domain, envelope.issuedAtMs, envelope.payload, envelope.signature);
|
|
356
|
+
return verified ? envelope.payload : null;
|
|
357
|
+
}
|
|
358
|
+
function parseEvent(message) {
|
|
359
|
+
if (!isWithinEncodedLimit(message, MAX_SIGNED_PAYLOAD_BYTES))
|
|
360
|
+
return null;
|
|
361
|
+
let parsed;
|
|
362
|
+
try {
|
|
363
|
+
parsed = JSON.parse(message);
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
return parseProxyRoutingInvalidationEvent(parsed);
|
|
369
|
+
}
|
|
370
|
+
function parseAcknowledgement(message) {
|
|
371
|
+
if (!isWithinEncodedLimit(message, MAX_SIGNED_PAYLOAD_BYTES))
|
|
372
|
+
return null;
|
|
373
|
+
let parsed;
|
|
374
|
+
try {
|
|
375
|
+
parsed = JSON.parse(message);
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
381
|
+
return null;
|
|
382
|
+
let descriptors;
|
|
383
|
+
try {
|
|
384
|
+
descriptors = Object.getOwnPropertyDescriptors(parsed);
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
const eventId = ownDataValue(descriptors, "eventId");
|
|
390
|
+
const replicaId = ownDataValue(descriptors, "replicaId");
|
|
391
|
+
if (!isCanonicalOpaqueProjectIdentifier(eventId) ||
|
|
392
|
+
!isCanonicalOpaqueProjectIdentifier(replicaId)) {
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
return Object.freeze({ eventId, replicaId });
|
|
396
|
+
}
|
|
397
|
+
async function createDefaultClient(redisUrl) {
|
|
398
|
+
const createRedisClient = createClient;
|
|
399
|
+
return createRedisClient({
|
|
400
|
+
url: redisUrl,
|
|
401
|
+
socket: {
|
|
402
|
+
connectTimeout: DEFAULT_CONNECT_TIMEOUT_MS,
|
|
403
|
+
reconnectStrategy: (retries) => retries >= MAX_RECONNECT_ATTEMPTS
|
|
404
|
+
? new Error("Routing invalidation Redis reconnect limit reached")
|
|
405
|
+
: Math.min(100 * 2 ** retries, 1_000),
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
function logInfo(logger, message, extra) {
|
|
410
|
+
try {
|
|
411
|
+
logger?.info(message, extra);
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
// Diagnostic callbacks cannot own the routing lifecycle.
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
function logWarn(logger, message, extra) {
|
|
418
|
+
try {
|
|
419
|
+
logger?.warn(message, extra);
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
// Diagnostic callbacks cannot own the routing lifecycle.
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
function logError(logger, message, error, extra) {
|
|
426
|
+
try {
|
|
427
|
+
logger?.error(message, error, extra);
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
// Diagnostic callbacks cannot own the routing lifecycle.
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
async function closeClient(client, logger) {
|
|
434
|
+
try {
|
|
435
|
+
await client.close();
|
|
436
|
+
}
|
|
437
|
+
catch (error) {
|
|
438
|
+
try {
|
|
439
|
+
client.destroy();
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
// The process-level cleanup deadline remains authoritative.
|
|
443
|
+
}
|
|
444
|
+
logWarn(logger, "Failed to close routing invalidation Redis client cleanly", {
|
|
445
|
+
error: getErrorMessage(error),
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
export async function startProxyRoutingInvalidationBus(options) {
|
|
450
|
+
assertPlainStartOptions(options);
|
|
451
|
+
const configuredRedisUrl = readOwnOption(options, "redisUrl");
|
|
452
|
+
const redisUrl = requireRedisUrl(configuredRedisUrl === undefined ? getEnv("REDIS_URL") : configuredRedisUrl);
|
|
453
|
+
if (redisUrl === null)
|
|
454
|
+
return null;
|
|
455
|
+
const configuredIntegritySecret = readOwnOption(options, "integritySecret");
|
|
456
|
+
const integritySecret = requireIntegritySecret(configuredIntegritySecret === undefined
|
|
457
|
+
? getEnv(INTEGRITY_SECRET_ENV_VAR)
|
|
458
|
+
: configuredIntegritySecret);
|
|
459
|
+
if (integritySecret === null)
|
|
460
|
+
return null;
|
|
461
|
+
const configuredExpectedReplicas = readOwnOption(options, "expectedReplicas");
|
|
462
|
+
const expectedReplicas = resolveInteger(configuredExpectedReplicas === undefined
|
|
463
|
+
? getEnv("VERYFRONT_PROXY_EXPECTED_REPLICAS")
|
|
464
|
+
: configuredExpectedReplicas, 1, "Routing invalidation expected replicas", 1, MAX_REPLICA_COUNT);
|
|
465
|
+
const acknowledgementTimeoutMs = resolveInteger(readOwnOption(options, "acknowledgementTimeoutMs"), DEFAULT_ACKNOWLEDGEMENT_TIMEOUT_MS, "Routing invalidation acknowledgement timeout", 1, MAX_ACKNOWLEDGEMENT_TIMEOUT_MS);
|
|
466
|
+
const configuredReplicaId = readOwnOption(options, "replicaId");
|
|
467
|
+
const replicaId = validateReplicaId(configuredReplicaId === undefined
|
|
468
|
+
? getEnv("HOSTNAME") ?? dntShim.crypto.randomUUID()
|
|
469
|
+
: configuredReplicaId);
|
|
470
|
+
const createClient = requireFunction(readOwnOption(options, "createClient"), createDefaultClient, "Routing invalidation Redis client factory");
|
|
471
|
+
const onInvalidate = requireConfiguredFunction(readOwnOption(options, "onInvalidate"), "Routing invalidation callback");
|
|
472
|
+
const now = requireFunction(readOwnOption(options, "now"), () => Date.now(), "Routing invalidation clock");
|
|
473
|
+
readClock(now);
|
|
474
|
+
const logger = readOwnOption(options, "logger");
|
|
475
|
+
let rawPublishClient;
|
|
476
|
+
let rawSubscribeClient;
|
|
477
|
+
let publishClient;
|
|
478
|
+
let subscribeClient;
|
|
479
|
+
let hmacKey;
|
|
480
|
+
try {
|
|
481
|
+
rawPublishClient = await createClient(redisUrl);
|
|
482
|
+
publishClient = snapshotRedisClient(rawPublishClient, "Routing invalidation publish");
|
|
483
|
+
rawSubscribeClient = await createClient(redisUrl);
|
|
484
|
+
subscribeClient = snapshotRedisClient(rawSubscribeClient, "Routing invalidation subscribe");
|
|
485
|
+
hmacKey = await createHmacKey(integritySecret);
|
|
486
|
+
}
|
|
487
|
+
catch (error) {
|
|
488
|
+
safeDestroyUnknown(rawPublishClient);
|
|
489
|
+
if (rawSubscribeClient !== rawPublishClient) {
|
|
490
|
+
safeDestroyUnknown(rawSubscribeClient);
|
|
491
|
+
}
|
|
492
|
+
throw error;
|
|
493
|
+
}
|
|
494
|
+
const processedEventIds = new Set();
|
|
495
|
+
const eventProcessing = new Map();
|
|
496
|
+
const acknowledgementListeners = new Map();
|
|
497
|
+
const activeAcknowledgementChannels = new Set();
|
|
498
|
+
const activePublishEventIds = new Set();
|
|
499
|
+
const closeWaiters = new Set();
|
|
500
|
+
let closed = false;
|
|
501
|
+
let closePromise = null;
|
|
502
|
+
const createCloseWaiter = () => {
|
|
503
|
+
if (closed) {
|
|
504
|
+
return Object.freeze({
|
|
505
|
+
promise: Promise.resolve(),
|
|
506
|
+
dispose: () => undefined,
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
let resolve;
|
|
510
|
+
const promise = new Promise((innerResolve) => {
|
|
511
|
+
resolve = innerResolve;
|
|
512
|
+
});
|
|
513
|
+
closeWaiters.add(resolve);
|
|
514
|
+
return Object.freeze({
|
|
515
|
+
promise,
|
|
516
|
+
dispose: () => closeWaiters.delete(resolve),
|
|
517
|
+
});
|
|
518
|
+
};
|
|
519
|
+
const resolveCloseWaiters = () => {
|
|
520
|
+
for (const resolve of closeWaiters)
|
|
521
|
+
resolve();
|
|
522
|
+
closeWaiters.clear();
|
|
523
|
+
};
|
|
524
|
+
const subscribeAcknowledgement = async (channel, listener) => {
|
|
525
|
+
let listeners = acknowledgementListeners.get(channel);
|
|
526
|
+
if (!listeners) {
|
|
527
|
+
listeners = new Set();
|
|
528
|
+
acknowledgementListeners.set(channel, listeners);
|
|
529
|
+
try {
|
|
530
|
+
await subscribeClient.subscribe(channel, (message, receivedChannel) => {
|
|
531
|
+
if (receivedChannel !== channel)
|
|
532
|
+
return;
|
|
533
|
+
for (const acknowledgementListener of listeners ?? []) {
|
|
534
|
+
acknowledgementListener(message, receivedChannel);
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
catch (error) {
|
|
539
|
+
acknowledgementListeners.delete(channel);
|
|
540
|
+
throw error;
|
|
541
|
+
}
|
|
542
|
+
if (closed) {
|
|
543
|
+
acknowledgementListeners.delete(channel);
|
|
544
|
+
try {
|
|
545
|
+
await subscribeClient.unsubscribe(channel);
|
|
546
|
+
}
|
|
547
|
+
catch {
|
|
548
|
+
// The bus close path owns the underlying connection.
|
|
549
|
+
}
|
|
550
|
+
throw new Error("Proxy routing invalidation bus is closed");
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
listeners.add(listener);
|
|
554
|
+
activeAcknowledgementChannels.add(channel);
|
|
555
|
+
};
|
|
556
|
+
const unsubscribeAcknowledgement = async (channel, listener) => {
|
|
557
|
+
const listeners = acknowledgementListeners.get(channel);
|
|
558
|
+
if (!listeners)
|
|
559
|
+
return;
|
|
560
|
+
listeners.delete(listener);
|
|
561
|
+
if (listeners.size > 0)
|
|
562
|
+
return;
|
|
563
|
+
acknowledgementListeners.delete(channel);
|
|
564
|
+
activeAcknowledgementChannels.delete(channel);
|
|
565
|
+
if (!closed)
|
|
566
|
+
await subscribeClient.unsubscribe(channel);
|
|
567
|
+
};
|
|
568
|
+
const rememberProcessedEvent = (eventId) => {
|
|
569
|
+
processedEventIds.delete(eventId);
|
|
570
|
+
processedEventIds.add(eventId);
|
|
571
|
+
while (processedEventIds.size > MAX_RECENT_EVENT_IDS) {
|
|
572
|
+
const oldestEventId = processedEventIds.values().next().value;
|
|
573
|
+
if (!oldestEventId)
|
|
574
|
+
break;
|
|
575
|
+
processedEventIds.delete(oldestEventId);
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
const processEvent = (event) => {
|
|
579
|
+
if (processedEventIds.has(event.eventId))
|
|
580
|
+
return Promise.resolve();
|
|
581
|
+
const existing = eventProcessing.get(event.eventId);
|
|
582
|
+
if (existing)
|
|
583
|
+
return existing;
|
|
584
|
+
if (eventProcessing.size >= MAX_ACTIVE_EVENT_PROCESSING) {
|
|
585
|
+
return Promise.reject(new Error("Proxy routing invalidation processing capacity is exhausted"));
|
|
586
|
+
}
|
|
587
|
+
const processing = Promise.resolve()
|
|
588
|
+
.then(() => onInvalidate(event))
|
|
589
|
+
.then(() => {
|
|
590
|
+
rememberProcessedEvent(event.eventId);
|
|
591
|
+
eventProcessing.delete(event.eventId);
|
|
592
|
+
}, (error) => {
|
|
593
|
+
eventProcessing.delete(event.eventId);
|
|
594
|
+
throw error;
|
|
595
|
+
});
|
|
596
|
+
eventProcessing.set(event.eventId, processing);
|
|
597
|
+
return processing;
|
|
598
|
+
};
|
|
599
|
+
const logRedisError = (error) => {
|
|
600
|
+
logError(logger, "Proxy routing invalidation Redis error", error);
|
|
601
|
+
};
|
|
602
|
+
try {
|
|
603
|
+
publishClient.on?.("error", logRedisError);
|
|
604
|
+
subscribeClient.on?.("error", logRedisError);
|
|
605
|
+
await Promise.all([publishClient.connect(), subscribeClient.connect()]);
|
|
606
|
+
await subscribeClient.subscribe(ROUTING_INVALIDATION_CHANNEL, (message, channel) => {
|
|
607
|
+
if (channel !== ROUTING_INVALIDATION_CHANNEL)
|
|
608
|
+
return;
|
|
609
|
+
void verifySignedEnvelope(hmacKey, "event", message, now)
|
|
610
|
+
.then((payload) => {
|
|
611
|
+
if (!payload || closed)
|
|
612
|
+
return null;
|
|
613
|
+
const event = parseEvent(payload);
|
|
614
|
+
if (!event)
|
|
615
|
+
return null;
|
|
616
|
+
return processEvent(event).then(async () => {
|
|
617
|
+
if (closed)
|
|
618
|
+
return;
|
|
619
|
+
const acknowledgementPayload = JSON.stringify({ eventId: event.eventId, replicaId });
|
|
620
|
+
await publishClient.publish(`${ROUTING_INVALIDATION_ACK_PREFIX}${event.eventId}`, await serializeSignedEnvelope(hmacKey, "ack", acknowledgementPayload, now));
|
|
621
|
+
});
|
|
622
|
+
})
|
|
623
|
+
.catch((error) => {
|
|
624
|
+
logError(logger, "Failed to apply proxy routing invalidation", error);
|
|
625
|
+
});
|
|
626
|
+
});
|
|
627
|
+
logInfo(logger, "Proxy routing invalidation bus connected", {
|
|
628
|
+
expectedReplicas,
|
|
629
|
+
replicaId,
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
catch (error) {
|
|
633
|
+
safeDestroyUnknown(rawPublishClient);
|
|
634
|
+
if (rawSubscribeClient !== rawPublishClient) {
|
|
635
|
+
safeDestroyUnknown(rawSubscribeClient);
|
|
636
|
+
}
|
|
637
|
+
throw error;
|
|
638
|
+
}
|
|
639
|
+
const bus = {
|
|
640
|
+
async publish(eventValue) {
|
|
641
|
+
if (closed)
|
|
642
|
+
throw new Error("Proxy routing invalidation bus is closed");
|
|
643
|
+
const event = parseProxyRoutingInvalidationEvent(eventValue);
|
|
644
|
+
if (!event) {
|
|
645
|
+
throw new TypeError("Proxy routing invalidation event is invalid");
|
|
646
|
+
}
|
|
647
|
+
if (activePublishEventIds.has(event.eventId)) {
|
|
648
|
+
throw new Error(`Proxy routing invalidation event ${event.eventId} is already publishing`);
|
|
649
|
+
}
|
|
650
|
+
if (activePublishEventIds.size >= MAX_ACTIVE_PUBLISHES) {
|
|
651
|
+
throw new Error("Proxy routing invalidation publish capacity is exhausted");
|
|
652
|
+
}
|
|
653
|
+
activePublishEventIds.add(event.eventId);
|
|
654
|
+
const acknowledgementChannel = `${ROUTING_INVALIDATION_ACK_PREFIX}${event.eventId}`;
|
|
655
|
+
const acknowledgedReplicas = new Set();
|
|
656
|
+
let recipients = 0;
|
|
657
|
+
let resolveAcknowledged;
|
|
658
|
+
const acknowledgementReceived = new Promise((resolve) => {
|
|
659
|
+
resolveAcknowledged = resolve;
|
|
660
|
+
});
|
|
661
|
+
const acknowledgementListener = (message, channel) => {
|
|
662
|
+
if (channel !== acknowledgementChannel)
|
|
663
|
+
return;
|
|
664
|
+
void verifySignedEnvelope(hmacKey, "ack", message, now)
|
|
665
|
+
.then((payload) => {
|
|
666
|
+
if (!payload)
|
|
667
|
+
return;
|
|
668
|
+
const acknowledgement = parseAcknowledgement(payload);
|
|
669
|
+
if (!acknowledgement || acknowledgement.eventId !== event.eventId)
|
|
670
|
+
return;
|
|
671
|
+
if (acknowledgedReplicas.size < MAX_REPLICA_COUNT) {
|
|
672
|
+
acknowledgedReplicas.add(acknowledgement.replicaId);
|
|
673
|
+
}
|
|
674
|
+
if (recipients > 0 && acknowledgedReplicas.size >= recipients) {
|
|
675
|
+
resolveAcknowledged?.();
|
|
676
|
+
}
|
|
677
|
+
})
|
|
678
|
+
.catch((error) => {
|
|
679
|
+
logError(logger, "Failed to verify proxy routing invalidation acknowledgement", error, { eventId: event.eventId });
|
|
680
|
+
});
|
|
681
|
+
};
|
|
682
|
+
let subscribed = false;
|
|
683
|
+
try {
|
|
684
|
+
await subscribeAcknowledgement(acknowledgementChannel, acknowledgementListener);
|
|
685
|
+
subscribed = true;
|
|
686
|
+
const eventPayload = JSON.stringify(event);
|
|
687
|
+
try {
|
|
688
|
+
recipients = await publishClient.publish(ROUTING_INVALIDATION_CHANNEL, await serializeSignedEnvelope(hmacKey, "event", eventPayload, now));
|
|
689
|
+
}
|
|
690
|
+
catch (error) {
|
|
691
|
+
if (!closed)
|
|
692
|
+
throw error;
|
|
693
|
+
return Object.freeze({
|
|
694
|
+
acknowledged: Math.min(acknowledgedReplicas.size, recipients),
|
|
695
|
+
converged: false,
|
|
696
|
+
recipients,
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
if (recipients > 0 && acknowledgedReplicas.size >= recipients)
|
|
700
|
+
resolveAcknowledged?.();
|
|
701
|
+
if (recipients > 0) {
|
|
702
|
+
const closeWaiter = createCloseWaiter();
|
|
703
|
+
let timeoutId;
|
|
704
|
+
try {
|
|
705
|
+
await Promise.race([
|
|
706
|
+
acknowledgementReceived,
|
|
707
|
+
closeWaiter.promise,
|
|
708
|
+
new Promise((resolve) => {
|
|
709
|
+
timeoutId = dntShim.setTimeout(resolve, acknowledgementTimeoutMs);
|
|
710
|
+
}),
|
|
711
|
+
]);
|
|
712
|
+
}
|
|
713
|
+
finally {
|
|
714
|
+
if (timeoutId !== undefined)
|
|
715
|
+
clearTimeout(timeoutId);
|
|
716
|
+
closeWaiter.dispose();
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
const acknowledged = Math.min(acknowledgedReplicas.size, recipients);
|
|
720
|
+
return Object.freeze({
|
|
721
|
+
acknowledged,
|
|
722
|
+
converged: recipients >= expectedReplicas && acknowledged >= recipients,
|
|
723
|
+
recipients,
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
finally {
|
|
727
|
+
try {
|
|
728
|
+
if (subscribed) {
|
|
729
|
+
await unsubscribeAcknowledgement(acknowledgementChannel, acknowledgementListener);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
finally {
|
|
733
|
+
activePublishEventIds.delete(event.eventId);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
},
|
|
737
|
+
close() {
|
|
738
|
+
if (closePromise)
|
|
739
|
+
return closePromise;
|
|
740
|
+
closed = true;
|
|
741
|
+
resolveCloseWaiters();
|
|
742
|
+
closePromise = (async () => {
|
|
743
|
+
try {
|
|
744
|
+
await Promise.allSettled([
|
|
745
|
+
subscribeClient.unsubscribe(ROUTING_INVALIDATION_CHANNEL),
|
|
746
|
+
...[...activeAcknowledgementChannels].map((channel) => subscribeClient.unsubscribe(channel)),
|
|
747
|
+
]);
|
|
748
|
+
}
|
|
749
|
+
finally {
|
|
750
|
+
const clients = rawPublishClient === rawSubscribeClient
|
|
751
|
+
? [publishClient]
|
|
752
|
+
: [publishClient, subscribeClient];
|
|
753
|
+
await Promise.all(clients.map((client) => closeClient(client, logger)));
|
|
754
|
+
}
|
|
755
|
+
})();
|
|
756
|
+
return closePromise;
|
|
757
|
+
},
|
|
758
|
+
};
|
|
759
|
+
return Object.freeze(bus);
|
|
760
|
+
}
|