@proulr/bff-core 0.1.14 → 0.1.15
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/index.integration.test.ts +143 -11
- package/src/index.ts +47 -6
- package/src/screen-response-cache.test.ts +114 -4
- package/src/screen-response-cache.ts +86 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proulr/bff-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
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.80",
|
|
37
|
+
"@proulr/sdui-contract": "^0.1.21",
|
|
38
38
|
"@supabase/supabase-js": "^2.107.0",
|
|
39
39
|
"dotenv": "^16.6.1",
|
|
40
40
|
"hono": "^4.10.6",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { gunzipSync } from 'node:zlib';
|
|
2
3
|
|
|
3
4
|
type ServeCallback = (info: { address: string; family: string; port: number }) => void;
|
|
4
5
|
|
|
@@ -14,28 +15,49 @@ vi.mock('@hono/node-server', () => ({
|
|
|
14
15
|
|
|
15
16
|
import { createSduiBff } from './index';
|
|
16
17
|
|
|
18
|
+
/** Contador de builders por screenId — prova reuso de cache e escopo do purge. */
|
|
19
|
+
const buildCounts: Record<string, number> = {};
|
|
20
|
+
|
|
17
21
|
function createTestApp(overrides?: {
|
|
18
22
|
metricsEnv?: string;
|
|
23
|
+
ttlEnv?: string;
|
|
19
24
|
actions?: Parameters<typeof createSduiBff>[0]['actions'];
|
|
20
25
|
}) {
|
|
21
26
|
if (overrides?.metricsEnv !== undefined) {
|
|
22
27
|
vi.stubEnv('BFF_METRICS', overrides.metricsEnv);
|
|
23
28
|
}
|
|
29
|
+
if (overrides?.ttlEnv !== undefined) {
|
|
30
|
+
vi.stubEnv('BFF_SCREEN_CACHE_TTL_MS', overrides.ttlEnv);
|
|
31
|
+
}
|
|
32
|
+
Object.keys(buildCounts).forEach((key) => delete buildCounts[key]);
|
|
24
33
|
return createSduiBff({
|
|
25
34
|
name: 'Integration BFF',
|
|
26
35
|
port: 9999,
|
|
27
36
|
bffId: 'integration-bff',
|
|
28
37
|
screens: {
|
|
29
|
-
'demo.home': async (ctx) =>
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
'demo.home': async (ctx) => {
|
|
39
|
+
buildCounts['demo.home'] = (buildCounts['demo.home'] ?? 0) + 1;
|
|
40
|
+
return {
|
|
41
|
+
schema_version: 1,
|
|
42
|
+
screen_id: 'demo.home',
|
|
43
|
+
blocks: [],
|
|
44
|
+
data: {
|
|
45
|
+
auth_token: ctx.authToken ?? null,
|
|
46
|
+
locale: ctx.query?.locale ?? null,
|
|
47
|
+
},
|
|
48
|
+
meta: { bff: 'integration-bff' },
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
// Acima do threshold (1 KB) do hono/compress — corpo pequeno é servido identity.
|
|
52
|
+
'demo.big': async () => {
|
|
53
|
+
buildCounts['demo.big'] = (buildCounts['demo.big'] ?? 0) + 1;
|
|
54
|
+
return {
|
|
55
|
+
schema_version: 1,
|
|
56
|
+
screen_id: 'demo.big',
|
|
57
|
+
blocks: [],
|
|
58
|
+
data: { filler: 'proulr-'.repeat(400) },
|
|
59
|
+
};
|
|
60
|
+
},
|
|
39
61
|
},
|
|
40
62
|
actions: overrides?.actions ?? {
|
|
41
63
|
ping: async (_ctx, body) => ({ echo: body }),
|
|
@@ -68,7 +90,7 @@ describe('createSduiBff HTTP integration', () => {
|
|
|
68
90
|
ok: true,
|
|
69
91
|
bff: 'integration-bff',
|
|
70
92
|
name: 'Integration BFF',
|
|
71
|
-
screens: ['demo.home'],
|
|
93
|
+
screens: ['demo.home', 'demo.big'],
|
|
72
94
|
metrics_enabled: true,
|
|
73
95
|
});
|
|
74
96
|
});
|
|
@@ -84,6 +106,116 @@ describe('createSduiBff HTTP integration', () => {
|
|
|
84
106
|
expect(body.data).toMatchObject({ auth_token: 'tok-abc', locale: 'pt-BR' });
|
|
85
107
|
});
|
|
86
108
|
|
|
109
|
+
it('reuses the cached wire within the default TTL and answers 304 on If-None-Match', async () => {
|
|
110
|
+
const { app } = createTestApp();
|
|
111
|
+
|
|
112
|
+
const first = await app.request('/v1/screens/demo.home', {
|
|
113
|
+
headers: { Authorization: 'Bearer tok-reuse' },
|
|
114
|
+
});
|
|
115
|
+
const etag = first.headers.get('ETag');
|
|
116
|
+
expect(etag).toMatch(/^W\/"/);
|
|
117
|
+
expect(buildCounts['demo.home']).toBe(1);
|
|
118
|
+
|
|
119
|
+
const second = await app.request('/v1/screens/demo.home', {
|
|
120
|
+
headers: { Authorization: 'Bearer tok-reuse' },
|
|
121
|
+
});
|
|
122
|
+
expect(second.status).toBe(200);
|
|
123
|
+
expect(buildCounts['demo.home']).toBe(1);
|
|
124
|
+
await expect(second.json()).resolves.toMatchObject({ screen_id: 'demo.home' });
|
|
125
|
+
|
|
126
|
+
const conditional = await app.request('/v1/screens/demo.home', {
|
|
127
|
+
headers: { Authorization: 'Bearer tok-reuse', 'If-None-Match': etag ?? '' },
|
|
128
|
+
});
|
|
129
|
+
expect(conditional.status).toBe(304);
|
|
130
|
+
expect(conditional.headers.get('ETag')).toBe(etag);
|
|
131
|
+
await expect(conditional.text()).resolves.toBe('');
|
|
132
|
+
expect(buildCounts['demo.home']).toBe(1);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('does not reuse the cache when BFF_SCREEN_CACHE_TTL_MS=0', async () => {
|
|
136
|
+
const { app } = createTestApp({ ttlEnv: '0' });
|
|
137
|
+
|
|
138
|
+
await app.request('/v1/screens/demo.home', { headers: { Authorization: 'Bearer tok-0' } });
|
|
139
|
+
await app.request('/v1/screens/demo.home', { headers: { Authorization: 'Bearer tok-0' } });
|
|
140
|
+
expect(buildCounts['demo.home']).toBe(2);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('compresses a screen above the threshold and marks it as varying by encoding', async () => {
|
|
144
|
+
const { app } = createTestApp();
|
|
145
|
+
const response = await app.request('/v1/screens/demo.big', {
|
|
146
|
+
headers: { 'Accept-Encoding': 'gzip' },
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
expect(response.status).toBe(200);
|
|
150
|
+
expect(response.headers.get('Content-Encoding')).toBe('gzip');
|
|
151
|
+
expect(response.headers.get('Vary')).toContain('Accept-Encoding');
|
|
152
|
+
|
|
153
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
154
|
+
const json = JSON.parse(gunzipSync(bytes).toString('utf8')) as {
|
|
155
|
+
screen_id: string;
|
|
156
|
+
data: { filler: string };
|
|
157
|
+
};
|
|
158
|
+
expect(json.screen_id).toBe('demo.big');
|
|
159
|
+
expect(json.data.filler).toHaveLength('proulr-'.repeat(400).length);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('leaves an identity response uncompressed when the client accepts nothing', async () => {
|
|
163
|
+
const { app } = createTestApp();
|
|
164
|
+
const response = await app.request('/v1/screens/demo.big');
|
|
165
|
+
expect(response.headers.get('Content-Encoding')).toBeNull();
|
|
166
|
+
await expect(response.json()).resolves.toMatchObject({ screen_id: 'demo.big' });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('purges only the declared screen and the author, not the whole park', async () => {
|
|
170
|
+
const actionHandler = vi.fn(async () => ({
|
|
171
|
+
ok: true,
|
|
172
|
+
invalidated_screen_ids: ['demo.big'],
|
|
173
|
+
}));
|
|
174
|
+
const { app } = createTestApp({ actions: { 'social.like': actionHandler } });
|
|
175
|
+
|
|
176
|
+
// autor (tok-author) e outra pessoa (tok-other) com as duas telas em cache
|
|
177
|
+
for (const token of ['tok-author', 'tok-other']) {
|
|
178
|
+
for (const screenId of ['demo.home', 'demo.big']) {
|
|
179
|
+
await app.request(`/v1/screens/${screenId}`, {
|
|
180
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
expect(buildCounts).toEqual({ 'demo.home': 2, 'demo.big': 2 });
|
|
185
|
+
|
|
186
|
+
await app.request('/v1/actions/social.like', {
|
|
187
|
+
method: 'POST',
|
|
188
|
+
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer tok-author' },
|
|
189
|
+
body: JSON.stringify({ post_id: 'p1' }),
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// demo.big caiu para todo o parque; demo.home só para o autor.
|
|
193
|
+
await app.request('/v1/screens/demo.big', { headers: { Authorization: 'Bearer tok-other' } });
|
|
194
|
+
expect(buildCounts['demo.big']).toBe(3);
|
|
195
|
+
|
|
196
|
+
await app.request('/v1/screens/demo.home', { headers: { Authorization: 'Bearer tok-other' } });
|
|
197
|
+
expect(buildCounts['demo.home']).toBe(2);
|
|
198
|
+
|
|
199
|
+
await app.request('/v1/screens/demo.home', { headers: { Authorization: 'Bearer tok-author' } });
|
|
200
|
+
expect(buildCounts['demo.home']).toBe(3);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('does not echo the reserved invalidated_screen_ids key in the action response', async () => {
|
|
204
|
+
const { app } = createTestApp({
|
|
205
|
+
actions: {
|
|
206
|
+
'social.like': async () => ({ ok: true, likes: 3, invalidated_screen_ids: ['demo.home'] }),
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
const response = await app.request('/v1/actions/social.like', {
|
|
210
|
+
method: 'POST',
|
|
211
|
+
headers: { 'Content-Type': 'application/json' },
|
|
212
|
+
body: '{}',
|
|
213
|
+
});
|
|
214
|
+
const body = await response.json();
|
|
215
|
+
expect(body).toMatchObject({ ok: true, action_id: 'social.like', likes: 3 });
|
|
216
|
+
expect(body).not.toHaveProperty('invalidated_screen_ids');
|
|
217
|
+
});
|
|
218
|
+
|
|
87
219
|
it('ignores non-Bearer Authorization header', async () => {
|
|
88
220
|
const { app } = createTestApp();
|
|
89
221
|
const response = await app.request('/v1/screens/demo.home', {
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { serve } from '@hono/node-server';
|
|
3
3
|
import { Hono } from 'hono';
|
|
4
|
+
import { compress } from 'hono/compress';
|
|
4
5
|
import { cors } from 'hono/cors';
|
|
5
6
|
import type { SduiScreenBuilder, SduiScreenBuilderContext } from '@proulr/sdui-contract';
|
|
6
7
|
import { createBffMetrics, isBffMetricsEnabled } from './bff-metrics';
|
|
7
8
|
import { ScreenResponseCache, buildScreenCacheKey } from './screen-response-cache';
|
|
8
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Handler de action. O retorno é mesclado no JSON de resposta, com uma chave
|
|
12
|
+
* reservada: `invalidated_screen_ids?: string[]` lista as telas cujo cache o BFF
|
|
13
|
+
* sabe alterado por esta escrita (ex.: `feed.social` num like). A chave nunca é
|
|
14
|
+
* devolvida ao cliente.
|
|
15
|
+
*/
|
|
9
16
|
export type SduiActionHandler = (
|
|
10
17
|
ctx: SduiScreenBuilderContext,
|
|
11
18
|
body: Record<string, unknown>,
|
|
@@ -41,15 +48,32 @@ function resolveScreenId(path: string): string {
|
|
|
41
48
|
return 'other';
|
|
42
49
|
}
|
|
43
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Freshness default em vez de 0: com TTL zerado o cache só faz coalescing de
|
|
53
|
+
* in-flight, então cada revalidação do app reconstrói o wire no builder. 5 s é o
|
|
54
|
+
* orçamento em que um like/mensagem atrasa sem o usuário perceber e o round-trip
|
|
55
|
+
* condicional (304) deixa de custar CPU. `BFF_SCREEN_CACHE_TTL_MS=0` continua
|
|
56
|
+
* desligando o cache para quem precisar de leitura fortemente consistente.
|
|
57
|
+
*/
|
|
58
|
+
const DEFAULT_SCREEN_CACHE_TTL_MS = 5000;
|
|
59
|
+
|
|
60
|
+
function resolveScreenCacheTtlMs(): number {
|
|
61
|
+
const configured = Number(process.env.BFF_SCREEN_CACHE_TTL_MS);
|
|
62
|
+
return Number.isFinite(configured) && configured >= 0
|
|
63
|
+
? configured
|
|
64
|
+
: DEFAULT_SCREEN_CACHE_TTL_MS;
|
|
65
|
+
}
|
|
66
|
+
|
|
44
67
|
export function createSduiBff(options: CreateSduiBffOptions) {
|
|
45
68
|
const app = new Hono();
|
|
46
69
|
const metricsEnabled = isBffMetricsEnabled();
|
|
47
70
|
const metrics = metricsEnabled ? createBffMetrics(options.bffId) : null;
|
|
48
|
-
const screenCache = new ScreenResponseCache(
|
|
49
|
-
Number(process.env.BFF_SCREEN_CACHE_TTL_MS ?? 0) || 0,
|
|
50
|
-
);
|
|
71
|
+
const screenCache = new ScreenResponseCache(resolveScreenCacheTtlMs());
|
|
51
72
|
|
|
52
73
|
app.use('*', cors());
|
|
74
|
+
// O túnel cloudflared vai na porta do BFF sem Caddy no caminho, então a
|
|
75
|
+
// compressão tem que nascer aqui — é o único salto que cruza a internet.
|
|
76
|
+
app.use('*', compress());
|
|
53
77
|
|
|
54
78
|
app.use('*', async (c, next) => {
|
|
55
79
|
const requestId = randomUUID();
|
|
@@ -116,16 +140,22 @@ export function createSduiBff(options: CreateSduiBffOptions) {
|
|
|
116
140
|
cacheKey,
|
|
117
141
|
async () => builder(buildContext({}, query, authToken)),
|
|
118
142
|
c.req.header('If-None-Match'),
|
|
143
|
+
{ screenId, authToken },
|
|
119
144
|
);
|
|
120
145
|
|
|
146
|
+
// O body é sempre identity aqui; hono/compress escolhe o encoding depois. Sem
|
|
147
|
+
// Vary, qualquer borda compartilhada serviria gzip para quem pediu identity.
|
|
148
|
+
const vary = { Vary: 'Accept-Encoding' };
|
|
149
|
+
|
|
121
150
|
if (hit.notModified) {
|
|
122
|
-
return c.newResponse(null, 304, { ETag: hit.entry.etag });
|
|
151
|
+
return c.newResponse(null, 304, { ETag: hit.entry.etag, ...vary });
|
|
123
152
|
}
|
|
124
153
|
|
|
125
154
|
return c.body(hit.entry.body, 200, {
|
|
126
155
|
'Content-Type': 'application/json; charset=utf-8',
|
|
127
156
|
ETag: hit.entry.etag,
|
|
128
157
|
'Cache-Control': 'private, no-cache',
|
|
158
|
+
...vary,
|
|
129
159
|
});
|
|
130
160
|
});
|
|
131
161
|
|
|
@@ -146,8 +176,16 @@ export function createSduiBff(options: CreateSduiBffOptions) {
|
|
|
146
176
|
try {
|
|
147
177
|
const authToken = readAuthToken(c.req.header('Authorization'));
|
|
148
178
|
const result = await handler(buildContext({}, {}, authToken), body);
|
|
149
|
-
|
|
150
|
-
|
|
179
|
+
// `invalidated_screen_ids` é a tela que o BFF dono sabe ter mudado. Sem
|
|
180
|
+
// lista, só o token do autor é purgado — `purgeAll()` derrubava o cache de
|
|
181
|
+
// todas as telas de todo o parque a cada like.
|
|
182
|
+
const { invalidated_screen_ids: invalidatedScreenIds, ...payload } = result;
|
|
183
|
+
const screens = Array.isArray(invalidatedScreenIds)
|
|
184
|
+
? invalidatedScreenIds.filter((screenId): screenId is string => typeof screenId === 'string')
|
|
185
|
+
: [];
|
|
186
|
+
screenCache.invalidateScreens(screens);
|
|
187
|
+
screenCache.invalidateToken(authToken);
|
|
188
|
+
return c.json({ ok: true, action_id: actionId, ...payload });
|
|
151
189
|
} catch (error) {
|
|
152
190
|
console.warn(`[${options.bffId}] action ${actionId} failed`, error);
|
|
153
191
|
return c.json(
|
|
@@ -178,8 +216,11 @@ export {
|
|
|
178
216
|
ScreenResponseCache,
|
|
179
217
|
buildScreenCacheKey,
|
|
180
218
|
computeScreenEtag,
|
|
219
|
+
computeScreenRevision,
|
|
181
220
|
type ScreenCacheEntry,
|
|
182
221
|
type ScreenCacheHit,
|
|
222
|
+
type ScreenCacheKeyInput,
|
|
223
|
+
type ScreenCacheScopeInput,
|
|
183
224
|
} from './screen-response-cache';
|
|
184
225
|
export {
|
|
185
226
|
buildNativeAdInsertionSeed,
|
|
@@ -3,8 +3,13 @@ import {
|
|
|
3
3
|
ScreenResponseCache,
|
|
4
4
|
buildScreenCacheKey,
|
|
5
5
|
computeScreenEtag,
|
|
6
|
+
computeScreenRevision,
|
|
6
7
|
} from './screen-response-cache';
|
|
7
8
|
|
|
9
|
+
function parseBody(body: string): Record<string, unknown> {
|
|
10
|
+
return JSON.parse(body) as Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
|
|
8
13
|
describe('buildScreenCacheKey', () => {
|
|
9
14
|
it('is stable regardless of query insertion order', () => {
|
|
10
15
|
const a = buildScreenCacheKey({
|
|
@@ -63,8 +68,41 @@ describe('ScreenResponseCache', () => {
|
|
|
63
68
|
|
|
64
69
|
const [h1, h2] = await Promise.all([p1, p2]);
|
|
65
70
|
expect(calls).toBe(1);
|
|
66
|
-
expect(h1.entry.body).
|
|
67
|
-
expect(h2.entry.body).toBe(
|
|
71
|
+
expect(h1.entry.body).toContain('"ok":true');
|
|
72
|
+
expect(h2.entry.body).toBe(h1.entry.body);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('stamps meta.rev with the same digest as the weak ETag', async () => {
|
|
76
|
+
const cache = new ScreenResponseCache(5_000);
|
|
77
|
+
const wire = { schema_version: 1, screen_id: 'feed.social', blocks: [] };
|
|
78
|
+
const hit = await cache.get('k', async () => wire);
|
|
79
|
+
|
|
80
|
+
const rev = (parseBody(hit.entry.body).meta as { rev: string }).rev;
|
|
81
|
+
expect(hit.entry.etag).toBe(`W/"${rev}"`);
|
|
82
|
+
// O digest é do wire antes da injeção — o corpo final não é a fonte do rev.
|
|
83
|
+
expect(rev).toBe(computeScreenRevision(JSON.stringify(wire)));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('keeps the rev stable for identical content and rotates it when the wire changes', async () => {
|
|
87
|
+
const cache = new ScreenResponseCache(0);
|
|
88
|
+
let n = 0;
|
|
89
|
+
const build = async () => ({ screen_id: 'feed.social', n: ++n });
|
|
90
|
+
|
|
91
|
+
const first = await cache.get('k', build);
|
|
92
|
+
const second = await cache.get('k', build);
|
|
93
|
+
const firstRev = (parseBody(first.entry.body).meta as { rev: string }).rev;
|
|
94
|
+
const secondRev = (parseBody(second.entry.body).meta as { rev: string }).rev;
|
|
95
|
+
expect(secondRev).not.toBe(firstRev);
|
|
96
|
+
|
|
97
|
+
const twin = await cache.get('k2', async () => ({ screen_id: 'feed.social', n: 2 }));
|
|
98
|
+
expect((parseBody(twin.entry.body).meta as { rev: string }).rev).toBe(secondRev);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('leaves a non-object payload untouched', async () => {
|
|
102
|
+
const cache = new ScreenResponseCache(5_000);
|
|
103
|
+
const hit = await cache.get('k', async () => 'plain');
|
|
104
|
+
expect(hit.entry.body).toBe('"plain"');
|
|
105
|
+
expect(hit.entry.etag).toBe(computeScreenEtag('"plain"'));
|
|
68
106
|
});
|
|
69
107
|
|
|
70
108
|
it('rebuilds after completion when ttl is 0', async () => {
|
|
@@ -75,7 +113,7 @@ describe('ScreenResponseCache', () => {
|
|
|
75
113
|
await cache.get('k', build);
|
|
76
114
|
const second = await cache.get('k', build);
|
|
77
115
|
expect(calls).toBe(2);
|
|
78
|
-
expect(second.entry.body).toBe(
|
|
116
|
+
expect(parseBody(second.entry.body).n).toBe(2);
|
|
79
117
|
});
|
|
80
118
|
|
|
81
119
|
it('serves fresh entries within ttl and flags matching etag as not modified', async () => {
|
|
@@ -121,6 +159,78 @@ describe('ScreenResponseCache', () => {
|
|
|
121
159
|
expect(calls).toBe(2);
|
|
122
160
|
});
|
|
123
161
|
|
|
162
|
+
it('invalidateToken drops only that user entries', async () => {
|
|
163
|
+
let calls = 0;
|
|
164
|
+
const build = async () => ({ n: ++calls });
|
|
165
|
+
const cache = new ScreenResponseCache(5_000);
|
|
166
|
+
|
|
167
|
+
const authorKey = buildScreenCacheKey({ screenId: 'feed.social', query: {}, authToken: 'A' });
|
|
168
|
+
const otherKey = buildScreenCacheKey({ screenId: 'feed.social', query: {}, authToken: 'B' });
|
|
169
|
+
await cache.get(authorKey, build, null, { screenId: 'feed.social', authToken: 'A' });
|
|
170
|
+
await cache.get(otherKey, build, null, { screenId: 'feed.social', authToken: 'B' });
|
|
171
|
+
expect(calls).toBe(2);
|
|
172
|
+
|
|
173
|
+
cache.invalidateToken('A');
|
|
174
|
+
|
|
175
|
+
// O autor reconstrói; a entrada do outro usuário continua no cache.
|
|
176
|
+
await cache.get(authorKey, build, null, { screenId: 'feed.social', authToken: 'A' });
|
|
177
|
+
expect(calls).toBe(3);
|
|
178
|
+
await cache.get(otherKey, build, null, { screenId: 'feed.social', authToken: 'B' });
|
|
179
|
+
expect(calls).toBe(3);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('invalidateToken also clears the anonymous scope', async () => {
|
|
183
|
+
let calls = 0;
|
|
184
|
+
const build = async () => ({ n: ++calls });
|
|
185
|
+
const cache = new ScreenResponseCache(5_000);
|
|
186
|
+
const key = buildScreenCacheKey({ screenId: 'vibes.home', query: {} });
|
|
187
|
+
|
|
188
|
+
await cache.get(key, build, null, { screenId: 'vibes.home' });
|
|
189
|
+
cache.invalidateToken(undefined);
|
|
190
|
+
await cache.get(key, build, null, { screenId: 'vibes.home' });
|
|
191
|
+
expect(calls).toBe(2);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('invalidateScreens drops the screen for every token and ignores an empty list', async () => {
|
|
195
|
+
let calls = 0;
|
|
196
|
+
const build = async () => ({ n: ++calls });
|
|
197
|
+
const cache = new ScreenResponseCache(5_000);
|
|
198
|
+
const keyA = buildScreenCacheKey({ screenId: 'feed.social', query: {}, authToken: 'A' });
|
|
199
|
+
const keyB = buildScreenCacheKey({ screenId: 'feed.social', query: {}, authToken: 'B' });
|
|
200
|
+
const otherKey = buildScreenCacheKey({ screenId: 'chat.inbox', query: {}, authToken: 'A' });
|
|
201
|
+
|
|
202
|
+
await cache.get(keyA, build, null, { screenId: 'feed.social', authToken: 'A' });
|
|
203
|
+
await cache.get(keyB, build, null, { screenId: 'feed.social', authToken: 'B' });
|
|
204
|
+
await cache.get(otherKey, build, null, { screenId: 'chat.inbox', authToken: 'A' });
|
|
205
|
+
expect(calls).toBe(3);
|
|
206
|
+
|
|
207
|
+
cache.invalidateScreens([]);
|
|
208
|
+
await cache.get(keyA, build, null, { screenId: 'feed.social', authToken: 'A' });
|
|
209
|
+
expect(calls).toBe(3);
|
|
210
|
+
|
|
211
|
+
cache.invalidateScreens(['feed.social']);
|
|
212
|
+
await cache.get(keyA, build, null, { screenId: 'feed.social', authToken: 'A' });
|
|
213
|
+
await cache.get(keyB, build, null, { screenId: 'feed.social', authToken: 'B' });
|
|
214
|
+
expect(calls).toBe(5);
|
|
215
|
+
await cache.get(otherKey, build, null, { screenId: 'chat.inbox', authToken: 'A' });
|
|
216
|
+
expect(calls).toBe(5);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('forgotten scopes evicted with their entries do not break invalidation', async () => {
|
|
220
|
+
let calls = 0;
|
|
221
|
+
const build = async () => ({ n: ++calls });
|
|
222
|
+
const cache = new ScreenResponseCache(5_000, 2);
|
|
223
|
+
|
|
224
|
+
await cache.get('a', build, null, { screenId: 'feed.social', authToken: 'A' });
|
|
225
|
+
await cache.get('b', build, null, { screenId: 'chat.inbox', authToken: 'B' });
|
|
226
|
+
await cache.get('c', build, null, { screenId: 'vibes.home', authToken: 'C' });
|
|
227
|
+
|
|
228
|
+
expect(() => {
|
|
229
|
+
cache.invalidateScreens(['feed.social']);
|
|
230
|
+
cache.invalidateToken('A');
|
|
231
|
+
}).not.toThrow();
|
|
232
|
+
});
|
|
233
|
+
|
|
124
234
|
it('does not cache failures', async () => {
|
|
125
235
|
let calls = 0;
|
|
126
236
|
const build = async () => {
|
|
@@ -133,7 +243,7 @@ describe('ScreenResponseCache', () => {
|
|
|
133
243
|
await expect(cache.get('k', build)).rejects.toThrow('boom');
|
|
134
244
|
const ok = await cache.get('k', build);
|
|
135
245
|
expect(calls).toBe(2);
|
|
136
|
-
expect(ok.entry.body).toBe(
|
|
246
|
+
expect(parseBody(ok.entry.body).ok).toBe(true);
|
|
137
247
|
});
|
|
138
248
|
|
|
139
249
|
it('evicts the oldest entries beyond the limit', async () => {
|
|
@@ -18,6 +18,24 @@ export type ScreenCacheHit = {
|
|
|
18
18
|
notModified: boolean;
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Domínio de uma entrada, lembrado junto dela para permitir purge sem reconstruir
|
|
23
|
+
* a chave. O token nunca fica retido cru — só o escopo derivado dele.
|
|
24
|
+
*/
|
|
25
|
+
type ScreenCacheScope = {
|
|
26
|
+
screenId: string;
|
|
27
|
+
tokenScope: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type ScreenCacheScopeInput = {
|
|
31
|
+
screenId: string;
|
|
32
|
+
authToken?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function hashTokenScope(authToken?: string): string {
|
|
36
|
+
return createHash('sha256').update(authToken ?? 'anon').digest('base64url');
|
|
37
|
+
}
|
|
38
|
+
|
|
21
39
|
export function buildScreenCacheKey(input: ScreenCacheKeyInput): string {
|
|
22
40
|
const queryPart = Object.keys(input.query)
|
|
23
41
|
.sort()
|
|
@@ -33,18 +51,44 @@ export function buildScreenCacheKey(input: ScreenCacheKeyInput): string {
|
|
|
33
51
|
}
|
|
34
52
|
|
|
35
53
|
export function computeScreenEtag(body: string): string {
|
|
36
|
-
return `W/"${
|
|
54
|
+
return `W/"${computeScreenRevision(body)}"`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Digest do wire que vira duas coisas do mesmo valor: o ETag fraco da resposta e
|
|
59
|
+
* `meta.rev` dentro do corpo. O app precisa do segundo porque o wire persistido
|
|
60
|
+
* em AsyncStorage não carrega header HTTP — sem `rev` ele só saberia se mudou
|
|
61
|
+
* baixando o corpo inteiro.
|
|
62
|
+
*/
|
|
63
|
+
export function computeScreenRevision(body: string): string {
|
|
64
|
+
return createHash('sha256').update(body).digest('base64url').slice(0, 32);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* O digest é do wire **sem** `meta.rev`, senão ele dependeria de si mesmo. Por
|
|
69
|
+
* isso o hash é feito sobre a serialização original e a chave é injetada depois;
|
|
70
|
+
* o corpo em cache e o ETag apontam para o mesmo digest.
|
|
71
|
+
*/
|
|
72
|
+
function withRevision(screen: unknown, rev: string): unknown {
|
|
73
|
+
if (!screen || typeof screen !== 'object' || Array.isArray(screen)) return screen;
|
|
74
|
+
const meta = (screen as { meta?: unknown }).meta;
|
|
75
|
+
const metaObject = meta && typeof meta === 'object' && !Array.isArray(meta)
|
|
76
|
+
? (meta as Record<string, unknown>)
|
|
77
|
+
: {};
|
|
78
|
+
return { ...screen, meta: { ...metaObject, rev } };
|
|
37
79
|
}
|
|
38
80
|
|
|
39
81
|
/**
|
|
40
82
|
* Dedupes identical in-flight screen builds (the mount/focus revalidation storm
|
|
41
|
-
* fires the same GET 3-4x concurrently)
|
|
42
|
-
*
|
|
43
|
-
*
|
|
83
|
+
* fires the same GET 3-4x concurrently) and, with `ttlMs > 0`, serves completed
|
|
84
|
+
* results while fresh. Freshness is bounded by TTL and by the targeted purges
|
|
85
|
+
* (`invalidateScreens` / `invalidateToken`), never by dropping the whole park's
|
|
86
|
+
* cache on every write — an action used to invalidate every screen for everyone.
|
|
44
87
|
*/
|
|
45
88
|
export class ScreenResponseCache {
|
|
46
89
|
private entries = new Map<string, ScreenCacheEntry>();
|
|
47
90
|
private pending = new Map<string, Promise<ScreenCacheEntry>>();
|
|
91
|
+
private scopes = new Map<string, ScreenCacheScope>();
|
|
48
92
|
|
|
49
93
|
constructor(
|
|
50
94
|
private readonly ttlMs: number,
|
|
@@ -56,6 +100,7 @@ export class ScreenResponseCache {
|
|
|
56
100
|
key: string,
|
|
57
101
|
build: () => Promise<unknown>,
|
|
58
102
|
ifNoneMatch?: string | null,
|
|
103
|
+
scope?: ScreenCacheScopeInput,
|
|
59
104
|
): Promise<ScreenCacheHit> {
|
|
60
105
|
const fresh = this.entries.get(key);
|
|
61
106
|
if (fresh && fresh.freshUntil > this.now()) {
|
|
@@ -71,7 +116,7 @@ export class ScreenResponseCache {
|
|
|
71
116
|
}));
|
|
72
117
|
}
|
|
73
118
|
|
|
74
|
-
const promise = this.buildEntry(key, build);
|
|
119
|
+
const promise = this.buildEntry(key, build, scope);
|
|
75
120
|
this.pending.set(key, promise);
|
|
76
121
|
return promise.then((entry) => ({
|
|
77
122
|
entry,
|
|
@@ -80,23 +125,56 @@ export class ScreenResponseCache {
|
|
|
80
125
|
}));
|
|
81
126
|
}
|
|
82
127
|
|
|
128
|
+
/** Purge de uma tela em todos os queries/tokens — o que uma action declara ter mudado. */
|
|
129
|
+
invalidateScreens(screenIds: readonly string[]): void {
|
|
130
|
+
if (screenIds.length === 0) return;
|
|
131
|
+
const wanted = new Set(screenIds);
|
|
132
|
+
this.dropWhere((scope) => wanted.has(scope.screenId));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Purge do autor da action: quem escreve nunca relê o próprio estado atrasado,
|
|
137
|
+
* e as telas dos outros usuários continuam pagando só o TTL.
|
|
138
|
+
*/
|
|
139
|
+
invalidateToken(authToken?: string): void {
|
|
140
|
+
const tokenScope = hashTokenScope(authToken);
|
|
141
|
+
this.dropWhere((scope) => scope.tokenScope === tokenScope);
|
|
142
|
+
}
|
|
143
|
+
|
|
83
144
|
purgeAll(): void {
|
|
84
145
|
this.entries.clear();
|
|
146
|
+
this.scopes.clear();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private dropWhere(predicate: (scope: ScreenCacheScope) => boolean): void {
|
|
150
|
+
for (const [key, scope] of this.scopes) {
|
|
151
|
+
if (!predicate(scope)) continue;
|
|
152
|
+
this.entries.delete(key);
|
|
153
|
+
this.scopes.delete(key);
|
|
154
|
+
}
|
|
85
155
|
}
|
|
86
156
|
|
|
87
157
|
private async buildEntry(
|
|
88
158
|
key: string,
|
|
89
159
|
build: () => Promise<unknown>,
|
|
160
|
+
scope?: ScreenCacheScopeInput,
|
|
90
161
|
): Promise<ScreenCacheEntry> {
|
|
91
162
|
try {
|
|
92
163
|
const screen = await build();
|
|
93
|
-
const
|
|
164
|
+
const rev = computeScreenRevision(JSON.stringify(screen));
|
|
165
|
+
const body = JSON.stringify(withRevision(screen, rev));
|
|
94
166
|
const entry: ScreenCacheEntry = {
|
|
95
167
|
body,
|
|
96
|
-
etag:
|
|
168
|
+
etag: `W/"${rev}"`,
|
|
97
169
|
freshUntil: this.now() + this.ttlMs,
|
|
98
170
|
};
|
|
99
171
|
this.entries.set(key, entry);
|
|
172
|
+
if (scope) {
|
|
173
|
+
this.scopes.set(key, {
|
|
174
|
+
screenId: scope.screenId,
|
|
175
|
+
tokenScope: hashTokenScope(scope.authToken),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
100
178
|
this.evictOverflow();
|
|
101
179
|
return entry;
|
|
102
180
|
} finally {
|
|
@@ -109,6 +187,7 @@ export class ScreenResponseCache {
|
|
|
109
187
|
const oldest = this.entries.keys().next().value;
|
|
110
188
|
if (oldest === undefined) break;
|
|
111
189
|
this.entries.delete(oldest);
|
|
190
|
+
this.scopes.delete(oldest);
|
|
112
191
|
}
|
|
113
192
|
}
|
|
114
193
|
}
|