tina4-nodejs 3.13.92 → 3.13.95
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/CLAUDE.md +170 -28
- package/README.md +2 -2
- package/package.json +13 -9
- package/packages/cli/dist/bin.js +33126 -30055
- package/packages/cli/src/commands/metrics.ts +17 -11
- package/packages/cli/src/commands/serve.ts +10 -9
- package/packages/core/dist/index.js +33062 -29908
- package/packages/core/src/ai.ts +7 -1
- package/packages/core/src/auth.ts +191 -39
- package/packages/core/src/background.ts +19 -19
- package/packages/core/src/cache.ts +492 -49
- package/packages/core/src/devAdmin.ts +79 -32
- package/packages/core/src/devMailbox.ts +20 -44
- package/packages/core/src/dispatchPipeline.ts +285 -0
- package/packages/core/src/dotenv.ts +185 -40
- package/packages/core/src/index.ts +7 -6
- package/packages/core/src/logger.ts +257 -36
- package/packages/core/src/mcp.ts +1 -1
- package/packages/core/src/messenger.ts +81 -13
- package/packages/core/src/metrics.ts +199 -961
- package/packages/core/src/middleware.ts +390 -123
- package/packages/core/src/queue.ts +188 -32
- package/packages/core/src/queueBackends/kafkaBackend.ts +109 -13
- package/packages/core/src/queueBackends/liteBackend.ts +13 -0
- package/packages/core/src/queueBackends/mongoBackend.ts +101 -9
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +22 -4
- package/packages/core/src/rateLimiter.ts +10 -5
- package/packages/core/src/request.ts +6 -9
- package/packages/core/src/response.ts +46 -1
- package/packages/core/src/router.ts +29 -4
- package/packages/core/src/server.ts +751 -414
- package/packages/core/src/session.ts +244 -27
- package/packages/core/src/sessionHandlers/childError.ts +72 -0
- package/packages/core/src/sessionHandlers/databaseHandler.ts +338 -48
- package/packages/core/src/sessionHandlers/memcachedHandler.ts +181 -0
- package/packages/core/src/sessionHandlers/mongoClient.ts +293 -202
- package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
- package/packages/core/src/sessionHandlers/respClient.ts +16 -143
- package/packages/core/src/sessionHandlers/sqlClient.ts +290 -0
- package/packages/core/src/sessionHandlers/syncBridge.ts +190 -0
- package/packages/core/src/sessionHandlers/syncSocket.ts +236 -0
- package/packages/core/src/testClient.ts +18 -5
- package/packages/core/src/trustedProxy.ts +249 -0
- package/packages/core/src/types.ts +29 -5
- package/packages/core/src/websocket.ts +66 -0
- package/packages/frond/dist/index.js +74 -31
- package/packages/frond/src/engine.ts +99 -33
- package/packages/orm/dist/index.js +26554 -23400
- package/packages/orm/src/adapters/firebird.ts +183 -56
- package/packages/orm/src/adapters/mongodb.ts +25 -4
- package/packages/orm/src/adapters/mssql.ts +114 -29
- package/packages/orm/src/adapters/mysql.ts +103 -40
- package/packages/orm/src/adapters/odbc.ts +44 -21
- package/packages/orm/src/adapters/postgres.ts +118 -26
- package/packages/orm/src/adapters/sqlDialect.ts +120 -0
- package/packages/orm/src/adapters/sqlite.ts +64 -25
- package/packages/orm/src/baseModel.ts +135 -40
- package/packages/orm/src/cachedDatabase.ts +43 -19
- package/packages/orm/src/connectTimeout.ts +265 -0
- package/packages/orm/src/database.ts +338 -198
- package/packages/orm/src/databaseResult.ts +65 -13
- package/packages/orm/src/databaseUrl.ts +484 -0
- package/packages/orm/src/docstore.ts +386 -145
- package/packages/orm/src/index.ts +13 -3
- package/packages/orm/src/migration.ts +18 -3
- package/packages/orm/src/queryBuilder.ts +38 -4
- package/packages/orm/src/sqlTranslator.ts +310 -4
- package/packages/orm/src/types.ts +15 -4
- package/types/cli/src/bin.d.ts +92 -0
- package/types/cli/src/commands/build.d.ts +2 -0
- package/types/cli/src/commands/generate.d.ts +47 -0
- package/types/cli/src/commands/init.d.ts +1 -0
- package/types/cli/src/commands/metrics.d.ts +6 -0
- package/types/cli/src/commands/migrate.d.ts +1 -0
- package/types/cli/src/commands/migrateCreate.d.ts +1 -0
- package/types/cli/src/commands/migrateRollback.d.ts +1 -0
- package/types/cli/src/commands/migrateStatus.d.ts +1 -0
- package/types/cli/src/commands/queue.d.ts +20 -0
- package/types/cli/src/commands/routes.d.ts +1 -0
- package/types/cli/src/commands/seed.d.ts +1 -0
- package/types/cli/src/commands/serve.d.ts +6 -0
- package/types/cli/src/commands/test.d.ts +1 -0
- package/types/core/src/ai.d.ts +64 -0
- package/types/core/src/api.d.ts +262 -0
- package/types/core/src/auth.d.ts +177 -0
- package/types/core/src/authGate.d.ts +20 -0
- package/types/core/src/background.d.ts +34 -0
- package/types/core/src/cache.d.ts +163 -0
- package/types/core/src/constants.d.ts +38 -0
- package/types/core/src/container.d.ts +44 -0
- package/types/core/src/context/chunker.d.ts +31 -0
- package/types/core/src/context/index.d.ts +93 -0
- package/types/core/src/devAdmin.d.ts +179 -0
- package/types/core/src/devMailbox.d.ts +54 -0
- package/types/core/src/dispatchPipeline.d.ts +117 -0
- package/types/core/src/docs.d.ts +141 -0
- package/types/core/src/docsAutoDiscovery.d.ts +6 -0
- package/types/core/src/dotenv.d.ts +87 -0
- package/types/core/src/env.d.ts +28 -0
- package/types/core/src/errorOverlay.d.ts +36 -0
- package/types/core/src/events.d.ts +75 -0
- package/types/core/src/fakeData.d.ts +55 -0
- package/types/core/src/feedback.d.ts +90 -0
- package/types/core/src/graphql.d.ts +207 -0
- package/types/core/src/health.d.ts +22 -0
- package/types/core/src/htmlElement.d.ts +75 -0
- package/types/core/src/i18n.d.ts +37 -0
- package/types/core/src/index.d.ts +92 -0
- package/types/core/src/job.d.ts +39 -0
- package/types/core/src/logger.d.ts +200 -0
- package/types/core/src/mcp.d.ts +248 -0
- package/types/core/src/messenger.d.ts +191 -0
- package/types/core/src/metrics.d.ts +41 -0
- package/types/core/src/middleware.d.ts +330 -0
- package/types/core/src/mqtt.d.ts +257 -0
- package/types/core/src/mqttMessage.d.ts +67 -0
- package/types/core/src/plan.d.ts +96 -0
- package/types/core/src/projectIndex.d.ts +56 -0
- package/types/core/src/queue.d.ts +268 -0
- package/types/core/src/queueBackends/kafkaBackend.d.ts +117 -0
- package/types/core/src/queueBackends/liteBackend.d.ts +128 -0
- package/types/core/src/queueBackends/mongoBackend.d.ts +119 -0
- package/types/core/src/queueBackends/rabbitmqBackend.d.ts +55 -0
- package/types/core/src/rateLimiter.d.ts +49 -0
- package/types/core/src/request.d.ts +25 -0
- package/types/core/src/response.d.ts +28 -0
- package/types/core/src/routeDiscovery.d.ts +12 -0
- package/types/core/src/router.d.ts +366 -0
- package/types/core/src/scss.d.ts +19 -0
- package/types/core/src/server.d.ts +146 -0
- package/types/core/src/service.d.ts +115 -0
- package/types/core/src/session.d.ts +341 -0
- package/types/core/src/sessionHandlers/childError.d.ts +34 -0
- package/types/core/src/sessionHandlers/databaseHandler.d.ts +97 -0
- package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
- package/types/core/src/sessionHandlers/mongoClient.d.ts +35 -0
- package/types/core/src/sessionHandlers/mongoHandler.d.ts +109 -0
- package/types/core/src/sessionHandlers/respClient.d.ts +22 -0
- package/types/core/src/sessionHandlers/sqlClient.d.ts +39 -0
- package/types/core/src/sessionHandlers/syncBridge.d.ts +91 -0
- package/types/core/src/sessionHandlers/syncSocket.d.ts +49 -0
- package/types/core/src/sessionHandlers/valkeyHandler.d.ts +65 -0
- package/types/core/src/static.d.ts +2 -0
- package/types/core/src/test.d.ts +94 -0
- package/types/core/src/testClient.d.ts +36 -0
- package/types/core/src/testing.d.ts +58 -0
- package/types/core/src/trustedProxy.d.ts +44 -0
- package/types/core/src/types.d.ts +242 -0
- package/types/core/src/validator.d.ts +52 -0
- package/types/core/src/websocket.d.ts +402 -0
- package/types/core/src/websocketBackplane.d.ts +166 -0
- package/types/core/src/websocketConnection.d.ts +54 -0
- package/types/core/src/wsdl.d.ts +101 -0
- package/types/frond/src/engine.d.ts +263 -0
- package/types/frond/src/index.d.ts +2 -0
- package/types/orm/src/adapters/firebird.d.ts +183 -0
- package/types/orm/src/adapters/mongodb.d.ts +81 -0
- package/types/orm/src/adapters/mssql.d.ts +77 -0
- package/types/orm/src/adapters/mysql.d.ts +67 -0
- package/types/orm/src/adapters/odbc.d.ts +94 -0
- package/types/orm/src/adapters/postgres.d.ts +86 -0
- package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
- package/types/orm/src/adapters/sqlite.d.ts +68 -0
- package/types/orm/src/autoCrud.d.ts +73 -0
- package/types/orm/src/baseModel.d.ts +427 -0
- package/types/orm/src/cachedDatabase.d.ts +190 -0
- package/types/orm/src/connectTimeout.d.ts +100 -0
- package/types/orm/src/database.d.ts +655 -0
- package/types/orm/src/databaseResult.d.ts +109 -0
- package/types/orm/src/databaseUrl.d.ts +125 -0
- package/types/orm/src/docstore.d.ts +241 -0
- package/types/orm/src/fakeData.d.ts +22 -0
- package/types/orm/src/index.d.ts +43 -0
- package/types/orm/src/migration.d.ts +275 -0
- package/types/orm/src/model.d.ts +7 -0
- package/types/orm/src/query.d.ts +14 -0
- package/types/orm/src/queryBuilder.d.ts +193 -0
- package/types/orm/src/realtime/index.d.ts +7 -0
- package/types/orm/src/realtime/models/attachment.d.ts +43 -0
- package/types/orm/src/realtime/models/channel.d.ts +32 -0
- package/types/orm/src/realtime/models/channelMember.d.ts +32 -0
- package/types/orm/src/realtime/models/message.d.ts +36 -0
- package/types/orm/src/realtime/models/workspace.d.ts +26 -0
- package/types/orm/src/realtime/realtime.d.ts +24 -0
- package/types/orm/src/realtime/storage.d.ts +61 -0
- package/types/orm/src/seeder.d.ts +118 -0
- package/types/orm/src/sqlTranslator.d.ts +258 -0
- package/types/orm/src/types.d.ts +148 -0
- package/types/orm/src/validation.d.ts +6 -0
- package/types/swagger/src/generator.d.ts +46 -0
- package/types/swagger/src/index.d.ts +2 -0
- package/types/swagger/src/ui.d.ts +11 -0
- package/packages/core/src/sessionHandlers/redisHandler.ts +0 -206
|
@@ -121,6 +121,10 @@ interface CacheEntry {
|
|
|
121
121
|
contentType: string;
|
|
122
122
|
statusCode: number;
|
|
123
123
|
expiresAt: number;
|
|
124
|
+
/** RFC 9111 s4.1 — the field names the origin nominated in its Vary header. */
|
|
125
|
+
vary?: string[];
|
|
126
|
+
/** The values those fields had on the request that caused this to be stored. */
|
|
127
|
+
varyValues?: Record<string, string | undefined>;
|
|
124
128
|
}
|
|
125
129
|
|
|
126
130
|
interface DirectEntry {
|
|
@@ -150,6 +154,20 @@ interface CacheBackend {
|
|
|
150
154
|
set(key: string, value: unknown, ttl: number): Promise<void>;
|
|
151
155
|
delete(key: string): Promise<boolean>;
|
|
152
156
|
clear(): Promise<void>;
|
|
157
|
+
/**
|
|
158
|
+
* Evict expired entries and return HOW MANY were actually evicted.
|
|
159
|
+
*
|
|
160
|
+
* REQUIRED, not optional. It used to be neither declared nor implemented, so
|
|
161
|
+
* the module-level sweep() found no backend method and returned a permanent
|
|
162
|
+
* 0: the one API whose job is reclaiming expired space did nothing and
|
|
163
|
+
* reported success. Declaring it here makes "every provider can sweep" a
|
|
164
|
+
* compile-time fact instead of a runtime hope.
|
|
165
|
+
*
|
|
166
|
+
* 0 is the HONEST answer on redis/valkey/memcached/mongodb - they expire
|
|
167
|
+
* entries server-side, so there is nothing left for us to evict. It is the
|
|
168
|
+
* WRONG answer for memory, file and database, which own their own expiry.
|
|
169
|
+
*/
|
|
170
|
+
sweep(): Promise<number>;
|
|
153
171
|
stats(): Promise<{ hits: number; misses: number; size: number; backend: string }>;
|
|
154
172
|
name(): string;
|
|
155
173
|
/**
|
|
@@ -216,6 +234,23 @@ class MemoryBackend implements CacheBackend {
|
|
|
216
234
|
this.misses = 0;
|
|
217
235
|
}
|
|
218
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Drop expired entries and count them. `expiresAt > 0` is load-bearing: an
|
|
239
|
+
* entry stored with ttl <= 0 is permanent and carries 0, so a bare
|
|
240
|
+
* `now > expiresAt` would evict every permanent entry on the first sweep.
|
|
241
|
+
*/
|
|
242
|
+
async sweep(): Promise<number> {
|
|
243
|
+
const now = Date.now();
|
|
244
|
+
let evicted = 0;
|
|
245
|
+
for (const [key, entry] of this.store) {
|
|
246
|
+
if (entry.expiresAt > 0 && now > entry.expiresAt) {
|
|
247
|
+
this.store.delete(key);
|
|
248
|
+
evicted++;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return evicted;
|
|
252
|
+
}
|
|
253
|
+
|
|
219
254
|
async stats() {
|
|
220
255
|
// Sweep expired
|
|
221
256
|
const now = Date.now();
|
|
@@ -411,7 +446,7 @@ class RespClient {
|
|
|
411
446
|
* cache op is one async round-trip. AUTH + SELECT db run once on connect so a
|
|
412
447
|
* dead port OR wrong credentials fail the availability probe and fall back to
|
|
413
448
|
* file. Mirrors the Python master's _RedisBackend semantics (prefix, SETEX,
|
|
414
|
-
* scoped
|
|
449
|
+
* scoped SCAN+DEL clear, DBSIZE stats).
|
|
415
450
|
*/
|
|
416
451
|
class RedisBackend implements CacheBackend {
|
|
417
452
|
protected host: string;
|
|
@@ -508,29 +543,99 @@ class RedisBackend implements CacheBackend {
|
|
|
508
543
|
return result === "1";
|
|
509
544
|
}
|
|
510
545
|
|
|
546
|
+
/**
|
|
547
|
+
* Remove EVERY entry this cache can serve, and nothing else.
|
|
548
|
+
*
|
|
549
|
+
* SCAN, not KEYS: clear() runs on EVERY WRITE in persistent DB-cache mode,
|
|
550
|
+
* and KEYS is O(N) over the whole keyspace and blocks the entire server for
|
|
551
|
+
* its duration. Redis's own documentation says to prefer SCAN in production.
|
|
552
|
+
* The cursor loop is scoped to our prefix, so another application sharing the
|
|
553
|
+
* server is untouched - FLUSHALL/FLUSHDB would take their data with it and is
|
|
554
|
+
* never used here.
|
|
555
|
+
*
|
|
556
|
+
* The scan runs to cursor 0, so a keyspace larger than one page is fully
|
|
557
|
+
* cleared; stopping at the first page would leave later entries readable and
|
|
558
|
+
* still look green on a small test.
|
|
559
|
+
*/
|
|
560
|
+
/**
|
|
561
|
+
* Walk every key under OUR prefix, handing each page to `onPage`.
|
|
562
|
+
*
|
|
563
|
+
* ONE walk drives both clear() and stats(), so the two can never disagree
|
|
564
|
+
* about what this cache holds - which is exactly how stats() came to report a
|
|
565
|
+
* different keyspace from the one clear() empties.
|
|
566
|
+
*
|
|
567
|
+
* SCAN, not KEYS: clear() runs on EVERY WRITE in persistent DB-cache mode,
|
|
568
|
+
* and KEYS is O(N) over the whole keyspace and blocks the entire server for
|
|
569
|
+
* its duration. Redis's own documentation says to prefer SCAN. The walk is
|
|
570
|
+
* scoped to our prefix, so another application sharing the server is
|
|
571
|
+
* untouched, and it runs to cursor 0 so a keyspace larger than one page is
|
|
572
|
+
* fully covered.
|
|
573
|
+
*/
|
|
574
|
+
private async walkPrefixedKeys(onPage: (keys: string[]) => Promise<void>): Promise<void> {
|
|
575
|
+
let cursor = "0";
|
|
576
|
+
do {
|
|
577
|
+
const reply = await this.client.command(
|
|
578
|
+
"SCAN", cursor, "MATCH", this.prefix + "*", "COUNT", "500",
|
|
579
|
+
);
|
|
580
|
+
// A SCAN reply is a 2-element multi-bulk: [next cursor, [key, ...]].
|
|
581
|
+
if (!Array.isArray(reply) || reply.length !== 2) break;
|
|
582
|
+
cursor = typeof reply[0] === "string" ? reply[0] : "0";
|
|
583
|
+
const page = reply[1];
|
|
584
|
+
if (Array.isArray(page) && page.length > 0) {
|
|
585
|
+
const keys = page.filter((k): k is string => typeof k === "string");
|
|
586
|
+
if (keys.length > 0) await onPage(keys);
|
|
587
|
+
}
|
|
588
|
+
} while (cursor !== "0");
|
|
589
|
+
}
|
|
590
|
+
|
|
511
591
|
async clear(): Promise<void> {
|
|
512
592
|
this.hits = 0;
|
|
513
593
|
this.misses = 0;
|
|
514
|
-
// Scoped KEYS + DEL of our namespace only (never FLUSHALL — the harness is
|
|
515
|
-
// shared with other agents, and so are real deployments).
|
|
516
594
|
try {
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
if (flat.length > 0) await this.client.command("DEL", ...flat);
|
|
521
|
-
}
|
|
595
|
+
await this.walkPrefixedKeys(async (keys) => {
|
|
596
|
+
await this.client.command("DEL", ...keys);
|
|
597
|
+
});
|
|
522
598
|
} catch {
|
|
523
599
|
/* best effort */
|
|
524
600
|
}
|
|
525
601
|
}
|
|
526
602
|
|
|
603
|
+
/**
|
|
604
|
+
* Nothing to do: redis/valkey expires entries SERVER-SIDE (the TTL set by SETEX), so by the
|
|
605
|
+
* time a sweep runs there is nothing left for us to evict. 0 is the honest
|
|
606
|
+
* count, not a stub - inventing a number here would be a lie, and scanning
|
|
607
|
+
* the keyspace to "prove" it would cost a full scan to always return 0.
|
|
608
|
+
*/
|
|
609
|
+
async sweep(): Promise<number> {
|
|
610
|
+
return 0;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Report OUR entries, not the whole server's.
|
|
615
|
+
*
|
|
616
|
+
* This used to read DBSIZE, which counts the WHOLE database index - so on a
|
|
617
|
+
* shared Redis it included every key any other tenant had written. MEASURED
|
|
618
|
+
* before the fix: three of our writes plus two foreign keys reported size 5.
|
|
619
|
+
* Every other backend here is scoped (memory counts its own map, file its own
|
|
620
|
+
* directory, mongo its own collection, database its own table, memcached its
|
|
621
|
+
* own write log), and Ruby/Python had the same rule broken the other way
|
|
622
|
+
* round, returning a constant 0. Both fail the same rule: the number must
|
|
623
|
+
* describe THIS cache.
|
|
624
|
+
*
|
|
625
|
+
* The count comes from the same scoped walk clear() uses. Keys are deduped
|
|
626
|
+
* through a Set because SCAN may return a given key more than once across
|
|
627
|
+
* iterations (Redis guarantees at-least-once, not exactly-once).
|
|
628
|
+
*/
|
|
527
629
|
async stats() {
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
630
|
+
const seen = new Set<string>();
|
|
631
|
+
try {
|
|
632
|
+
await this.walkPrefixedKeys(async (keys) => {
|
|
633
|
+
for (const key of keys) seen.add(key);
|
|
634
|
+
});
|
|
635
|
+
} catch {
|
|
636
|
+
/* a failed walk reports 0 rather than a number from somewhere else */
|
|
637
|
+
}
|
|
638
|
+
return { hits: this.hits, misses: this.misses, size: seen.size, backend: this._name };
|
|
534
639
|
}
|
|
535
640
|
|
|
536
641
|
name() { return this._name; }
|
|
@@ -579,7 +684,23 @@ class FileBackend implements CacheBackend {
|
|
|
579
684
|
return undefined;
|
|
580
685
|
}
|
|
581
686
|
this.hits++;
|
|
582
|
-
|
|
687
|
+
// A cached null must come back as NULL, not as the storage envelope.
|
|
688
|
+
//
|
|
689
|
+
// This was `data.value ?? data`. `data` is the envelope
|
|
690
|
+
// {key, value, expiresAt}, so whenever the stored value was null the
|
|
691
|
+
// `??` fell through and handed the caller that OBJECT - which is truthy -
|
|
692
|
+
// where the caller had stored nothing. Every `if (cached)` then took the
|
|
693
|
+
// hit branch with a meaningless object, so the cache turned "this lookup
|
|
694
|
+
// found nothing" into "this lookup found something". Caching a negative
|
|
695
|
+
// lookup is the most common reason to cache a null at all, so it was
|
|
696
|
+
// wrong exactly where the feature gets used.
|
|
697
|
+
//
|
|
698
|
+
// The test is the ENVELOPE SHAPE, never the value's truthiness: false, 0,
|
|
699
|
+
// "" and [] are values, and a truthiness check would break all of them.
|
|
700
|
+
// The non-envelope fallback stays for a file this backend did not write.
|
|
701
|
+
const isEnvelope = data !== null && typeof data === "object"
|
|
702
|
+
&& "value" in data && "expiresAt" in data;
|
|
703
|
+
return isEnvelope ? data.value : data;
|
|
583
704
|
} catch {
|
|
584
705
|
this.misses++;
|
|
585
706
|
return undefined;
|
|
@@ -648,6 +769,28 @@ class FileBackend implements CacheBackend {
|
|
|
648
769
|
return { hits: this.hits, misses: this.misses, size: count, backend: "file" };
|
|
649
770
|
}
|
|
650
771
|
|
|
772
|
+
/**
|
|
773
|
+
* Delete expired cache files and count them. `expiresAt > 0` is load-bearing:
|
|
774
|
+
* a no-TTL entry is stored with 0 and must survive every sweep.
|
|
775
|
+
*/
|
|
776
|
+
async sweep(): Promise<number> {
|
|
777
|
+
const now = Date.now() / 1000;
|
|
778
|
+
let evicted = 0;
|
|
779
|
+
try {
|
|
780
|
+
for (const f of fs.readdirSync(this.dir).filter((n) => n.endsWith(".json"))) {
|
|
781
|
+
const p = path.join(this.dir, f);
|
|
782
|
+
try {
|
|
783
|
+
const data = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
784
|
+
if (data.expiresAt > 0 && now > data.expiresAt) {
|
|
785
|
+
fs.unlinkSync(p);
|
|
786
|
+
evicted++;
|
|
787
|
+
}
|
|
788
|
+
} catch { /* an unreadable file is not ours to count */ }
|
|
789
|
+
}
|
|
790
|
+
} catch { /* no cache directory yet */ }
|
|
791
|
+
return evicted;
|
|
792
|
+
}
|
|
793
|
+
|
|
651
794
|
name() { return "file"; }
|
|
652
795
|
}
|
|
653
796
|
|
|
@@ -705,8 +848,25 @@ class MemcachedClient {
|
|
|
705
848
|
}
|
|
706
849
|
}
|
|
707
850
|
|
|
708
|
-
/**
|
|
709
|
-
|
|
851
|
+
/**
|
|
852
|
+
* Send one command, resolve with the reply read until `terminator`.
|
|
853
|
+
*
|
|
854
|
+
* Commands are SERIALISED: the socket carries one reply stream with no
|
|
855
|
+
* request ids, so two commands in flight resolve each other's replies. That
|
|
856
|
+
* matters more than it looks - every key now carries a generation READ FROM
|
|
857
|
+
* THE SERVER, so a crossed reply computes the WRONG key and silently reads or
|
|
858
|
+
* overwrites the wrong entry. One command at a time on one socket.
|
|
859
|
+
*/
|
|
860
|
+
command(payload: string, terminator: string): Promise<string> {
|
|
861
|
+
const next = this.chain.then(() => this.send(payload, terminator));
|
|
862
|
+
// The chain must never reject, or every later command inherits the failure.
|
|
863
|
+
this.chain = next.then(() => undefined, () => undefined);
|
|
864
|
+
return next;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
private chain: Promise<unknown> = Promise.resolve();
|
|
868
|
+
|
|
869
|
+
private async send(payload: string, terminator: string): Promise<string> {
|
|
710
870
|
await this.connect();
|
|
711
871
|
if (!this.sock || this.sock.destroyed) return "";
|
|
712
872
|
return new Promise<string>((resolve) => {
|
|
@@ -779,12 +939,49 @@ class MemcachedBackend implements CacheBackend {
|
|
|
779
939
|
return this.available;
|
|
780
940
|
}
|
|
781
941
|
|
|
782
|
-
|
|
783
|
-
|
|
942
|
+
/**
|
|
943
|
+
* The SHARED namespace generation counter. clear() bumps it and every real
|
|
944
|
+
* key carries it, so one bump orphans every entry for every instance at once.
|
|
945
|
+
*/
|
|
946
|
+
private genKey = this.prefix + "generation";
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Read the SHARED namespace generation from the SERVER.
|
|
950
|
+
*
|
|
951
|
+
* memcached has no KEYS scan and no prefix delete, so the only way to
|
|
952
|
+
* invalidate globally without destroying other tenants is the documented
|
|
953
|
+
* namespace idiom: every real key carries a generation, and clear() bumps it.
|
|
954
|
+
* Every instance then computes a different key, and the old entries become
|
|
955
|
+
* unreachable at once, expiring under the server's own TTL/LRU.
|
|
956
|
+
*
|
|
957
|
+
* The generation is read from the server on EVERY key computation,
|
|
958
|
+
* deliberately. Caching it in-process would reintroduce exactly the bug this
|
|
959
|
+
* fixes: an instance holding a stale generation keeps computing the OLD key,
|
|
960
|
+
* the old key still holds the old value, so it serves a stale hit after
|
|
961
|
+
* another instance cleared. One extra round trip on a sub-millisecond local
|
|
962
|
+
* service is the price of cross-instance invalidation.
|
|
963
|
+
*/
|
|
964
|
+
private async generation(): Promise<string> {
|
|
965
|
+
const resp = await this.client.command(`get ${this.genKey}\r\n`, "END\r\n");
|
|
966
|
+
if (resp.startsWith("VALUE")) {
|
|
967
|
+
const idx = resp.indexOf("\r\n");
|
|
968
|
+
const nbytes = parseInt(resp.slice(0, idx).split(/\s+/)[3], 10);
|
|
969
|
+
if (idx !== -1 && Number.isFinite(nbytes)) return resp.slice(idx + 2, idx + 2 + nbytes);
|
|
970
|
+
}
|
|
971
|
+
return "0";
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Hash to a safe, bounded key (memcached keys: no spaces/control bytes, <=250
|
|
976
|
+
* chars). The generation sits IN the key, so a clear() on ANY instance orphans
|
|
977
|
+
* it for every instance at once.
|
|
978
|
+
*/
|
|
979
|
+
private async mcKey(key: string): Promise<string> {
|
|
980
|
+
return `${this.prefix}${await this.generation()}:${crypto.createHash("sha256").update(key).digest("hex")}`;
|
|
784
981
|
}
|
|
785
982
|
|
|
786
983
|
async get(key: string): Promise<unknown | undefined> {
|
|
787
|
-
const resp = await this.client.command(`get ${this.mcKey(key)}\r\n`, "END\r\n");
|
|
984
|
+
const resp = await this.client.command(`get ${await this.mcKey(key)}\r\n`, "END\r\n");
|
|
788
985
|
if (resp.startsWith("VALUE")) {
|
|
789
986
|
try {
|
|
790
987
|
const idx = resp.indexOf("\r\n");
|
|
@@ -801,36 +998,126 @@ class MemcachedBackend implements CacheBackend {
|
|
|
801
998
|
return undefined;
|
|
802
999
|
}
|
|
803
1000
|
|
|
1001
|
+
/**
|
|
1002
|
+
* Keys THIS backend wrote, mapped to the moment each expires (0 = never).
|
|
1003
|
+
* Memcached has no KEYS/prefix scan, so neither a scoped count nor a scoped
|
|
1004
|
+
* clear can be driven from the server - both come from this log.
|
|
1005
|
+
*/
|
|
1006
|
+
private own = new Map<string, number>();
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* memcached's 30-day cliff: an exptime AT OR BELOW 2592000 is RELATIVE
|
|
1010
|
+
* seconds, anything ABOVE it is an ABSOLUTE UNIX TIMESTAMP.
|
|
1011
|
+
*
|
|
1012
|
+
* The ttl used to be interpolated raw, so any TINA4_CACHE_TTL over 30 days
|
|
1013
|
+
* made every write vanish the instant it landed - the caller wrote a number
|
|
1014
|
+
* of seconds and the server read a date in 1970. memcached still answers
|
|
1015
|
+
* STORED, so it presented as a 100% miss rate with nothing logged: a cache
|
|
1016
|
+
* that looks like it is working and never returns a hit.
|
|
1017
|
+
*
|
|
1018
|
+
* CONVERT, never CLAMP. Clamping to 2592000 also makes the entry survive and
|
|
1019
|
+
* is also wrong - it silently discards more than half the lifetime the
|
|
1020
|
+
* operator explicitly configured, which is the same class of silent-wrong-
|
|
1021
|
+
* answer as the bug it would be replacing.
|
|
1022
|
+
*/
|
|
1023
|
+
private static readonly MAX_RELATIVE_EXPTIME = 2592000;
|
|
1024
|
+
|
|
1025
|
+
private exptimeFor(ttl: number): number {
|
|
1026
|
+
if (ttl <= 0) return 0;
|
|
1027
|
+
if (ttl > MemcachedBackend.MAX_RELATIVE_EXPTIME) {
|
|
1028
|
+
return Math.floor(Date.now() / 1000) + ttl;
|
|
1029
|
+
}
|
|
1030
|
+
return ttl;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
804
1033
|
async set(key: string, value: unknown, ttl: number): Promise<void> {
|
|
805
1034
|
const data = JSON.stringify(value);
|
|
806
|
-
const exptime = ttl
|
|
807
|
-
const
|
|
1035
|
+
const exptime = this.exptimeFor(ttl);
|
|
1036
|
+
const mcKey = await this.mcKey(key);
|
|
1037
|
+
const payload = `set ${mcKey} 0 ${exptime} ${Buffer.byteLength(data)}\r\n${data}\r\n`;
|
|
808
1038
|
await this.client.command(payload, "\r\n");
|
|
1039
|
+
// THE WRITE LOG KEEPS THE RAW ttl, never the converted exptime. This line
|
|
1040
|
+
// turns its number into a wall-clock deadline, so feeding it a converted
|
|
1041
|
+
// absolute timestamp would compute Date.now() + <unix timestamp> * 1000 -
|
|
1042
|
+
// about 166 years out - and the log would then never expire anything, so
|
|
1043
|
+
// stats() would report expired entries as live forever.
|
|
1044
|
+
this.own.set(mcKey, ttl > 0 ? Date.now() + ttl * 1000 : 0);
|
|
809
1045
|
}
|
|
810
1046
|
|
|
811
1047
|
async delete(key: string): Promise<boolean> {
|
|
812
|
-
const
|
|
1048
|
+
const mcKey = await this.mcKey(key);
|
|
1049
|
+
const resp = await this.client.command(`delete ${mcKey}\r\n`, "\r\n");
|
|
1050
|
+
this.own.delete(mcKey);
|
|
813
1051
|
return resp.startsWith("DELETED");
|
|
814
1052
|
}
|
|
815
1053
|
|
|
1054
|
+
/**
|
|
1055
|
+
* Invalidate EVERY entry this cache can serve, on EVERY instance.
|
|
1056
|
+
*
|
|
1057
|
+
* Two wrong answers were shipped before this one. `flush_all` wipes EVERY key
|
|
1058
|
+
* on the instance including every other application's - cacheClear() is
|
|
1059
|
+
* public API, so calling it destroyed other tenants' data. Deleting only the
|
|
1060
|
+
* keys THIS process wrote fixed that but broke the contract the other way: a
|
|
1061
|
+
* second instance kept serving rows the first had just invalidated, because
|
|
1062
|
+
* it had never seen those keys.
|
|
1063
|
+
*
|
|
1064
|
+
* The namespace generation does both. Bumping the shared counter orphans
|
|
1065
|
+
* every previously-written entry for every instance at once, and touches
|
|
1066
|
+
* nothing outside our own prefix. The orphans are reclaimed by memcached's
|
|
1067
|
+
* own TTL and LRU - unreachable is what "removed" means for a cache.
|
|
1068
|
+
*
|
|
1069
|
+
* The local write log is still cleared so stats() reports honestly, and its
|
|
1070
|
+
* keys are deleted eagerly so the space comes back immediately rather than
|
|
1071
|
+
* waiting for eviction.
|
|
1072
|
+
*/
|
|
816
1073
|
async clear(): Promise<void> {
|
|
817
1074
|
this.hits = 0;
|
|
818
1075
|
this.misses = 0;
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
1076
|
+
for (const mcKey of this.own.keys()) {
|
|
1077
|
+
await this.client.command(`delete ${mcKey}\r\n`, "\r\n");
|
|
1078
|
+
}
|
|
1079
|
+
this.own.clear();
|
|
1080
|
+
// incr is atomic, so two instances clearing at once still both advance.
|
|
1081
|
+
const bumped = await this.client.command(`incr ${this.genKey} 1\r\n`, "\r\n");
|
|
1082
|
+
if (!/^\d+/.test(bumped.trim())) {
|
|
1083
|
+
// No counter yet: create it. `add` fails harmlessly if another instance
|
|
1084
|
+
// created it in the gap, and the incr then applies on top of theirs.
|
|
1085
|
+
await this.client.command(`add ${this.genKey} 0 0 1\r\n1\r\n`, "\r\n");
|
|
1086
|
+
await this.client.command(`incr ${this.genKey} 1\r\n`, "\r\n");
|
|
1087
|
+
}
|
|
822
1088
|
}
|
|
823
1089
|
|
|
1090
|
+
/**
|
|
1091
|
+
* Report OUR entries, not the whole server's.
|
|
1092
|
+
*
|
|
1093
|
+
* This used to read memcached's `curr_items`, a GLOBAL counter that includes
|
|
1094
|
+
* every key written by every other tenant of that server. Every other backend
|
|
1095
|
+
* here is scoped - memory counts its own map, redis/valkey scan their own
|
|
1096
|
+
* prefix, file counts its own directory, mongo its own collection, database
|
|
1097
|
+
* its own table. Memcached was the only one leaking.
|
|
1098
|
+
*
|
|
1099
|
+
* The count comes from our own write log, filtered by the TTLs we set. That
|
|
1100
|
+
* is exact for the keys this process wrote; a key EVICTED early under memory
|
|
1101
|
+
* pressure is invisible to us and would be over-counted, which is a far
|
|
1102
|
+
* smaller and more honest error than counting another application's keys.
|
|
1103
|
+
*/
|
|
824
1104
|
async stats() {
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
for (const
|
|
828
|
-
if (
|
|
829
|
-
const n = parseInt(line.split(/\s+/)[2], 10);
|
|
830
|
-
if (!isNaN(n)) size = n;
|
|
831
|
-
}
|
|
1105
|
+
const now = Date.now();
|
|
1106
|
+
// Drop the expired ones so the log cannot grow without bound.
|
|
1107
|
+
for (const [k, expires] of this.own) {
|
|
1108
|
+
if (expires !== 0 && expires <= now) this.own.delete(k);
|
|
832
1109
|
}
|
|
833
|
-
return { hits: this.hits, misses: this.misses, size, backend: "memcached" };
|
|
1110
|
+
return { hits: this.hits, misses: this.misses, size: this.own.size, backend: "memcached" };
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* Nothing to do: memcached expires entries SERVER-SIDE (the exptime set on each key), so by the
|
|
1115
|
+
* time a sweep runs there is nothing left for us to evict. 0 is the honest
|
|
1116
|
+
* count, not a stub - inventing a number here would be a lie, and scanning
|
|
1117
|
+
* the keyspace to "prove" it would cost a full scan to always return 0.
|
|
1118
|
+
*/
|
|
1119
|
+
async sweep(): Promise<number> {
|
|
1120
|
+
return 0;
|
|
834
1121
|
}
|
|
835
1122
|
|
|
836
1123
|
name() { return "memcached"; }
|
|
@@ -971,6 +1258,16 @@ class MongoBackend implements CacheBackend {
|
|
|
971
1258
|
return { hits: this.hits, misses: this.misses, size, backend: "mongodb" };
|
|
972
1259
|
}
|
|
973
1260
|
|
|
1261
|
+
/**
|
|
1262
|
+
* Nothing to do: mongodb expires entries SERVER-SIDE (the TTL index on expiresAt), so by the
|
|
1263
|
+
* time a sweep runs there is nothing left for us to evict. 0 is the honest
|
|
1264
|
+
* count, not a stub - inventing a number here would be a lie, and scanning
|
|
1265
|
+
* the keyspace to "prove" it would cost a full scan to always return 0.
|
|
1266
|
+
*/
|
|
1267
|
+
async sweep(): Promise<number> {
|
|
1268
|
+
return 0;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
974
1271
|
name() { return "mongodb"; }
|
|
975
1272
|
}
|
|
976
1273
|
|
|
@@ -1114,6 +1411,42 @@ class DatabaseBackend implements CacheBackend {
|
|
|
1114
1411
|
return { hits: this.hits, misses: this.misses, size, backend: "database" };
|
|
1115
1412
|
}
|
|
1116
1413
|
|
|
1414
|
+
/**
|
|
1415
|
+
* Delete expired rows and return how many went.
|
|
1416
|
+
*
|
|
1417
|
+
* The network providers return 0 because they expire entries SERVER-SIDE -
|
|
1418
|
+
* nothing was evicted because there was nothing left to evict, and 0 is the
|
|
1419
|
+
* honest answer there. A SQL TABLE EXPIRES NOTHING BY ITSELF. Before this
|
|
1420
|
+
* override the database backend had no sweep at all, so expired rows were
|
|
1421
|
+
* removed only when someone happened to read that exact key again: the table
|
|
1422
|
+
* grew without bound while the one API whose job is reclaiming that space
|
|
1423
|
+
* reported success having done nothing.
|
|
1424
|
+
*
|
|
1425
|
+
* `expires_at > 0` is load-bearing: an entry stored with ttl <= 0 is
|
|
1426
|
+
* permanent and carries 0, so a bare `now > expires_at` would evict every
|
|
1427
|
+
* permanent entry on the first sweep.
|
|
1428
|
+
*/
|
|
1429
|
+
async sweep(): Promise<number> {
|
|
1430
|
+
if (!this.db) return 0;
|
|
1431
|
+
const now = Date.now() / 1000;
|
|
1432
|
+
try {
|
|
1433
|
+
const row = await this.db.fetchOne(
|
|
1434
|
+
"SELECT COUNT(*) AS c FROM tina4_cache WHERE expires_at > 0 AND expires_at < ?",
|
|
1435
|
+
[now],
|
|
1436
|
+
);
|
|
1437
|
+
const expired = row && row.c != null ? Number(row.c) : 0;
|
|
1438
|
+
if (expired > 0) {
|
|
1439
|
+
await this.db.execute(
|
|
1440
|
+
"DELETE FROM tina4_cache WHERE expires_at > 0 AND expires_at < ?", [now],
|
|
1441
|
+
);
|
|
1442
|
+
try { this.db.commit(); } catch { /* sqlite autocommits */ }
|
|
1443
|
+
}
|
|
1444
|
+
return expired;
|
|
1445
|
+
} catch {
|
|
1446
|
+
return 0;
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1117
1450
|
name() { return "database"; }
|
|
1118
1451
|
}
|
|
1119
1452
|
|
|
@@ -1173,8 +1506,18 @@ export async function createBackend(config?: {
|
|
|
1173
1506
|
}
|
|
1174
1507
|
case "file":
|
|
1175
1508
|
return new FileBackend(cacheDir(), maxEntries);
|
|
1176
|
-
|
|
1509
|
+
case "memory":
|
|
1510
|
+
case "":
|
|
1177
1511
|
return new MemoryBackend(maxEntries);
|
|
1512
|
+
default:
|
|
1513
|
+
// An UNRECOGNISED name THROWS, naming the bad value and the valid ones —
|
|
1514
|
+
// the contract the session layer already settled on. Falling through to
|
|
1515
|
+
// memory turned a typo (TINA4_CACHE_BACKEND=redsi) into a running app
|
|
1516
|
+
// with a per-process cache while the operator believed it was Redis.
|
|
1517
|
+
throw new Error(
|
|
1518
|
+
`Unknown cache backend '${backendName}'. Valid backends: ` +
|
|
1519
|
+
"memory, file, redis, valkey, memcached, mongodb, database.",
|
|
1520
|
+
);
|
|
1178
1521
|
}
|
|
1179
1522
|
|
|
1180
1523
|
// Wait for the async connect/probe to settle before deciding availability.
|
|
@@ -1216,16 +1559,51 @@ export async function createBackend(config?: {
|
|
|
1216
1559
|
*/
|
|
1217
1560
|
let _responseBackend: CacheBackend | null = null;
|
|
1218
1561
|
let _responseBackendPromise: Promise<CacheBackend> | null = null;
|
|
1562
|
+
/**
|
|
1563
|
+
* Backends built for an EXPLICITLY configured responseCache, keyed by the
|
|
1564
|
+
* provider-affecting part of its config. Kept apart from the shared
|
|
1565
|
+
* module-level backend so a named provider is never overridden by ambient state.
|
|
1566
|
+
*/
|
|
1567
|
+
const _explicitResponseBackends = new Map<string, Promise<CacheBackend>>();
|
|
1568
|
+
|
|
1569
|
+
export function _getResponseBackend(config?: ResponseCacheConfig): Promise<CacheBackend> {
|
|
1570
|
+
// An EXPLICITLY requested provider gets its OWN backend; only the
|
|
1571
|
+
// no-argument case shares the module-level one (mirrors the Python master).
|
|
1572
|
+
//
|
|
1573
|
+
// This function used to open with `if (_responseBackend) return ...`, so the
|
|
1574
|
+
// memoised backend was handed back BEFORE config was ever read. Once any
|
|
1575
|
+
// responseCache middleware existed, every later explicitly-named provider was
|
|
1576
|
+
// silently ignored: the developer names a backend, the framework quietly uses
|
|
1577
|
+
// a different one, and the only symptom is cache behaviour that does not
|
|
1578
|
+
// match the configuration.
|
|
1579
|
+
const wantsItsOwn = config?.backend !== undefined
|
|
1580
|
+
|| config?.cacheUrl !== undefined
|
|
1581
|
+
|| config?.cacheDir !== undefined
|
|
1582
|
+
|| config?.maxEntries !== undefined;
|
|
1583
|
+
|
|
1584
|
+
if (wantsItsOwn) {
|
|
1585
|
+
// Memoised per DISTINCT config, not per call: the middleware resolves its
|
|
1586
|
+
// backend on every request, so building a fresh one each time would open a
|
|
1587
|
+
// new connection per request. Two different configs stay two different
|
|
1588
|
+
// stores, which is the half that actually matters - honouring the NAME
|
|
1589
|
+
// while still returning the shared object would change nothing observable.
|
|
1590
|
+
const key = JSON.stringify([config?.backend, config?.cacheUrl, config?.cacheDir, config?.maxEntries]);
|
|
1591
|
+
let built = _explicitResponseBackends.get(key);
|
|
1592
|
+
if (!built) {
|
|
1593
|
+
built = createBackend({
|
|
1594
|
+
backend: config?.backend,
|
|
1595
|
+
cacheUrl: config?.cacheUrl,
|
|
1596
|
+
cacheDir: config?.cacheDir,
|
|
1597
|
+
maxEntries: config?.maxEntries,
|
|
1598
|
+
});
|
|
1599
|
+
_explicitResponseBackends.set(key, built);
|
|
1600
|
+
}
|
|
1601
|
+
return built;
|
|
1602
|
+
}
|
|
1219
1603
|
|
|
1220
|
-
function _getResponseBackend(config?: ResponseCacheConfig): Promise<CacheBackend> {
|
|
1221
1604
|
if (_responseBackend) return Promise.resolve(_responseBackend);
|
|
1222
1605
|
if (!_responseBackendPromise) {
|
|
1223
|
-
_responseBackendPromise = createBackend({
|
|
1224
|
-
backend: config?.backend,
|
|
1225
|
-
cacheUrl: config?.cacheUrl,
|
|
1226
|
-
cacheDir: config?.cacheDir,
|
|
1227
|
-
maxEntries: config?.maxEntries,
|
|
1228
|
-
}).then((b) => {
|
|
1606
|
+
_responseBackendPromise = createBackend().then((b) => {
|
|
1229
1607
|
_responseBackend = b;
|
|
1230
1608
|
return b;
|
|
1231
1609
|
});
|
|
@@ -1245,6 +1623,65 @@ function _getResponseBackend(config?: ResponseCacheConfig): Promise<CacheBackend
|
|
|
1245
1623
|
* maxEntries. With the default `memory` backend behaviour is unchanged; a
|
|
1246
1624
|
* redis/etc. backend distributes cross-instance.
|
|
1247
1625
|
*/
|
|
1626
|
+
/**
|
|
1627
|
+
* Response directives that let a SHARED cache store a response to a request
|
|
1628
|
+
* carrying Authorization (RFC 9111 s3.5).
|
|
1629
|
+
*/
|
|
1630
|
+
const SHARED_CACHE_DIRECTIVES = ["public", "s-maxage", "must-revalidate"];
|
|
1631
|
+
|
|
1632
|
+
/** Case-insensitive request header lookup. */
|
|
1633
|
+
function requestHeader(req: { headers?: Record<string, unknown> }, name: string): string | undefined {
|
|
1634
|
+
const headers = req?.headers;
|
|
1635
|
+
if (!headers) return undefined;
|
|
1636
|
+
const target = name.toLowerCase();
|
|
1637
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
1638
|
+
if (key.toLowerCase() === target) {
|
|
1639
|
+
return Array.isArray(value) ? value.join(", ") : String(value);
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
return undefined;
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
/** The lower-cased field names in a response's Vary header. */
|
|
1646
|
+
function varyFields(raw: unknown): string[] {
|
|
1647
|
+
if (raw === undefined || raw === null) return [];
|
|
1648
|
+
const text = Array.isArray(raw) ? raw.join(",") : String(raw);
|
|
1649
|
+
return text.split(",").map((f) => f.trim().toLowerCase()).filter((f) => f !== "");
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
/**
|
|
1653
|
+
* May a SHARED cache store this response? (RFC 9111 s3, s4.1)
|
|
1654
|
+
*
|
|
1655
|
+
* s3 — "if the cache is shared: the Authorization header field is not present
|
|
1656
|
+
* in the request ... or a response directive is present that explicitly allows
|
|
1657
|
+
* shared caching". The key is method + URL only, and on Node the cache answers
|
|
1658
|
+
* BEFORE the auth gate, so without this an anonymous caller was served an
|
|
1659
|
+
* authenticated caller's body with a 200.
|
|
1660
|
+
*
|
|
1661
|
+
* s4.1 — a stored response whose Vary contains "*" "always fails to match", so
|
|
1662
|
+
* storing one is pointless.
|
|
1663
|
+
*/
|
|
1664
|
+
function mayStore(req: { headers?: Record<string, unknown> }, vary: string[], cacheControl: unknown): boolean {
|
|
1665
|
+
if (vary.includes("*")) return false;
|
|
1666
|
+
if (requestHeader(req, "authorization") === undefined) return true;
|
|
1667
|
+
const cc = String(cacheControl ?? "").toLowerCase();
|
|
1668
|
+
return SHARED_CACHE_DIRECTIVES.some((directive) => cc.includes(directive));
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
/**
|
|
1672
|
+
* Do the nominated request headers match the ones recorded on the entry?
|
|
1673
|
+
*
|
|
1674
|
+
* RFC 9111 s4.1 — the cache MUST NOT use a stored response unless every request
|
|
1675
|
+
* header field nominated by its Vary value matches. An absent field only
|
|
1676
|
+
* matches an absent field.
|
|
1677
|
+
*/
|
|
1678
|
+
function varyMatches(entry: CacheEntry, req: { headers?: Record<string, unknown> }): boolean {
|
|
1679
|
+
const vary = entry.vary ?? [];
|
|
1680
|
+
if (vary.length === 0) return true;
|
|
1681
|
+
const recorded = entry.varyValues ?? {};
|
|
1682
|
+
return vary.every((field) => requestHeader(req, field) === recorded[field]);
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1248
1685
|
export function responseCache(config?: ResponseCacheConfig): Middleware {
|
|
1249
1686
|
const ttl = config?.ttl
|
|
1250
1687
|
?? (process.env.TINA4_CACHE_TTL ? parseInt(process.env.TINA4_CACHE_TTL, 10) : 60);
|
|
@@ -1266,7 +1703,8 @@ export function responseCache(config?: ResponseCacheConfig): Middleware {
|
|
|
1266
1703
|
const cacheKey = `response:GET:${req.url}`;
|
|
1267
1704
|
const cached = (await backend.get(cacheKey)) as CacheEntry | undefined;
|
|
1268
1705
|
|
|
1269
|
-
if (cached && typeof cached === "object" && typeof cached.body === "string"
|
|
1706
|
+
if (cached && typeof cached === "object" && typeof cached.body === "string"
|
|
1707
|
+
&& varyMatches(cached, req as any)) {
|
|
1270
1708
|
// Cache HIT — serve from the (possibly distributed) backend.
|
|
1271
1709
|
res.header("X-Cache", "HIT");
|
|
1272
1710
|
// X-Cache-TTL advertises the configured cache lifetime in seconds
|
|
@@ -1282,10 +1720,14 @@ export function responseCache(config?: ResponseCacheConfig): Middleware {
|
|
|
1282
1720
|
let captured = false;
|
|
1283
1721
|
|
|
1284
1722
|
res.raw.end = function (chunk?: any, ...args: any[]) {
|
|
1285
|
-
|
|
1723
|
+
const vary = varyFields(res.raw.getHeader("Vary"));
|
|
1724
|
+
if (!captured && allowedCodes.has(res.raw.statusCode)
|
|
1725
|
+
&& mayStore(req as any, vary, res.raw.getHeader("Cache-Control"))) {
|
|
1286
1726
|
captured = true;
|
|
1287
1727
|
const body = typeof chunk === "string" ? chunk : chunk?.toString() ?? "";
|
|
1288
1728
|
const contentType = String(res.raw.getHeader("Content-Type") ?? "application/octet-stream");
|
|
1729
|
+
const varyValues: Record<string, string | undefined> = {};
|
|
1730
|
+
for (const field of vary) varyValues[field] = requestHeader(req as any, field);
|
|
1289
1731
|
|
|
1290
1732
|
// backend.set is async; the captured end() must stay synchronous (Node
|
|
1291
1733
|
// flushes the body here), so fire-and-forget the store. The backend's
|
|
@@ -1296,6 +1738,8 @@ export function responseCache(config?: ResponseCacheConfig): Middleware {
|
|
|
1296
1738
|
contentType,
|
|
1297
1739
|
statusCode: res.raw.statusCode,
|
|
1298
1740
|
expiresAt: Date.now() + ttl * 1000,
|
|
1741
|
+
vary,
|
|
1742
|
+
varyValues,
|
|
1299
1743
|
} as CacheEntry, ttl).catch(() => { /* best effort */ });
|
|
1300
1744
|
}
|
|
1301
1745
|
|
|
@@ -1387,11 +1831,7 @@ export async function cacheClear(): Promise<void> {
|
|
|
1387
1831
|
|
|
1388
1832
|
/** Remove expired entries from the cache. Returns count removed. */
|
|
1389
1833
|
export async function sweep(): Promise<number> {
|
|
1390
|
-
|
|
1391
|
-
if (typeof (backend as any).sweep === "function") {
|
|
1392
|
-
return (backend as any).sweep();
|
|
1393
|
-
}
|
|
1394
|
-
return 0;
|
|
1834
|
+
return (await _getBackend()).sweep();
|
|
1395
1835
|
}
|
|
1396
1836
|
|
|
1397
1837
|
/** Return cache statistics from the active backend. */
|
|
@@ -1409,5 +1849,8 @@ export function _resetBackend(): void {
|
|
|
1409
1849
|
_defaultBackendPromise = null;
|
|
1410
1850
|
_responseBackend = null;
|
|
1411
1851
|
_responseBackendPromise = null;
|
|
1852
|
+
// Explicitly-configured backends are memoised too, so a reset that left them
|
|
1853
|
+
// behind would leak one test's provider into the next.
|
|
1854
|
+
_explicitResponseBackends.clear();
|
|
1412
1855
|
_defaultTtl = null;
|
|
1413
1856
|
}
|