@proulr/bff-core 0.1.11 → 0.1.14
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/package.json +3 -3
- package/src/bff-supabase-client.test.ts +66 -0
- package/src/bff-supabase-client.ts +33 -6
- package/src/index.ts +28 -2
- package/src/screen-response-cache.test.ts +152 -0
- package/src/screen-response-cache.ts +114 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proulr/bff-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@hono/node-server": "^1.19.6",
|
|
36
|
-
"@proulr/rosetta": "^0.1.
|
|
37
|
-
"@proulr/sdui-contract": "^0.1.
|
|
36
|
+
"@proulr/rosetta": "^0.1.73",
|
|
37
|
+
"@proulr/sdui-contract": "^0.1.17",
|
|
38
38
|
"@supabase/supabase-js": "^2.107.0",
|
|
39
39
|
"dotenv": "^16.6.1",
|
|
40
40
|
"hono": "^4.10.6",
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock('./bff-env-loader', () => ({ loadBffEnv: vi.fn() }));
|
|
4
|
+
|
|
5
|
+
const createClient = vi.hoisted(() => vi.fn());
|
|
6
|
+
vi.mock('@supabase/supabase-js', () => ({ createClient }));
|
|
7
|
+
|
|
8
|
+
let clientSeq = 0;
|
|
9
|
+
|
|
10
|
+
describe('createBffSupabaseClient', () => {
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
createClient.mockReset();
|
|
13
|
+
createClient.mockImplementation(() => ({ id: ++clientSeq }));
|
|
14
|
+
vi.resetModules();
|
|
15
|
+
delete process.env.SUPABASE_INTERNAL_URL;
|
|
16
|
+
process.env.SUPABASE_URL = 'https://api.proulr.com';
|
|
17
|
+
process.env.SUPABASE_ANON_KEY = 'anon-key';
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('prefers SUPABASE_INTERNAL_URL (Kong loopback) over the public URL', async () => {
|
|
21
|
+
process.env.SUPABASE_INTERNAL_URL = 'http://127.0.0.1:8000';
|
|
22
|
+
const { createBffSupabaseClient } = await import('./bff-supabase-client');
|
|
23
|
+
createBffSupabaseClient('jwt-user-a');
|
|
24
|
+
expect(createClient).toHaveBeenCalledWith(
|
|
25
|
+
'http://127.0.0.1:8000',
|
|
26
|
+
'anon-key',
|
|
27
|
+
expect.anything(),
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('reuses the same client for the same token', async () => {
|
|
32
|
+
const { createBffSupabaseClient } = await import('./bff-supabase-client');
|
|
33
|
+
const first = createBffSupabaseClient('jwt-user-a');
|
|
34
|
+
const second = createBffSupabaseClient('jwt-user-a');
|
|
35
|
+
expect(second).toBe(first);
|
|
36
|
+
expect(createClient).toHaveBeenCalledTimes(1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('creates distinct clients per token and for anonymous', async () => {
|
|
40
|
+
const { createBffSupabaseClient } = await import('./bff-supabase-client');
|
|
41
|
+
const a = createBffSupabaseClient('jwt-user-a');
|
|
42
|
+
const b = createBffSupabaseClient('jwt-user-b');
|
|
43
|
+
const anon = createBffSupabaseClient();
|
|
44
|
+
expect(a).not.toBe(b);
|
|
45
|
+
expect(b).not.toBe(anon);
|
|
46
|
+
expect(createBffSupabaseClient()).toBe(anon);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('evicts the least recently used client beyond the cache limit', async () => {
|
|
50
|
+
const { createBffSupabaseClient } = await import('./bff-supabase-client');
|
|
51
|
+
const first = createBffSupabaseClient('token-0');
|
|
52
|
+
for (let i = 1; i <= 64; i += 1) {
|
|
53
|
+
createBffSupabaseClient(`token-${i}`);
|
|
54
|
+
}
|
|
55
|
+
const recreated = createBffSupabaseClient('token-0');
|
|
56
|
+
expect(recreated).not.toBe(first);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('throws when supabase is not configured', async () => {
|
|
60
|
+
delete process.env.SUPABASE_URL;
|
|
61
|
+
delete process.env.SUPABASE_ANON_KEY;
|
|
62
|
+
vi.resetModules();
|
|
63
|
+
const { createBffSupabaseClient } = await import('./bff-supabase-client');
|
|
64
|
+
expect(() => createBffSupabaseClient('t')).toThrow('bff_supabase_not_configured');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -3,19 +3,24 @@ import { loadBffEnv } from './bff-env-loader';
|
|
|
3
3
|
|
|
4
4
|
loadBffEnv();
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
// SUPABASE_INTERNAL_URL bypasses the Cloudflare hairpin on the VPS (Kong
|
|
7
|
+
// 127.0.0.1:8000). Never derive client-facing URLs (share/referral/functions)
|
|
8
|
+
// from it — those must keep the public SUPABASE_URL.
|
|
9
|
+
const supabaseUrl =
|
|
10
|
+
process.env.SUPABASE_INTERNAL_URL ??
|
|
11
|
+
process.env.SUPABASE_URL ??
|
|
12
|
+
process.env.EXPO_PUBLIC_SUPABASE_URL ?? '';
|
|
7
13
|
const supabaseAnonKey =
|
|
8
14
|
process.env.SUPABASE_ANON_KEY ?? process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY ?? '';
|
|
9
15
|
|
|
16
|
+
const CLIENT_CACHE_LIMIT = 64;
|
|
17
|
+
const clientsByToken = new Map<string, SupabaseClient>();
|
|
18
|
+
|
|
10
19
|
export function isBffSupabaseConfigured(): boolean {
|
|
11
20
|
return Boolean(supabaseUrl && supabaseAnonKey);
|
|
12
21
|
}
|
|
13
22
|
|
|
14
|
-
|
|
15
|
-
if (!isBffSupabaseConfigured()) {
|
|
16
|
-
throw new Error('bff_supabase_not_configured');
|
|
17
|
-
}
|
|
18
|
-
|
|
23
|
+
function buildClient(authToken?: string): SupabaseClient {
|
|
19
24
|
return createClient(supabaseUrl, supabaseAnonKey, {
|
|
20
25
|
auth: {
|
|
21
26
|
persistSession: false,
|
|
@@ -31,3 +36,25 @@ export function createBffSupabaseClient(authToken?: string): SupabaseClient {
|
|
|
31
36
|
: undefined,
|
|
32
37
|
});
|
|
33
38
|
}
|
|
39
|
+
|
|
40
|
+
export function createBffSupabaseClient(authToken?: string): SupabaseClient {
|
|
41
|
+
if (!isBffSupabaseConfigured()) {
|
|
42
|
+
throw new Error('bff_supabase_not_configured');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const cacheKey = authToken ?? 'anon';
|
|
46
|
+
const cached = clientsByToken.get(cacheKey);
|
|
47
|
+
if (cached) {
|
|
48
|
+
clientsByToken.delete(cacheKey);
|
|
49
|
+
clientsByToken.set(cacheKey, cached);
|
|
50
|
+
return cached;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const client = buildClient(authToken);
|
|
54
|
+
clientsByToken.set(cacheKey, client);
|
|
55
|
+
if (clientsByToken.size > CLIENT_CACHE_LIMIT) {
|
|
56
|
+
const oldest = clientsByToken.keys().next().value;
|
|
57
|
+
if (oldest !== undefined) clientsByToken.delete(oldest);
|
|
58
|
+
}
|
|
59
|
+
return client;
|
|
60
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { Hono } from 'hono';
|
|
|
4
4
|
import { cors } from 'hono/cors';
|
|
5
5
|
import type { SduiScreenBuilder, SduiScreenBuilderContext } from '@proulr/sdui-contract';
|
|
6
6
|
import { createBffMetrics, isBffMetricsEnabled } from './bff-metrics';
|
|
7
|
+
import { ScreenResponseCache, buildScreenCacheKey } from './screen-response-cache';
|
|
7
8
|
|
|
8
9
|
export type SduiActionHandler = (
|
|
9
10
|
ctx: SduiScreenBuilderContext,
|
|
@@ -44,6 +45,9 @@ export function createSduiBff(options: CreateSduiBffOptions) {
|
|
|
44
45
|
const app = new Hono();
|
|
45
46
|
const metricsEnabled = isBffMetricsEnabled();
|
|
46
47
|
const metrics = metricsEnabled ? createBffMetrics(options.bffId) : null;
|
|
48
|
+
const screenCache = new ScreenResponseCache(
|
|
49
|
+
Number(process.env.BFF_SCREEN_CACHE_TTL_MS ?? 0) || 0,
|
|
50
|
+
);
|
|
47
51
|
|
|
48
52
|
app.use('*', cors());
|
|
49
53
|
|
|
@@ -107,8 +111,22 @@ export function createSduiBff(options: CreateSduiBffOptions) {
|
|
|
107
111
|
|
|
108
112
|
const query = Object.fromEntries(new URL(c.req.url).searchParams.entries());
|
|
109
113
|
const authToken = readAuthToken(c.req.header('Authorization'));
|
|
110
|
-
const
|
|
111
|
-
|
|
114
|
+
const cacheKey = buildScreenCacheKey({ screenId, query, authToken });
|
|
115
|
+
const hit = await screenCache.get(
|
|
116
|
+
cacheKey,
|
|
117
|
+
async () => builder(buildContext({}, query, authToken)),
|
|
118
|
+
c.req.header('If-None-Match'),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
if (hit.notModified) {
|
|
122
|
+
return c.newResponse(null, 304, { ETag: hit.entry.etag });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return c.body(hit.entry.body, 200, {
|
|
126
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
127
|
+
ETag: hit.entry.etag,
|
|
128
|
+
'Cache-Control': 'private, no-cache',
|
|
129
|
+
});
|
|
112
130
|
});
|
|
113
131
|
|
|
114
132
|
app.post('/v1/actions/:actionId', async (c) => {
|
|
@@ -128,6 +146,7 @@ export function createSduiBff(options: CreateSduiBffOptions) {
|
|
|
128
146
|
try {
|
|
129
147
|
const authToken = readAuthToken(c.req.header('Authorization'));
|
|
130
148
|
const result = await handler(buildContext({}, {}, authToken), body);
|
|
149
|
+
screenCache.purgeAll();
|
|
131
150
|
return c.json({ ok: true, action_id: actionId, ...result });
|
|
132
151
|
} catch (error) {
|
|
133
152
|
console.warn(`[${options.bffId}] action ${actionId} failed`, error);
|
|
@@ -155,6 +174,13 @@ export function createSduiBff(options: CreateSduiBffOptions) {
|
|
|
155
174
|
|
|
156
175
|
export * from '@proulr/sdui-contract';
|
|
157
176
|
export { createBffSupabaseClient, isBffSupabaseConfigured } from './bff-supabase-client';
|
|
177
|
+
export {
|
|
178
|
+
ScreenResponseCache,
|
|
179
|
+
buildScreenCacheKey,
|
|
180
|
+
computeScreenEtag,
|
|
181
|
+
type ScreenCacheEntry,
|
|
182
|
+
type ScreenCacheHit,
|
|
183
|
+
} from './screen-response-cache';
|
|
158
184
|
export {
|
|
159
185
|
buildNativeAdInsertionSeed,
|
|
160
186
|
insertRandomNativeAdSlots,
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
ScreenResponseCache,
|
|
4
|
+
buildScreenCacheKey,
|
|
5
|
+
computeScreenEtag,
|
|
6
|
+
} from './screen-response-cache';
|
|
7
|
+
|
|
8
|
+
describe('buildScreenCacheKey', () => {
|
|
9
|
+
it('is stable regardless of query insertion order', () => {
|
|
10
|
+
const a = buildScreenCacheKey({
|
|
11
|
+
screenId: 'chat.inbox',
|
|
12
|
+
query: { locale: 'pt-BR', filter: 'all' },
|
|
13
|
+
authToken: 'tok',
|
|
14
|
+
});
|
|
15
|
+
const b = buildScreenCacheKey({
|
|
16
|
+
screenId: 'chat.inbox',
|
|
17
|
+
query: { filter: 'all', locale: 'pt-BR' },
|
|
18
|
+
authToken: 'tok',
|
|
19
|
+
});
|
|
20
|
+
expect(a).toBe(b);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('differs by screen id, query values and token', () => {
|
|
24
|
+
const base = { screenId: 'chat.inbox', query: { locale: 'pt-BR' }, authToken: 'tok' };
|
|
25
|
+
expect(buildScreenCacheKey(base)).not.toBe(
|
|
26
|
+
buildScreenCacheKey({ ...base, screenId: 'vibes.home' }),
|
|
27
|
+
);
|
|
28
|
+
expect(buildScreenCacheKey(base)).not.toBe(
|
|
29
|
+
buildScreenCacheKey({ ...base, query: { locale: 'en-US' } }),
|
|
30
|
+
);
|
|
31
|
+
expect(buildScreenCacheKey(base)).not.toBe(buildScreenCacheKey({ ...base, authToken: 'tok2' }));
|
|
32
|
+
expect(buildScreenCacheKey(base)).not.toBe(buildScreenCacheKey({ ...base, authToken: undefined }));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('does not leak the raw auth token in the key', () => {
|
|
36
|
+
const key = buildScreenCacheKey({ screenId: 'x', query: {}, authToken: 'super-secret-jwt' });
|
|
37
|
+
expect(key).not.toContain('super-secret-jwt');
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('computeScreenEtag', () => {
|
|
42
|
+
it('is deterministic per body and differs across bodies', () => {
|
|
43
|
+
expect(computeScreenEtag('{"a":1}')).toBe(computeScreenEtag('{"a":1}'));
|
|
44
|
+
expect(computeScreenEtag('{"a":1}')).not.toBe(computeScreenEtag('{"a":2}'));
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('ScreenResponseCache', () => {
|
|
49
|
+
it('coalesces concurrent identical builds into a single builder call', async () => {
|
|
50
|
+
let calls = 0;
|
|
51
|
+
let release: (screen: Record<string, unknown>) => void = () => {};
|
|
52
|
+
const build = () => {
|
|
53
|
+
calls += 1;
|
|
54
|
+
return new Promise<Record<string, unknown>>((resolve) => {
|
|
55
|
+
release = resolve;
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const cache = new ScreenResponseCache(0);
|
|
60
|
+
const p1 = cache.get('k', build);
|
|
61
|
+
const p2 = cache.get('k', build);
|
|
62
|
+
release({ ok: true });
|
|
63
|
+
|
|
64
|
+
const [h1, h2] = await Promise.all([p1, p2]);
|
|
65
|
+
expect(calls).toBe(1);
|
|
66
|
+
expect(h1.entry.body).toBe('{"ok":true}');
|
|
67
|
+
expect(h2.entry.body).toBe('{"ok":true}');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('rebuilds after completion when ttl is 0', async () => {
|
|
71
|
+
let calls = 0;
|
|
72
|
+
const build = async () => ({ n: ++calls });
|
|
73
|
+
const cache = new ScreenResponseCache(0);
|
|
74
|
+
|
|
75
|
+
await cache.get('k', build);
|
|
76
|
+
const second = await cache.get('k', build);
|
|
77
|
+
expect(calls).toBe(2);
|
|
78
|
+
expect(second.entry.body).toBe('{"n":2}');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('serves fresh entries within ttl and flags matching etag as not modified', async () => {
|
|
82
|
+
let calls = 0;
|
|
83
|
+
const build = async () => ({ n: ++calls });
|
|
84
|
+
let clock = 1_000;
|
|
85
|
+
const cache = new ScreenResponseCache(500, 512, () => clock);
|
|
86
|
+
|
|
87
|
+
const first = await cache.get('k', build);
|
|
88
|
+
clock += 400;
|
|
89
|
+
const second = await cache.get('k', build, first.entry.etag);
|
|
90
|
+
|
|
91
|
+
expect(calls).toBe(1);
|
|
92
|
+
expect(second.fresh).toBe(true);
|
|
93
|
+
expect(second.notModified).toBe(true);
|
|
94
|
+
|
|
95
|
+
clock += 200;
|
|
96
|
+
const third = await cache.get('k', build);
|
|
97
|
+
expect(calls).toBe(2);
|
|
98
|
+
expect(third.notModified).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('does not serve a stale entry to an etag-mismatched request', async () => {
|
|
102
|
+
let calls = 0;
|
|
103
|
+
const build = async () => ({ n: ++calls });
|
|
104
|
+
const cache = new ScreenResponseCache(500);
|
|
105
|
+
|
|
106
|
+
await cache.get('k', build);
|
|
107
|
+
const hit = await cache.get('k', build, 'W/"other"');
|
|
108
|
+
expect(calls).toBe(1);
|
|
109
|
+
expect(hit.notModified).toBe(false);
|
|
110
|
+
expect(hit.fresh).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('purgeAll forces the next build to run', async () => {
|
|
114
|
+
let calls = 0;
|
|
115
|
+
const build = async () => ({ n: ++calls });
|
|
116
|
+
const cache = new ScreenResponseCache(5_000);
|
|
117
|
+
|
|
118
|
+
await cache.get('k', build);
|
|
119
|
+
cache.purgeAll();
|
|
120
|
+
await cache.get('k', build);
|
|
121
|
+
expect(calls).toBe(2);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('does not cache failures', async () => {
|
|
125
|
+
let calls = 0;
|
|
126
|
+
const build = async () => {
|
|
127
|
+
calls += 1;
|
|
128
|
+
if (calls === 1) throw new Error('boom');
|
|
129
|
+
return { ok: true };
|
|
130
|
+
};
|
|
131
|
+
const cache = new ScreenResponseCache(5_000);
|
|
132
|
+
|
|
133
|
+
await expect(cache.get('k', build)).rejects.toThrow('boom');
|
|
134
|
+
const ok = await cache.get('k', build);
|
|
135
|
+
expect(calls).toBe(2);
|
|
136
|
+
expect(ok.entry.body).toBe('{"ok":true}');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('evicts the oldest entries beyond the limit', async () => {
|
|
140
|
+
let calls = 0;
|
|
141
|
+
const build = async () => ({ n: ++calls });
|
|
142
|
+
const cache = new ScreenResponseCache(60_000, 2);
|
|
143
|
+
|
|
144
|
+
await cache.get('a', build);
|
|
145
|
+
await cache.get('b', build);
|
|
146
|
+
await cache.get('c', build);
|
|
147
|
+
await cache.get('a', build);
|
|
148
|
+
expect(calls).toBe(4);
|
|
149
|
+
await cache.get('c', build);
|
|
150
|
+
expect(calls).toBe(4);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export type ScreenCacheKeyInput = {
|
|
4
|
+
screenId: string;
|
|
5
|
+
query: Record<string, string>;
|
|
6
|
+
authToken?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type ScreenCacheEntry = {
|
|
10
|
+
body: string;
|
|
11
|
+
etag: string;
|
|
12
|
+
freshUntil: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type ScreenCacheHit = {
|
|
16
|
+
entry: ScreenCacheEntry;
|
|
17
|
+
fresh: boolean;
|
|
18
|
+
notModified: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function buildScreenCacheKey(input: ScreenCacheKeyInput): string {
|
|
22
|
+
const queryPart = Object.keys(input.query)
|
|
23
|
+
.sort()
|
|
24
|
+
.map((name) => `${name}=${input.query[name]}`)
|
|
25
|
+
.join('&');
|
|
26
|
+
const hash = createHash('sha256');
|
|
27
|
+
hash.update(input.screenId);
|
|
28
|
+
hash.update('\u0000');
|
|
29
|
+
hash.update(queryPart);
|
|
30
|
+
hash.update('\u0000');
|
|
31
|
+
hash.update(input.authToken ?? 'anon');
|
|
32
|
+
return hash.digest('base64url');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function computeScreenEtag(body: string): string {
|
|
36
|
+
return `W/"${createHash('sha256').update(body).digest('base64url').slice(0, 32)}"`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Dedupes identical in-flight screen builds (the mount/focus revalidation storm
|
|
41
|
+
* fires the same GET 3-4x concurrently). `ttlMs > 0` additionally serves
|
|
42
|
+
* completed results while fresh; keep it low (default 0) so direct client-side
|
|
43
|
+
* writes stay near-real-time.
|
|
44
|
+
*/
|
|
45
|
+
export class ScreenResponseCache {
|
|
46
|
+
private entries = new Map<string, ScreenCacheEntry>();
|
|
47
|
+
private pending = new Map<string, Promise<ScreenCacheEntry>>();
|
|
48
|
+
|
|
49
|
+
constructor(
|
|
50
|
+
private readonly ttlMs: number,
|
|
51
|
+
private readonly limit: number = 512,
|
|
52
|
+
private readonly now: () => number = Date.now,
|
|
53
|
+
) {}
|
|
54
|
+
|
|
55
|
+
get(
|
|
56
|
+
key: string,
|
|
57
|
+
build: () => Promise<unknown>,
|
|
58
|
+
ifNoneMatch?: string | null,
|
|
59
|
+
): Promise<ScreenCacheHit> {
|
|
60
|
+
const fresh = this.entries.get(key);
|
|
61
|
+
if (fresh && fresh.freshUntil > this.now()) {
|
|
62
|
+
return Promise.resolve({ entry: fresh, fresh: true, notModified: ifNoneMatch === fresh.etag });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const pending = this.pending.get(key);
|
|
66
|
+
if (pending) {
|
|
67
|
+
return pending.then((entry) => ({
|
|
68
|
+
entry,
|
|
69
|
+
fresh: true,
|
|
70
|
+
notModified: ifNoneMatch === entry.etag,
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const promise = this.buildEntry(key, build);
|
|
75
|
+
this.pending.set(key, promise);
|
|
76
|
+
return promise.then((entry) => ({
|
|
77
|
+
entry,
|
|
78
|
+
fresh: true,
|
|
79
|
+
notModified: ifNoneMatch === entry.etag,
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
purgeAll(): void {
|
|
84
|
+
this.entries.clear();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private async buildEntry(
|
|
88
|
+
key: string,
|
|
89
|
+
build: () => Promise<unknown>,
|
|
90
|
+
): Promise<ScreenCacheEntry> {
|
|
91
|
+
try {
|
|
92
|
+
const screen = await build();
|
|
93
|
+
const body = JSON.stringify(screen);
|
|
94
|
+
const entry: ScreenCacheEntry = {
|
|
95
|
+
body,
|
|
96
|
+
etag: computeScreenEtag(body),
|
|
97
|
+
freshUntil: this.now() + this.ttlMs,
|
|
98
|
+
};
|
|
99
|
+
this.entries.set(key, entry);
|
|
100
|
+
this.evictOverflow();
|
|
101
|
+
return entry;
|
|
102
|
+
} finally {
|
|
103
|
+
this.pending.delete(key);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private evictOverflow(): void {
|
|
108
|
+
while (this.entries.size > this.limit) {
|
|
109
|
+
const oldest = this.entries.keys().next().value;
|
|
110
|
+
if (oldest === undefined) break;
|
|
111
|
+
this.entries.delete(oldest);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|