@proulr/bff-core 0.1.11 → 0.1.12

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proulr/bff-core",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
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.67",
37
- "@proulr/sdui-contract": "^0.1.16",
36
+ "@proulr/rosetta": "^0.1.70",
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
- const supabaseUrl = process.env.SUPABASE_URL ?? process.env.EXPO_PUBLIC_SUPABASE_URL ?? '';
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
- export function createBffSupabaseClient(authToken?: string): SupabaseClient {
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 screen = await builder(buildContext({}, query, authToken));
111
- return c.json(screen);
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
+ }