fontdue-js 3.0.0-alpha11 → 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 +14 -0
- package/README.md +144 -17
- package/dist/__tests__/createFontdueFetch.test.js +154 -3
- package/dist/__tests__/networkFetch.test.js +81 -2
- package/dist/__tests__/nextAdapter.test.js +249 -40
- 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 +122 -49
- 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 +15 -3
- package/dist/server/index.js +77 -31
- package/package.json +1 -1
- package/types/next-headers.d.ts +9 -0
- 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,17 +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
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
// Draft mode + the preview token cookie, controllable per test. Default: not
|
|
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).
|
|
48
|
+
const draft = vi.hoisted(() => ({
|
|
49
|
+
enabled: false,
|
|
50
|
+
token: undefined,
|
|
51
|
+
cookiesError: undefined
|
|
52
|
+
}));
|
|
53
|
+
vi.mock('next/headers', () => ({
|
|
54
|
+
draftMode: async () => ({
|
|
55
|
+
isEnabled: draft.enabled
|
|
56
|
+
}),
|
|
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
|
+
};
|
|
27
64
|
}
|
|
28
65
|
}));
|
|
29
66
|
beforeEach(() => {
|
|
30
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__;
|
|
31
72
|
revalidateTag.mockClear();
|
|
32
73
|
vi.unstubAllEnvs();
|
|
33
74
|
vi.restoreAllMocks();
|
|
75
|
+
draft.enabled = false;
|
|
76
|
+
draft.token = undefined;
|
|
77
|
+
draft.cookiesError = undefined;
|
|
34
78
|
});
|
|
35
79
|
function stubSingleTenant() {
|
|
36
80
|
let url = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 'https://acme.fontdue.com';
|
|
@@ -64,13 +108,13 @@ describe('isValidDomain', () => {
|
|
|
64
108
|
expect(isValidDomain('a'.repeat(254) + '.example')).toBe(false);
|
|
65
109
|
});
|
|
66
110
|
});
|
|
67
|
-
describe('
|
|
111
|
+
describe('endpointForDomain', () => {
|
|
68
112
|
it('single-tenant: targets NEXT_PUBLIC_FONTDUE_URL with no headers', async () => {
|
|
69
113
|
stubSingleTenant('https://acme.fontdue.com');
|
|
70
114
|
const {
|
|
71
|
-
|
|
115
|
+
endpointForDomain
|
|
72
116
|
} = await importTenant();
|
|
73
|
-
expect(
|
|
117
|
+
expect(endpointForDomain('acme.fontdue.com')).toEqual({
|
|
74
118
|
domain: 'acme.fontdue.com',
|
|
75
119
|
origin: 'https://acme.fontdue.com',
|
|
76
120
|
headers: {},
|
|
@@ -81,9 +125,9 @@ describe('fontdueEndpoint', () => {
|
|
|
81
125
|
vi.stubEnv('NEXT_PUBLIC_FONTDUE_URL', '');
|
|
82
126
|
vi.stubEnv('FONTDUE_MULTI_TENANT', '');
|
|
83
127
|
const {
|
|
84
|
-
|
|
128
|
+
endpointForDomain
|
|
85
129
|
} = await importTenant();
|
|
86
|
-
expect(() =>
|
|
130
|
+
expect(() => endpointForDomain('acme.fontdue.com')).toThrow(/NEXT_PUBLIC_FONTDUE_URL/);
|
|
87
131
|
});
|
|
88
132
|
it('multi-tenant: forwards the host to FONTDUE_ORIGIN with the proxy secret', async () => {
|
|
89
133
|
stubMultiTenant({
|
|
@@ -91,9 +135,9 @@ describe('fontdueEndpoint', () => {
|
|
|
91
135
|
secret: 's3cret'
|
|
92
136
|
});
|
|
93
137
|
const {
|
|
94
|
-
|
|
138
|
+
endpointForDomain
|
|
95
139
|
} = await importTenant();
|
|
96
|
-
expect(
|
|
140
|
+
expect(endpointForDomain('acme.fontdue.com')).toEqual({
|
|
97
141
|
domain: 'acme.fontdue.com',
|
|
98
142
|
origin: 'http://app:4000',
|
|
99
143
|
headers: {
|
|
@@ -108,18 +152,18 @@ describe('fontdueEndpoint', () => {
|
|
|
108
152
|
origin: 'http://app:4000'
|
|
109
153
|
});
|
|
110
154
|
const {
|
|
111
|
-
|
|
155
|
+
endpointForDomain
|
|
112
156
|
} = await importTenant();
|
|
113
|
-
expect(
|
|
157
|
+
expect(endpointForDomain('acme.fontdue.com').headers).toEqual({
|
|
114
158
|
'x-forwarded-host': 'acme.fontdue.com'
|
|
115
159
|
});
|
|
116
160
|
});
|
|
117
161
|
it('multi-tenant: falls back to the tenant public URL without FONTDUE_ORIGIN', async () => {
|
|
118
162
|
stubMultiTenant();
|
|
119
163
|
const {
|
|
120
|
-
|
|
164
|
+
endpointForDomain
|
|
121
165
|
} = await importTenant();
|
|
122
|
-
expect(
|
|
166
|
+
expect(endpointForDomain('acme.fontdue.com').origin).toBe('https://acme.fontdue.com');
|
|
123
167
|
});
|
|
124
168
|
});
|
|
125
169
|
describe('configureFontdueRender', () => {
|
|
@@ -131,10 +175,10 @@ describe('configureFontdueRender', () => {
|
|
|
131
175
|
configureFontdueRender
|
|
132
176
|
} = await importTenant();
|
|
133
177
|
const {
|
|
134
|
-
|
|
178
|
+
getFontdueSlotConfig
|
|
135
179
|
} = await import("../relay/serverConfig.js");
|
|
136
180
|
expect(configureFontdueRender('not a domain')).toBeNull();
|
|
137
|
-
expect(
|
|
181
|
+
expect(getFontdueSlotConfig()).toBeUndefined();
|
|
138
182
|
});
|
|
139
183
|
it('sets the per-render server config and returns the endpoint', async () => {
|
|
140
184
|
stubMultiTenant({
|
|
@@ -144,9 +188,6 @@ describe('configureFontdueRender', () => {
|
|
|
144
188
|
const {
|
|
145
189
|
configureFontdueRender
|
|
146
190
|
} = await importTenant();
|
|
147
|
-
const {
|
|
148
|
-
getFontdueServerConfig
|
|
149
|
-
} = await import("../relay/serverConfig.js");
|
|
150
191
|
const endpoint = configureFontdueRender('acme.fontdue.com');
|
|
151
192
|
expect(endpoint === null || endpoint === void 0 ? void 0 : endpoint.origin).toBe('http://app:4000');
|
|
152
193
|
// Outside an RSC render React.cache doesn't memoize, so the write is a
|
|
@@ -166,7 +207,7 @@ describe('configureFontdueRender', () => {
|
|
|
166
207
|
});
|
|
167
208
|
});
|
|
168
209
|
});
|
|
169
|
-
describe('
|
|
210
|
+
describe('__prepareFontdueRender', () => {
|
|
170
211
|
const props = params => ({
|
|
171
212
|
params: Promise.resolve(params)
|
|
172
213
|
});
|
|
@@ -175,9 +216,9 @@ describe('prepareFontdueRender', () => {
|
|
|
175
216
|
origin: 'http://app:4000'
|
|
176
217
|
});
|
|
177
218
|
const {
|
|
178
|
-
|
|
219
|
+
__prepareFontdueRender
|
|
179
220
|
} = await importTenant();
|
|
180
|
-
const endpoint = await
|
|
221
|
+
const endpoint = await __prepareFontdueRender(props({
|
|
181
222
|
domain: 'acme.fontdue.com',
|
|
182
223
|
slug: 'sans'
|
|
183
224
|
}));
|
|
@@ -187,32 +228,209 @@ describe('prepareFontdueRender', () => {
|
|
|
187
228
|
it('404s invalid or missing domains', async () => {
|
|
188
229
|
stubMultiTenant();
|
|
189
230
|
const {
|
|
190
|
-
|
|
231
|
+
__prepareFontdueRender
|
|
191
232
|
} = await importTenant();
|
|
192
|
-
await expect(
|
|
233
|
+
await expect(__prepareFontdueRender(props({
|
|
193
234
|
domain: 'not a domain'
|
|
194
235
|
}))).rejects.toThrow('NEXT_NOT_FOUND');
|
|
195
|
-
await expect(
|
|
236
|
+
await expect(__prepareFontdueRender(props({
|
|
196
237
|
slug: 'sans'
|
|
197
238
|
}))).rejects.toThrow('NEXT_NOT_FOUND');
|
|
198
239
|
});
|
|
240
|
+
|
|
241
|
+
// React.cache doesn't memoize outside an RSC render (see configureFontdueRender
|
|
242
|
+
// above), so the slot write is a no-op here — capture what __prepareFontdueRender
|
|
243
|
+
// passes to setFontdueServerConfig instead.
|
|
244
|
+
async function captureRenderConfig(params) {
|
|
245
|
+
let captured;
|
|
246
|
+
vi.doMock('../relay/serverConfig', async importActual => ({
|
|
247
|
+
...(await importActual()),
|
|
248
|
+
setFontdueServerConfig: c => {
|
|
249
|
+
captured = c;
|
|
250
|
+
}
|
|
251
|
+
}));
|
|
252
|
+
const {
|
|
253
|
+
__prepareFontdueRender
|
|
254
|
+
} = await importTenant();
|
|
255
|
+
await __prepareFontdueRender(props(params));
|
|
256
|
+
vi.doUnmock('../relay/serverConfig');
|
|
257
|
+
return captured;
|
|
258
|
+
}
|
|
259
|
+
it('public render: sets the tenant config with cache tags and no token', async () => {
|
|
260
|
+
var _config$headers;
|
|
261
|
+
stubMultiTenant({
|
|
262
|
+
origin: 'http://app:4000'
|
|
263
|
+
});
|
|
264
|
+
const config = await captureRenderConfig({
|
|
265
|
+
domain: 'acme.fontdue.com'
|
|
266
|
+
});
|
|
267
|
+
expect(config.cacheTags).toEqual(['graphql:acme.fontdue.com']);
|
|
268
|
+
expect((_config$headers = config.headers) === null || _config$headers === void 0 ? void 0 : _config$headers.authorization).toBeUndefined();
|
|
269
|
+
});
|
|
270
|
+
it('preview render: folds the token into headers and drops cache tags', async () => {
|
|
271
|
+
var _config$headers2;
|
|
272
|
+
stubMultiTenant({
|
|
273
|
+
origin: 'http://app:4000'
|
|
274
|
+
});
|
|
275
|
+
draft.enabled = true;
|
|
276
|
+
draft.token = 'admin-tok';
|
|
277
|
+
const config = await captureRenderConfig({
|
|
278
|
+
domain: 'acme.fontdue.com'
|
|
279
|
+
});
|
|
280
|
+
// Embeds (createNetworkFetch) and the app's fetch (createFontdueFetch) both
|
|
281
|
+
// read this, so both reveal hidden fonts; no tags keeps the render live.
|
|
282
|
+
expect((_config$headers2 = config.headers) === null || _config$headers2 === void 0 ? void 0 : _config$headers2.authorization).toBe('Bearer admin-tok');
|
|
283
|
+
expect(config.cacheTags).toBeUndefined();
|
|
284
|
+
});
|
|
199
285
|
});
|
|
200
|
-
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', () => {
|
|
201
419
|
it('multi-tenant: throws when no render config was set', async () => {
|
|
202
420
|
stubMultiTenant({
|
|
203
421
|
origin: 'http://app:4000'
|
|
204
422
|
});
|
|
205
423
|
const {
|
|
206
|
-
|
|
424
|
+
fontdueEndpoint
|
|
207
425
|
} = await importTenant();
|
|
208
|
-
expect(() =>
|
|
426
|
+
expect(() => fontdueEndpoint()).toThrow(/__prepareFontdueRender/);
|
|
209
427
|
});
|
|
210
428
|
it('single-tenant: derives the endpoint from NEXT_PUBLIC_FONTDUE_URL', async () => {
|
|
211
429
|
stubSingleTenant('https://acme.fontdue.com');
|
|
212
430
|
const {
|
|
213
|
-
|
|
431
|
+
fontdueEndpoint
|
|
214
432
|
} = await importTenant();
|
|
215
|
-
expect(
|
|
433
|
+
expect(fontdueEndpoint()).toEqual({
|
|
216
434
|
domain: 'acme.fontdue.com',
|
|
217
435
|
origin: 'https://acme.fontdue.com',
|
|
218
436
|
headers: {},
|
|
@@ -225,26 +443,17 @@ describe('currentFontdueEndpoint', () => {
|
|
|
225
443
|
});
|
|
226
444
|
vi.doMock('../relay/serverConfig', async importActual => ({
|
|
227
445
|
...(await importActual()),
|
|
228
|
-
|
|
446
|
+
getFontdueSlotConfig: () => ({
|
|
229
447
|
domain: 'acme.fontdue.com'
|
|
230
448
|
})
|
|
231
449
|
}));
|
|
232
450
|
const {
|
|
233
|
-
|
|
451
|
+
fontdueEndpoint
|
|
234
452
|
} = await importTenant();
|
|
235
|
-
expect(
|
|
453
|
+
expect(fontdueEndpoint().headers['x-forwarded-host']).toBe('acme.fontdue.com');
|
|
236
454
|
vi.doUnmock('../relay/serverConfig');
|
|
237
455
|
});
|
|
238
456
|
});
|
|
239
|
-
describe('generateStaticParams', () => {
|
|
240
|
-
it('returns no build-time params (everything renders on demand)', async () => {
|
|
241
|
-
stubMultiTenant();
|
|
242
|
-
const {
|
|
243
|
-
generateStaticParams
|
|
244
|
-
} = await importTenant();
|
|
245
|
-
await expect(generateStaticParams()).resolves.toEqual([]);
|
|
246
|
-
});
|
|
247
|
-
});
|
|
248
457
|
|
|
249
458
|
// withFontdue detects the route-tree shape from the working directory; give
|
|
250
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
|
}
|