fontdue-js 3.0.0-alpha12 → 3.0.0-alpha13
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/.playwright-mcp/console-2026-06-15T09-14-00-118Z.log +84 -0
- package/.playwright-mcp/console-2026-06-15T09-25-42-726Z.log +2 -0
- package/.playwright-mcp/console-2026-06-15T09-25-47-707Z.log +1 -0
- package/.playwright-mcp/page-2026-06-15T09-14-01-054Z.yml +13 -0
- package/CHANGELOG.md +2 -2
- package/README.md +25 -50
- package/dist/__tests__/createFontdueFetch.test.js +33 -0
- package/dist/__tests__/networkFetch.test.js +81 -2
- package/dist/__tests__/nextAdapter.test.js +195 -50
- package/dist/__tests__/serverConfig.test.js +62 -0
- package/dist/components/ConfigContext.d.ts +3 -0
- package/dist/components/ConfigContext.js +5 -2
- package/dist/components/FontdueAdminToolbar/index.js +72 -16
- package/dist/components/FontdueProvider/index.server.d.ts +1 -0
- package/dist/components/FontdueProvider/index.server.js +10 -0
- package/dist/fontdue.css +59 -0
- package/dist/next/index.d.ts +1 -2
- package/dist/next/index.js +16 -6
- package/dist/next/registerSingleTenantResolver.d.ts +1 -0
- package/dist/next/registerSingleTenantResolver.js +36 -0
- package/dist/next/revalidate.js +1 -1
- package/dist/next/tenant.d.ts +6 -4
- package/dist/next/tenant.js +106 -69
- package/dist/preview/constants.d.ts +2 -0
- package/dist/preview/constants.js +20 -1
- package/dist/relay/environment.d.ts +2 -0
- package/dist/relay/environment.js +67 -38
- package/dist/relay/serverConfig.d.ts +6 -4
- package/dist/relay/serverConfig.js +81 -19
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +27 -15
- package/package.json +1 -1
- package/types/next-navigation.d.ts +4 -0
- package/vitest.config.ts +5 -0
|
@@ -8,6 +8,11 @@ import path from 'node:path';
|
|
|
8
8
|
async function importTenant() {
|
|
9
9
|
return await import("../next/tenant.js");
|
|
10
10
|
}
|
|
11
|
+
// Importing this module registers the single-tenant ambient resolver as a side
|
|
12
|
+
// effect, the same way the FontdueProvider react-server entrypoint does.
|
|
13
|
+
async function importRegister() {
|
|
14
|
+
return await import("../next/registerSingleTenantResolver.js");
|
|
15
|
+
}
|
|
11
16
|
async function importConfig() {
|
|
12
17
|
return await import("../next/config.js");
|
|
13
18
|
}
|
|
@@ -20,36 +25,56 @@ vi.mock('next/cache', () => ({
|
|
|
20
25
|
}));
|
|
21
26
|
|
|
22
27
|
// Next's notFound() throws a sentinel the framework catches; the mock does
|
|
23
|
-
// the same so tests can assert on it.
|
|
28
|
+
// the same so tests can assert on it. unstable_rethrow re-throws Next's
|
|
29
|
+
// internal control-flow errors (dynamic bailout, notFound, redirect) and is a
|
|
30
|
+
// no-op for anything else — the mock keys off the `digest` convention Next
|
|
31
|
+
// uses to tag those errors.
|
|
24
32
|
vi.mock('next/navigation', () => ({
|
|
25
33
|
notFound: () => {
|
|
26
34
|
throw new Error('NEXT_NOT_FOUND');
|
|
35
|
+
},
|
|
36
|
+
unstable_rethrow: error => {
|
|
37
|
+
const digest = error === null || error === void 0 ? void 0 : error.digest;
|
|
38
|
+
if (typeof digest === 'string' && (digest.startsWith('DYNAMIC_SERVER_USAGE') || digest.startsWith('NEXT_') || digest.startsWith('BAILOUT_TO_CLIENT_SIDE_RENDERING'))) {
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
27
41
|
}
|
|
28
42
|
}));
|
|
29
43
|
|
|
30
44
|
// Draft mode + the preview token cookie, controllable per test. Default: not
|
|
31
|
-
// previewing, so
|
|
45
|
+
// previewing, so __prepareFontdueRender takes the public (cached) path. Set
|
|
46
|
+
// cookiesError to make cookies() throw (e.g. simulate Next's dynamic bailout
|
|
47
|
+
// during a prerender pass).
|
|
32
48
|
const draft = vi.hoisted(() => ({
|
|
33
49
|
enabled: false,
|
|
34
|
-
token: undefined
|
|
50
|
+
token: undefined,
|
|
51
|
+
cookiesError: undefined
|
|
35
52
|
}));
|
|
36
53
|
vi.mock('next/headers', () => ({
|
|
37
54
|
draftMode: async () => ({
|
|
38
55
|
isEnabled: draft.enabled
|
|
39
56
|
}),
|
|
40
|
-
cookies: async () =>
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
57
|
+
cookies: async () => {
|
|
58
|
+
if (draft.cookiesError) throw draft.cookiesError;
|
|
59
|
+
return {
|
|
60
|
+
get: name => name === 'fontdue_preview_token' && draft.token ? {
|
|
61
|
+
value: draft.token
|
|
62
|
+
} : undefined
|
|
63
|
+
};
|
|
64
|
+
}
|
|
45
65
|
}));
|
|
46
66
|
beforeEach(() => {
|
|
47
67
|
vi.resetModules();
|
|
68
|
+
// The server-config store (incl. the ambient resolver) is anchored on
|
|
69
|
+
// globalThis, so it survives resetModules; clear it so a resolver registered
|
|
70
|
+
// by one test can't leak into the next.
|
|
71
|
+
delete globalThis.__fontdueServerConfigStore__;
|
|
48
72
|
revalidateTag.mockClear();
|
|
49
73
|
vi.unstubAllEnvs();
|
|
50
74
|
vi.restoreAllMocks();
|
|
51
75
|
draft.enabled = false;
|
|
52
76
|
draft.token = undefined;
|
|
77
|
+
draft.cookiesError = undefined;
|
|
53
78
|
});
|
|
54
79
|
function stubSingleTenant() {
|
|
55
80
|
let url = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 'https://acme.fontdue.com';
|
|
@@ -83,13 +108,13 @@ describe('isValidDomain', () => {
|
|
|
83
108
|
expect(isValidDomain('a'.repeat(254) + '.example')).toBe(false);
|
|
84
109
|
});
|
|
85
110
|
});
|
|
86
|
-
describe('
|
|
111
|
+
describe('endpointForDomain', () => {
|
|
87
112
|
it('single-tenant: targets NEXT_PUBLIC_FONTDUE_URL with no headers', async () => {
|
|
88
113
|
stubSingleTenant('https://acme.fontdue.com');
|
|
89
114
|
const {
|
|
90
|
-
|
|
115
|
+
endpointForDomain
|
|
91
116
|
} = await importTenant();
|
|
92
|
-
expect(
|
|
117
|
+
expect(endpointForDomain('acme.fontdue.com')).toEqual({
|
|
93
118
|
domain: 'acme.fontdue.com',
|
|
94
119
|
origin: 'https://acme.fontdue.com',
|
|
95
120
|
headers: {},
|
|
@@ -100,9 +125,9 @@ describe('fontdueEndpoint', () => {
|
|
|
100
125
|
vi.stubEnv('NEXT_PUBLIC_FONTDUE_URL', '');
|
|
101
126
|
vi.stubEnv('FONTDUE_MULTI_TENANT', '');
|
|
102
127
|
const {
|
|
103
|
-
|
|
128
|
+
endpointForDomain
|
|
104
129
|
} = await importTenant();
|
|
105
|
-
expect(() =>
|
|
130
|
+
expect(() => endpointForDomain('acme.fontdue.com')).toThrow(/NEXT_PUBLIC_FONTDUE_URL/);
|
|
106
131
|
});
|
|
107
132
|
it('multi-tenant: forwards the host to FONTDUE_ORIGIN with the proxy secret', async () => {
|
|
108
133
|
stubMultiTenant({
|
|
@@ -110,9 +135,9 @@ describe('fontdueEndpoint', () => {
|
|
|
110
135
|
secret: 's3cret'
|
|
111
136
|
});
|
|
112
137
|
const {
|
|
113
|
-
|
|
138
|
+
endpointForDomain
|
|
114
139
|
} = await importTenant();
|
|
115
|
-
expect(
|
|
140
|
+
expect(endpointForDomain('acme.fontdue.com')).toEqual({
|
|
116
141
|
domain: 'acme.fontdue.com',
|
|
117
142
|
origin: 'http://app:4000',
|
|
118
143
|
headers: {
|
|
@@ -127,18 +152,18 @@ describe('fontdueEndpoint', () => {
|
|
|
127
152
|
origin: 'http://app:4000'
|
|
128
153
|
});
|
|
129
154
|
const {
|
|
130
|
-
|
|
155
|
+
endpointForDomain
|
|
131
156
|
} = await importTenant();
|
|
132
|
-
expect(
|
|
157
|
+
expect(endpointForDomain('acme.fontdue.com').headers).toEqual({
|
|
133
158
|
'x-forwarded-host': 'acme.fontdue.com'
|
|
134
159
|
});
|
|
135
160
|
});
|
|
136
161
|
it('multi-tenant: falls back to the tenant public URL without FONTDUE_ORIGIN', async () => {
|
|
137
162
|
stubMultiTenant();
|
|
138
163
|
const {
|
|
139
|
-
|
|
164
|
+
endpointForDomain
|
|
140
165
|
} = await importTenant();
|
|
141
|
-
expect(
|
|
166
|
+
expect(endpointForDomain('acme.fontdue.com').origin).toBe('https://acme.fontdue.com');
|
|
142
167
|
});
|
|
143
168
|
});
|
|
144
169
|
describe('configureFontdueRender', () => {
|
|
@@ -150,10 +175,10 @@ describe('configureFontdueRender', () => {
|
|
|
150
175
|
configureFontdueRender
|
|
151
176
|
} = await importTenant();
|
|
152
177
|
const {
|
|
153
|
-
|
|
178
|
+
getFontdueSlotConfig
|
|
154
179
|
} = await import("../relay/serverConfig.js");
|
|
155
180
|
expect(configureFontdueRender('not a domain')).toBeNull();
|
|
156
|
-
expect(
|
|
181
|
+
expect(getFontdueSlotConfig()).toBeUndefined();
|
|
157
182
|
});
|
|
158
183
|
it('sets the per-render server config and returns the endpoint', async () => {
|
|
159
184
|
stubMultiTenant({
|
|
@@ -163,9 +188,6 @@ describe('configureFontdueRender', () => {
|
|
|
163
188
|
const {
|
|
164
189
|
configureFontdueRender
|
|
165
190
|
} = await importTenant();
|
|
166
|
-
const {
|
|
167
|
-
getFontdueServerConfig
|
|
168
|
-
} = await import("../relay/serverConfig.js");
|
|
169
191
|
const endpoint = configureFontdueRender('acme.fontdue.com');
|
|
170
192
|
expect(endpoint === null || endpoint === void 0 ? void 0 : endpoint.origin).toBe('http://app:4000');
|
|
171
193
|
// Outside an RSC render React.cache doesn't memoize, so the write is a
|
|
@@ -185,7 +207,7 @@ describe('configureFontdueRender', () => {
|
|
|
185
207
|
});
|
|
186
208
|
});
|
|
187
209
|
});
|
|
188
|
-
describe('
|
|
210
|
+
describe('__prepareFontdueRender', () => {
|
|
189
211
|
const props = params => ({
|
|
190
212
|
params: Promise.resolve(params)
|
|
191
213
|
});
|
|
@@ -194,9 +216,9 @@ describe('prepareFontdueRender', () => {
|
|
|
194
216
|
origin: 'http://app:4000'
|
|
195
217
|
});
|
|
196
218
|
const {
|
|
197
|
-
|
|
219
|
+
__prepareFontdueRender
|
|
198
220
|
} = await importTenant();
|
|
199
|
-
const endpoint = await
|
|
221
|
+
const endpoint = await __prepareFontdueRender(props({
|
|
200
222
|
domain: 'acme.fontdue.com',
|
|
201
223
|
slug: 'sans'
|
|
202
224
|
}));
|
|
@@ -206,18 +228,18 @@ describe('prepareFontdueRender', () => {
|
|
|
206
228
|
it('404s invalid or missing domains', async () => {
|
|
207
229
|
stubMultiTenant();
|
|
208
230
|
const {
|
|
209
|
-
|
|
231
|
+
__prepareFontdueRender
|
|
210
232
|
} = await importTenant();
|
|
211
|
-
await expect(
|
|
233
|
+
await expect(__prepareFontdueRender(props({
|
|
212
234
|
domain: 'not a domain'
|
|
213
235
|
}))).rejects.toThrow('NEXT_NOT_FOUND');
|
|
214
|
-
await expect(
|
|
236
|
+
await expect(__prepareFontdueRender(props({
|
|
215
237
|
slug: 'sans'
|
|
216
238
|
}))).rejects.toThrow('NEXT_NOT_FOUND');
|
|
217
239
|
});
|
|
218
240
|
|
|
219
241
|
// React.cache doesn't memoize outside an RSC render (see configureFontdueRender
|
|
220
|
-
// above), so the slot write is a no-op here — capture what
|
|
242
|
+
// above), so the slot write is a no-op here — capture what __prepareFontdueRender
|
|
221
243
|
// passes to setFontdueServerConfig instead.
|
|
222
244
|
async function captureRenderConfig(params) {
|
|
223
245
|
let captured;
|
|
@@ -228,9 +250,9 @@ describe('prepareFontdueRender', () => {
|
|
|
228
250
|
}
|
|
229
251
|
}));
|
|
230
252
|
const {
|
|
231
|
-
|
|
253
|
+
__prepareFontdueRender
|
|
232
254
|
} = await importTenant();
|
|
233
|
-
await
|
|
255
|
+
await __prepareFontdueRender(props(params));
|
|
234
256
|
vi.doUnmock('../relay/serverConfig');
|
|
235
257
|
return captured;
|
|
236
258
|
}
|
|
@@ -261,22 +283,154 @@ describe('prepareFontdueRender', () => {
|
|
|
261
283
|
expect(config.cacheTags).toBeUndefined();
|
|
262
284
|
});
|
|
263
285
|
});
|
|
264
|
-
describe('
|
|
286
|
+
describe('configureFontduePreview (single-tenant)', () => {
|
|
287
|
+
async function captureConfig() {
|
|
288
|
+
let captured;
|
|
289
|
+
vi.doMock('../relay/serverConfig', async importActual => ({
|
|
290
|
+
...(await importActual()),
|
|
291
|
+
setFontdueServerConfig: c => {
|
|
292
|
+
captured = c;
|
|
293
|
+
}
|
|
294
|
+
}));
|
|
295
|
+
const {
|
|
296
|
+
configureFontduePreview
|
|
297
|
+
} = await importTenant();
|
|
298
|
+
const endpoint = await configureFontduePreview();
|
|
299
|
+
vi.doUnmock('../relay/serverConfig');
|
|
300
|
+
return {
|
|
301
|
+
captured,
|
|
302
|
+
endpoint
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
it('public render: configures the NEXT_PUBLIC_FONTDUE_URL site with cache tags', async () => {
|
|
306
|
+
var _captured$headers;
|
|
307
|
+
stubSingleTenant('https://acme.fontdue.com');
|
|
308
|
+
const {
|
|
309
|
+
captured,
|
|
310
|
+
endpoint
|
|
311
|
+
} = await captureConfig();
|
|
312
|
+
expect(endpoint.origin).toBe('https://acme.fontdue.com');
|
|
313
|
+
expect(captured.url).toBe('https://acme.fontdue.com');
|
|
314
|
+
expect(captured.cacheTags).toEqual(['graphql:acme.fontdue.com']);
|
|
315
|
+
expect((_captured$headers = captured.headers) === null || _captured$headers === void 0 ? void 0 : _captured$headers.authorization).toBeUndefined();
|
|
316
|
+
});
|
|
317
|
+
it('preview render: forwards the token and drops cache tags', async () => {
|
|
318
|
+
var _captured$headers2;
|
|
319
|
+
stubSingleTenant('https://acme.fontdue.com');
|
|
320
|
+
draft.enabled = true;
|
|
321
|
+
draft.token = 'admin-tok';
|
|
322
|
+
const {
|
|
323
|
+
captured
|
|
324
|
+
} = await captureConfig();
|
|
325
|
+
expect((_captured$headers2 = captured.headers) === null || _captured$headers2 === void 0 ? void 0 : _captured$headers2.authorization).toBe('Bearer admin-tok');
|
|
326
|
+
expect(captured.cacheTags).toBeUndefined();
|
|
327
|
+
});
|
|
328
|
+
it('throws a helpful error when NEXT_PUBLIC_FONTDUE_URL is unset', async () => {
|
|
329
|
+
stubMultiTenant();
|
|
330
|
+
const {
|
|
331
|
+
configureFontduePreview
|
|
332
|
+
} = await importTenant();
|
|
333
|
+
await expect(configureFontduePreview()).rejects.toThrow(/NEXT_PUBLIC_FONTDUE_URL/);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
describe('single-tenant ambient resolver (no per-render call)', () => {
|
|
337
|
+
// Importing registerSingleTenantResolver registers the resolver as a module
|
|
338
|
+
// side effect — the FontdueProvider RSC entrypoint does this import, so
|
|
339
|
+
// merely mounting the provider wires it up. No
|
|
340
|
+
// configureFontduePreview()/__prepareFontdueRender() call is made in any of
|
|
341
|
+
// these tests; the config is pulled at fetch time.
|
|
342
|
+
it('public render: feeds the env URL + cache tags, no token', async () => {
|
|
343
|
+
var _config$headers3;
|
|
344
|
+
stubSingleTenant('https://acme.fontdue.com');
|
|
345
|
+
await importRegister();
|
|
346
|
+
const {
|
|
347
|
+
resolveFontdueServerConfig
|
|
348
|
+
} = await import("../relay/serverConfig.js");
|
|
349
|
+
const config = await resolveFontdueServerConfig();
|
|
350
|
+
expect(config === null || config === void 0 ? void 0 : config.url).toBe('https://acme.fontdue.com');
|
|
351
|
+
expect(config === null || config === void 0 ? void 0 : config.cacheTags).toEqual(['graphql:acme.fontdue.com']);
|
|
352
|
+
expect(config === null || config === void 0 ? void 0 : (_config$headers3 = config.headers) === null || _config$headers3 === void 0 ? void 0 : _config$headers3.authorization).toBeUndefined();
|
|
353
|
+
});
|
|
354
|
+
it('preview render: folds in the admin token and drops cache tags', async () => {
|
|
355
|
+
var _config$headers4;
|
|
356
|
+
stubSingleTenant('https://acme.fontdue.com');
|
|
357
|
+
draft.enabled = true;
|
|
358
|
+
draft.token = 'admin-tok';
|
|
359
|
+
await importRegister();
|
|
360
|
+
const {
|
|
361
|
+
resolveFontdueServerConfig
|
|
362
|
+
} = await import("../relay/serverConfig.js");
|
|
363
|
+
const config = await resolveFontdueServerConfig();
|
|
364
|
+
// This is what reveals hidden fonts in an embedded component's own preload
|
|
365
|
+
// on a page that never calls the app's GraphQL fetcher.
|
|
366
|
+
expect(config === null || config === void 0 ? void 0 : (_config$headers4 = config.headers) === null || _config$headers4 === void 0 ? void 0 : _config$headers4.authorization).toBe('Bearer admin-tok');
|
|
367
|
+
expect(config === null || config === void 0 ? void 0 : config.cacheTags).toBeUndefined();
|
|
368
|
+
});
|
|
369
|
+
it('multi-tenant: resolver stays out of the way so the slot drives config', async () => {
|
|
370
|
+
stubMultiTenant({
|
|
371
|
+
origin: 'http://app:4000'
|
|
372
|
+
});
|
|
373
|
+
await importRegister();
|
|
374
|
+
const {
|
|
375
|
+
resolveFontdueServerConfig
|
|
376
|
+
} = await import("../relay/serverConfig.js");
|
|
377
|
+
expect(await resolveFontdueServerConfig()).toBeUndefined();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// Regression for FD-712: a preview render that begins as a static/prerender
|
|
381
|
+
// pass must NOT swallow Next's dynamic bailout — re-throwing it is what takes
|
|
382
|
+
// the route off the full-route cache so the embeds' preloads re-run with the
|
|
383
|
+
// token. If this were swallowed, every server fetch (including a <BuyButton>
|
|
384
|
+
// node(id) @required(THROW) preload) would silently get the public view.
|
|
385
|
+
it('preview render: propagates Next’s dynamic bailout instead of dropping the token', async () => {
|
|
386
|
+
stubSingleTenant('https://acme.fontdue.com');
|
|
387
|
+
draft.enabled = true;
|
|
388
|
+
draft.token = 'admin-tok';
|
|
389
|
+
draft.cookiesError = Object.assign(new Error('Dynamic server usage'), {
|
|
390
|
+
digest: 'DYNAMIC_SERVER_USAGE'
|
|
391
|
+
});
|
|
392
|
+
await importRegister();
|
|
393
|
+
const {
|
|
394
|
+
resolveFontdueServerConfig
|
|
395
|
+
} = await import("../relay/serverConfig.js");
|
|
396
|
+
await expect(resolveFontdueServerConfig()).rejects.toBe(draft.cookiesError);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// A non-control-flow throw (e.g. no request scope at all) is not a bailout,
|
|
400
|
+
// so it's swallowed and the render degrades to the public (token-less) config
|
|
401
|
+
// rather than crashing.
|
|
402
|
+
it('preview render: swallows a non-control-flow throw and falls back to public', async () => {
|
|
403
|
+
var _config$headers5;
|
|
404
|
+
stubSingleTenant('https://acme.fontdue.com');
|
|
405
|
+
draft.enabled = true;
|
|
406
|
+
draft.token = 'admin-tok';
|
|
407
|
+
draft.cookiesError = new Error('no request scope');
|
|
408
|
+
await importRegister();
|
|
409
|
+
const {
|
|
410
|
+
resolveFontdueServerConfig
|
|
411
|
+
} = await import("../relay/serverConfig.js");
|
|
412
|
+
const config = await resolveFontdueServerConfig();
|
|
413
|
+
expect(config === null || config === void 0 ? void 0 : config.url).toBe('https://acme.fontdue.com');
|
|
414
|
+
expect(config === null || config === void 0 ? void 0 : config.cacheTags).toEqual(['graphql:acme.fontdue.com']);
|
|
415
|
+
expect(config === null || config === void 0 ? void 0 : (_config$headers5 = config.headers) === null || _config$headers5 === void 0 ? void 0 : _config$headers5.authorization).toBeUndefined();
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
describe('fontdueEndpoint', () => {
|
|
265
419
|
it('multi-tenant: throws when no render config was set', async () => {
|
|
266
420
|
stubMultiTenant({
|
|
267
421
|
origin: 'http://app:4000'
|
|
268
422
|
});
|
|
269
423
|
const {
|
|
270
|
-
|
|
424
|
+
fontdueEndpoint
|
|
271
425
|
} = await importTenant();
|
|
272
|
-
expect(() =>
|
|
426
|
+
expect(() => fontdueEndpoint()).toThrow(/__prepareFontdueRender/);
|
|
273
427
|
});
|
|
274
428
|
it('single-tenant: derives the endpoint from NEXT_PUBLIC_FONTDUE_URL', async () => {
|
|
275
429
|
stubSingleTenant('https://acme.fontdue.com');
|
|
276
430
|
const {
|
|
277
|
-
|
|
431
|
+
fontdueEndpoint
|
|
278
432
|
} = await importTenant();
|
|
279
|
-
expect(
|
|
433
|
+
expect(fontdueEndpoint()).toEqual({
|
|
280
434
|
domain: 'acme.fontdue.com',
|
|
281
435
|
origin: 'https://acme.fontdue.com',
|
|
282
436
|
headers: {},
|
|
@@ -289,26 +443,17 @@ describe('currentFontdueEndpoint', () => {
|
|
|
289
443
|
});
|
|
290
444
|
vi.doMock('../relay/serverConfig', async importActual => ({
|
|
291
445
|
...(await importActual()),
|
|
292
|
-
|
|
446
|
+
getFontdueSlotConfig: () => ({
|
|
293
447
|
domain: 'acme.fontdue.com'
|
|
294
448
|
})
|
|
295
449
|
}));
|
|
296
450
|
const {
|
|
297
|
-
|
|
451
|
+
fontdueEndpoint
|
|
298
452
|
} = await importTenant();
|
|
299
|
-
expect(
|
|
453
|
+
expect(fontdueEndpoint().headers['x-forwarded-host']).toBe('acme.fontdue.com');
|
|
300
454
|
vi.doUnmock('../relay/serverConfig');
|
|
301
455
|
});
|
|
302
456
|
});
|
|
303
|
-
describe('generateStaticParams', () => {
|
|
304
|
-
it('returns no build-time params (everything renders on demand)', async () => {
|
|
305
|
-
stubMultiTenant();
|
|
306
|
-
const {
|
|
307
|
-
generateStaticParams
|
|
308
|
-
} = await importTenant();
|
|
309
|
-
await expect(generateStaticParams()).resolves.toEqual([]);
|
|
310
|
-
});
|
|
311
|
-
});
|
|
312
457
|
|
|
313
458
|
// withFontdue detects the route-tree shape from the working directory; give
|
|
314
459
|
// it one with or without src/app/[domain].
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// The render-scoped slot and the ambient resolver are anchored on globalThis so
|
|
4
|
+
// they survive this module being bundled into more than one Next.js server
|
|
5
|
+
// chunk (the app/provider chunk vs. a per-embed chunk), which would otherwise
|
|
6
|
+
// give each chunk its own copy of the module-level state. Re-importing the
|
|
7
|
+
// module after vi.resetModules() faithfully simulates that second chunk: a
|
|
8
|
+
// fresh module instance with its own scope but the same globalThis.
|
|
9
|
+
const STORE_KEY = '__fontdueServerConfigStore__';
|
|
10
|
+
function clearStore() {
|
|
11
|
+
delete globalThis[STORE_KEY];
|
|
12
|
+
}
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
vi.resetModules();
|
|
15
|
+
clearStore();
|
|
16
|
+
});
|
|
17
|
+
afterEach(clearStore);
|
|
18
|
+
describe('serverConfig store (chunk-duplication safety, FD-712)', () => {
|
|
19
|
+
it('shares the ambient resolver across module instances', async () => {
|
|
20
|
+
var _await$a$resolveFontd, _await$a$resolveFontd2, _await$b$resolveFontd, _await$b$resolveFontd2;
|
|
21
|
+
// First "chunk": register a resolver (what fontdue-js/next does when
|
|
22
|
+
// <FontdueProvider> mounts in single-tenant mode).
|
|
23
|
+
const a = await import("../relay/serverConfig.js");
|
|
24
|
+
a.registerAmbientConfigResolver(async () => ({
|
|
25
|
+
url: 'https://acme.fontdue.com',
|
|
26
|
+
headers: {
|
|
27
|
+
authorization: 'Bearer admin-tok'
|
|
28
|
+
}
|
|
29
|
+
}));
|
|
30
|
+
expect((_await$a$resolveFontd = await a.resolveFontdueServerConfig()) === null || _await$a$resolveFontd === void 0 ? void 0 : (_await$a$resolveFontd2 = _await$a$resolveFontd.headers) === null || _await$a$resolveFontd2 === void 0 ? void 0 : _await$a$resolveFontd2.authorization).toBe('Bearer admin-tok');
|
|
31
|
+
|
|
32
|
+
// Second "chunk": a fresh module instance. Before the fix its module-level
|
|
33
|
+
// `ambientResolver` was undefined here, so an embed preloading from this
|
|
34
|
+
// chunk fetched without the preview token and a hidden-font reveal failed.
|
|
35
|
+
vi.resetModules();
|
|
36
|
+
const b = await import("../relay/serverConfig.js");
|
|
37
|
+
expect(b).not.toBe(a); // genuinely a re-evaluated module
|
|
38
|
+
expect((_await$b$resolveFontd = await b.resolveFontdueServerConfig()) === null || _await$b$resolveFontd === void 0 ? void 0 : (_await$b$resolveFontd2 = _await$b$resolveFontd.headers) === null || _await$b$resolveFontd2 === void 0 ? void 0 : _await$b$resolveFontd2.authorization).toBe('Bearer admin-tok');
|
|
39
|
+
});
|
|
40
|
+
it('reuses one shared store object across re-imports', async () => {
|
|
41
|
+
const a = await import("../relay/serverConfig.js");
|
|
42
|
+
// The store is created lazily on first use, not on import.
|
|
43
|
+
a.registerAmbientConfigResolver(() => undefined);
|
|
44
|
+
const first = globalThis[STORE_KEY];
|
|
45
|
+
expect(first).toBeDefined();
|
|
46
|
+
vi.resetModules();
|
|
47
|
+
const b = await import("../relay/serverConfig.js");
|
|
48
|
+
await b.resolveFontdueServerConfig();
|
|
49
|
+
const second = globalThis[STORE_KEY];
|
|
50
|
+
|
|
51
|
+
// Same store (slot factory + resolver), not a per-instance copy — so the
|
|
52
|
+
// React.cache-backed slot multi-tenant uses (setFontdueServerConfig via
|
|
53
|
+
// __prepareFontdueRender) is one shared cell every chunk reads/writes.
|
|
54
|
+
expect(second).toBe(first);
|
|
55
|
+
});
|
|
56
|
+
it('starts with no resolver (resolution unchanged until something registers)', async () => {
|
|
57
|
+
const {
|
|
58
|
+
resolveFontdueServerConfig
|
|
59
|
+
} = await import("../relay/serverConfig.js");
|
|
60
|
+
expect(await resolveFontdueServerConfig()).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -25,6 +25,7 @@ interface TrackingConfig {
|
|
|
25
25
|
}
|
|
26
26
|
interface PreviewConfig {
|
|
27
27
|
endpoint?: string;
|
|
28
|
+
revalidateEndpoint?: string;
|
|
28
29
|
}
|
|
29
30
|
export interface Config {
|
|
30
31
|
form?: FormConfig;
|
|
@@ -108,6 +109,7 @@ export declare const makeConfig: (config?: Config) => {
|
|
|
108
109
|
};
|
|
109
110
|
preview: {
|
|
110
111
|
endpoint: string;
|
|
112
|
+
revalidateEndpoint: string | undefined;
|
|
111
113
|
};
|
|
112
114
|
corsErrorModal: boolean;
|
|
113
115
|
};
|
|
@@ -183,6 +185,7 @@ declare const _default: React.Context<{
|
|
|
183
185
|
};
|
|
184
186
|
preview: {
|
|
185
187
|
endpoint: string;
|
|
188
|
+
revalidateEndpoint: string | undefined;
|
|
186
189
|
};
|
|
187
190
|
corsErrorModal: boolean;
|
|
188
191
|
}>;
|
|
@@ -80,7 +80,7 @@ export const mergeConfig = (base, override) => {
|
|
|
80
80
|
return deepMerge(base, override);
|
|
81
81
|
};
|
|
82
82
|
export const makeConfig = config => {
|
|
83
|
-
var _config$form, _config$storeModal, _config$storeModal2, _config$storeModal3, _config$storeModal4, _config$stripe, _config$tracking, _config$tracking2, _config$tracking3, _config$tracking4, _config$preview;
|
|
83
|
+
var _config$form, _config$storeModal, _config$storeModal2, _config$storeModal3, _config$storeModal4, _config$stripe, _config$tracking, _config$tracking2, _config$tracking3, _config$tracking4, _config$preview, _config$preview2;
|
|
84
84
|
return {
|
|
85
85
|
typeTester: makeTypeTesterConfig(config === null || config === void 0 ? void 0 : config.typeTester),
|
|
86
86
|
form: {
|
|
@@ -103,7 +103,10 @@ export const makeConfig = config => {
|
|
|
103
103
|
segment: config === null || config === void 0 ? void 0 : (_config$tracking4 = config.tracking) === null || _config$tracking4 === void 0 ? void 0 : _config$tracking4.segment
|
|
104
104
|
},
|
|
105
105
|
preview: {
|
|
106
|
-
endpoint: (config === null || config === void 0 ? void 0 : (_config$preview = config.preview) === null || _config$preview === void 0 ? void 0 : _config$preview.endpoint) ?? PREVIEW_ENDPOINT
|
|
106
|
+
endpoint: (config === null || config === void 0 ? void 0 : (_config$preview = config.preview) === null || _config$preview === void 0 ? void 0 : _config$preview.endpoint) ?? PREVIEW_ENDPOINT,
|
|
107
|
+
// No default: an unset revalidate endpoint hides the toolbar's refresh
|
|
108
|
+
// button (frameworks without a client-callable purge leave it unset).
|
|
109
|
+
revalidateEndpoint: config === null || config === void 0 ? void 0 : (_config$preview2 = config.preview) === null || _config$preview2 === void 0 ? void 0 : _config$preview2.revalidateEndpoint
|
|
107
110
|
},
|
|
108
111
|
corsErrorModal: (config === null || config === void 0 ? void 0 : config.corsErrorModal) ?? true
|
|
109
112
|
};
|
|
@@ -5,7 +5,8 @@ import _FontdueAdminToolbarQuery from "../../__generated__/FontdueAdminToolbarQu
|
|
|
5
5
|
import React, { useContext, useEffect, useState } from 'react';
|
|
6
6
|
import { commitMutation, fetchQuery, graphql, useRelayEnvironment } from 'react-relay';
|
|
7
7
|
import ConfigContext from '../ConfigContext.js';
|
|
8
|
-
import { PREVIEW_ENDPOINT,
|
|
8
|
+
import { PREVIEW_ENDPOINT, hasPreviewMarkerCookie } from '../../preview/constants.js';
|
|
9
|
+
import { fontdueBaseUrl, version } from '../../relay/environment.js';
|
|
9
10
|
// Admin-only affordance for logged-in foundry admins: reveal unpublished
|
|
10
11
|
// ("hidden") fonts across the whole storefront. Storefront pages are
|
|
11
12
|
// server-rendered sessionless and cached, and the provider's own query is
|
|
@@ -22,28 +23,31 @@ const adminQuery = (_FontdueAdminToolbarQuery.hash && _FontdueAdminToolbarQuery.
|
|
|
22
23
|
// createAdminToken mints a short-lived, stateless admin token from the admin
|
|
23
24
|
// session; it's handed to the preview route to enter draft mode.
|
|
24
25
|
const tokenMutation = (_FontdueAdminToolbarTokenMutation.hash && _FontdueAdminToolbarTokenMutation.hash !== "2b82f195747c86d50fd5884d76f3b709" && console.error("The definition of 'FontdueAdminToolbarTokenMutation' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _FontdueAdminToolbarTokenMutation);
|
|
25
|
-
|
|
26
|
-
// The preview entry/exit route and the readable marker cookie are part of the
|
|
27
|
-
// portable preview contract — see fontdue-js/preview. The route path defaults
|
|
28
|
-
// to '/api/preview' but is configurable via FontdueConfig.preview.endpoint so
|
|
29
|
-
// non-Next apps can mount it wherever their router expects (the Next adapter's
|
|
30
|
-
// draft-mode route, an Astro APIRoute, an RR7 action — all honor the same
|
|
31
|
-
// POST-token-to-enter / DELETE-to-exit contract).
|
|
32
|
-
function hasPreviewCookie() {
|
|
33
|
-
return typeof document !== 'undefined' && document.cookie.split('; ').some(c => c === `${PREVIEW_MARKER_COOKIE}=1`);
|
|
34
|
-
}
|
|
35
26
|
export default function FontdueAdminToolbar() {
|
|
36
|
-
var _useContext$preview;
|
|
37
27
|
const environment = useRelayEnvironment();
|
|
38
|
-
const
|
|
28
|
+
const previewConfig = useContext(ConfigContext).preview;
|
|
29
|
+
// The preview entry/exit route is part of the portable preview contract (see
|
|
30
|
+
// fontdue-js/preview); it defaults to '/api/preview' but is configurable so
|
|
31
|
+
// non-Next apps can mount it where their router expects. The revalidate route
|
|
32
|
+
// is opt-in (no default) — see config.preview.revalidateEndpoint.
|
|
33
|
+
const previewEndpoint = (previewConfig === null || previewConfig === void 0 ? void 0 : previewConfig.endpoint) ?? PREVIEW_ENDPOINT;
|
|
34
|
+
const revalidateEndpoint = previewConfig === null || previewConfig === void 0 ? void 0 : previewConfig.revalidateEndpoint;
|
|
35
|
+
|
|
36
|
+
// Where the admin is signed in: the configured Fontdue origin. Used for the
|
|
37
|
+
// "signed in at" line and the deep link back to the admin. Undefined (e.g.
|
|
38
|
+
// multi-tenant) just drops both gracefully.
|
|
39
|
+
const fontdueUrl = fontdueBaseUrl();
|
|
40
|
+
const fontdueHost = fontdueUrl ? safeHost(fontdueUrl) : null;
|
|
41
|
+
const adminUrl = fontdueUrl ? `${fontdueUrl.replace(/\/+$/, '')}/admin` : null;
|
|
39
42
|
const [adminName, setAdminName] = useState(null);
|
|
40
43
|
const [ready, setReady] = useState(false);
|
|
41
44
|
const [open, setOpen] = useState(false);
|
|
42
45
|
const [previewing, setPreviewing] = useState(false);
|
|
43
46
|
const [busy, setBusy] = useState(false);
|
|
44
47
|
const [error, setError] = useState(null);
|
|
48
|
+
const [notice, setNotice] = useState(null);
|
|
45
49
|
useEffect(() => {
|
|
46
|
-
setPreviewing(
|
|
50
|
+
setPreviewing(hasPreviewMarkerCookie());
|
|
47
51
|
// network-only: the provider's preloaded query already wrote a sessionless
|
|
48
52
|
// `viewer` to the store, so we must go to the network (with the session)
|
|
49
53
|
// rather than read that cached, admin-blind copy.
|
|
@@ -83,6 +87,7 @@ export default function FontdueAdminToolbar() {
|
|
|
83
87
|
function enterPreview() {
|
|
84
88
|
setBusy(true);
|
|
85
89
|
setError(null);
|
|
90
|
+
setNotice(null);
|
|
86
91
|
commitMutation(environment, {
|
|
87
92
|
mutation: tokenMutation,
|
|
88
93
|
variables: {},
|
|
@@ -105,11 +110,32 @@ export default function FontdueAdminToolbar() {
|
|
|
105
110
|
async function exitPreview() {
|
|
106
111
|
setBusy(true);
|
|
107
112
|
setError(null);
|
|
113
|
+
setNotice(null);
|
|
108
114
|
await fetch(previewEndpoint, {
|
|
109
115
|
method: 'DELETE'
|
|
110
116
|
});
|
|
111
117
|
window.location.reload();
|
|
112
118
|
}
|
|
119
|
+
|
|
120
|
+
// Purge the cached storefront so public visitors see freshly published (or
|
|
121
|
+
// newly hidden) content. Targets the configured revalidate route as-is.
|
|
122
|
+
async function refreshCache() {
|
|
123
|
+
if (!revalidateEndpoint) return;
|
|
124
|
+
setBusy(true);
|
|
125
|
+
setError(null);
|
|
126
|
+
setNotice(null);
|
|
127
|
+
try {
|
|
128
|
+
const res = await fetch(revalidateEndpoint, {
|
|
129
|
+
method: 'POST'
|
|
130
|
+
});
|
|
131
|
+
if (!res.ok) throw new Error(`status ${res.status}`);
|
|
132
|
+
setNotice('Public cache refreshed.');
|
|
133
|
+
} catch {
|
|
134
|
+
setError('Could not refresh the cache.');
|
|
135
|
+
} finally {
|
|
136
|
+
setBusy(false);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
113
139
|
return /*#__PURE__*/React.createElement("div", {
|
|
114
140
|
className: "fontdue-admin-toolbar",
|
|
115
141
|
"data-previewing": previewing,
|
|
@@ -132,9 +158,29 @@ export default function FontdueAdminToolbar() {
|
|
|
132
158
|
"data-testid": "preview-toggle"
|
|
133
159
|
}), /*#__PURE__*/React.createElement("span", null, "Preview hidden fonts")), /*#__PURE__*/React.createElement("p", {
|
|
134
160
|
className: "fontdue-admin-toolbar__hint"
|
|
135
|
-
}, previewing ? 'Unpublished fonts are visible across the site — only to you.' : 'Reveal unpublished fonts everywhere, only for you.'),
|
|
161
|
+
}, previewing ? 'Unpublished fonts are visible across the site — only to you.' : 'Reveal unpublished fonts everywhere, only for you.'), (revalidateEndpoint || adminUrl) && /*#__PURE__*/React.createElement("div", {
|
|
162
|
+
className: "fontdue-admin-toolbar__actions"
|
|
163
|
+
}, revalidateEndpoint && /*#__PURE__*/React.createElement("button", {
|
|
164
|
+
type: "button",
|
|
165
|
+
className: "fontdue-admin-toolbar__action",
|
|
166
|
+
onClick: refreshCache,
|
|
167
|
+
disabled: busy,
|
|
168
|
+
"data-testid": "revalidate-button"
|
|
169
|
+
}, "Refresh public cache"), adminUrl && /*#__PURE__*/React.createElement("a", {
|
|
170
|
+
className: "fontdue-admin-toolbar__action",
|
|
171
|
+
href: adminUrl,
|
|
172
|
+
target: "_blank",
|
|
173
|
+
rel: "noreferrer",
|
|
174
|
+
"data-testid": "open-admin-link"
|
|
175
|
+
}, "Open Fontdue admin \u2197")), notice && /*#__PURE__*/React.createElement("p", {
|
|
176
|
+
className: "fontdue-admin-toolbar__notice"
|
|
177
|
+
}, notice), error && /*#__PURE__*/React.createElement("p", {
|
|
136
178
|
className: "fontdue-admin-toolbar__error"
|
|
137
|
-
}, error)
|
|
179
|
+
}, error), /*#__PURE__*/React.createElement("p", {
|
|
180
|
+
className: "fontdue-admin-toolbar__meta"
|
|
181
|
+
}, fontdueHost ? `Shown because you’re signed in to Fontdue at ${fontdueHost}.` : 'Shown because you’re signed in to Fontdue.', /*#__PURE__*/React.createElement("span", {
|
|
182
|
+
className: "fontdue-admin-toolbar__version"
|
|
183
|
+
}, "fontdue-js ", version))), /*#__PURE__*/React.createElement("button", {
|
|
138
184
|
type: "button",
|
|
139
185
|
className: "fontdue-admin-toolbar__button",
|
|
140
186
|
onClick: () => setOpen(v => !v),
|
|
@@ -144,4 +190,14 @@ export default function FontdueAdminToolbar() {
|
|
|
144
190
|
className: "fontdue-admin-toolbar__dot",
|
|
145
191
|
"aria-hidden": "true"
|
|
146
192
|
}), "Fontdue", previewing ? ' · previewing' : ''));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Hostname of a base URL, or the raw value if it can't be parsed (so a
|
|
196
|
+
// misconfigured URL still shows something rather than throwing).
|
|
197
|
+
function safeHost(url) {
|
|
198
|
+
try {
|
|
199
|
+
return new URL(url).host;
|
|
200
|
+
} catch {
|
|
201
|
+
return url;
|
|
202
|
+
}
|
|
147
203
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import type { FontdueProvider_props } from './index.js';
|
|
3
|
+
import '../../next/registerSingleTenantResolver.js';
|
|
3
4
|
export type { FontdueProvider_props } from './index.js';
|
|
4
5
|
export type { FontdueProviderPreloadedQuery } from '../../loadFontdueProviderQuery.js';
|
|
5
6
|
export type { FontdueServerConfig } from '../../relay/serverConfig.js';
|