fontdue-js 3.0.0-alpha9 → 3.0.0

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.
Files changed (135) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +182 -13
  3. package/dist/__generated__/CartOrderCompleteOrderMutation.graphql.d.ts +1 -1
  4. package/dist/__generated__/CartOrderCompleteOrderMutation.graphql.js +9 -3
  5. package/dist/__generated__/CartOrderRemoveDiscountMutation.graphql.d.ts +1 -1
  6. package/dist/__generated__/CartOrderRemoveDiscountMutation.graphql.js +9 -3
  7. package/dist/__generated__/CartOrderUpdateMutation.graphql.d.ts +1 -1
  8. package/dist/__generated__/CartOrderUpdateMutation.graphql.js +9 -3
  9. package/dist/__generated__/CartQuery.graphql.d.ts +1 -1
  10. package/dist/__generated__/CartQuery.graphql.js +9 -3
  11. package/dist/__generated__/CartStateUpdateMutation.graphql.d.ts +1 -1
  12. package/dist/__generated__/CartStateUpdateMutation.graphql.js +9 -3
  13. package/dist/__generated__/CharacterViewerIDQuery.graphql.d.ts +1 -1
  14. package/dist/__generated__/CharacterViewerIDQuery.graphql.js +9 -3
  15. package/dist/__generated__/CharacterViewerSlugQuery.graphql.d.ts +1 -1
  16. package/dist/__generated__/CharacterViewerSlugQuery.graphql.js +9 -3
  17. package/dist/__generated__/CharacterViewerStyleRefetchQuery.graphql.d.ts +1 -1
  18. package/dist/__generated__/CharacterViewerStyleRefetchQuery.graphql.js +9 -3
  19. package/dist/__generated__/CheckoutUpdateCustomerMutation.graphql.d.ts +1 -1
  20. package/dist/__generated__/CheckoutUpdateCustomerMutation.graphql.js +9 -3
  21. package/dist/__generated__/CheckoutUpdateOrderMutation.graphql.d.ts +1 -1
  22. package/dist/__generated__/CheckoutUpdateOrderMutation.graphql.js +9 -3
  23. package/dist/__generated__/CollectionAa_Query.graphql.d.ts +1 -1
  24. package/dist/__generated__/CollectionAa_Query.graphql.js +9 -3
  25. package/dist/__generated__/FontFamiliesQuery.graphql.d.ts +1 -1
  26. package/dist/__generated__/FontFamiliesQuery.graphql.js +9 -3
  27. package/dist/__generated__/FontdueAdminToolbarQuery.graphql.d.ts +20 -0
  28. package/dist/__generated__/FontdueAdminToolbarQuery.graphql.js +80 -0
  29. package/dist/__generated__/FontdueAdminToolbarTokenMutation.graphql.d.ts +18 -0
  30. package/dist/__generated__/FontdueAdminToolbarTokenMutation.graphql.js +56 -0
  31. package/dist/__generated__/PrecartAddToCartMutation.graphql.d.ts +1 -1
  32. package/dist/__generated__/PrecartAddToCartMutation.graphql.js +9 -3
  33. package/dist/__generated__/StoreModalCartQuery.graphql.d.ts +1 -1
  34. package/dist/__generated__/StoreModalCartQuery.graphql.js +9 -3
  35. package/dist/__generated__/StoreModalContainerQuery.graphql.d.ts +1 -1
  36. package/dist/__generated__/StoreModalContainerQuery.graphql.js +9 -3
  37. package/dist/__generated__/StoreModalIndexQuery.graphql.d.ts +1 -1
  38. package/dist/__generated__/StoreModalIndexQuery.graphql.js +9 -3
  39. package/dist/__generated__/StoreModalProductQuery.graphql.d.ts +1 -1
  40. package/dist/__generated__/StoreModalProductQuery.graphql.js +9 -3
  41. package/dist/__generated__/StoreModalProductRefetchQuery.graphql.d.ts +1 -1
  42. package/dist/__generated__/StoreModalProductRefetchQuery.graphql.js +9 -3
  43. package/dist/__generated__/TestFontsFormUpdateCustomerMutation.graphql.d.ts +1 -1
  44. package/dist/__generated__/TestFontsFormUpdateCustomerMutation.graphql.js +9 -3
  45. package/dist/__generated__/TypeTesterStandaloneChangedStylesQuery.graphql.d.ts +1 -1
  46. package/dist/__generated__/TypeTesterStandaloneChangedStylesQuery.graphql.js +9 -3
  47. package/dist/__generated__/TypeTesterStandaloneQuery.graphql.d.ts +1 -1
  48. package/dist/__generated__/TypeTesterStandaloneQuery.graphql.js +9 -3
  49. package/dist/__generated__/TypeTestersChangedStylesQuery.graphql.d.ts +1 -1
  50. package/dist/__generated__/TypeTestersChangedStylesQuery.graphql.js +9 -3
  51. package/dist/__generated__/TypeTestersIDQuery.graphql.d.ts +1 -1
  52. package/dist/__generated__/TypeTestersIDQuery.graphql.js +9 -3
  53. package/dist/__generated__/TypeTestersRefetchQuery.graphql.d.ts +1 -1
  54. package/dist/__generated__/TypeTestersRefetchQuery.graphql.js +9 -3
  55. package/dist/__generated__/TypeTestersSlugQuery.graphql.d.ts +1 -1
  56. package/dist/__generated__/TypeTestersSlugQuery.graphql.js +9 -3
  57. package/dist/__generated__/useFontStyle_fontStyle.graphql.d.ts +2 -1
  58. package/dist/__generated__/useFontStyle_fontStyle.graphql.js +8 -2
  59. package/dist/__tests__/createFontdueFetch.test.js +276 -0
  60. package/dist/__tests__/imageLoader.test.js +62 -0
  61. package/dist/__tests__/metricFallback.test.js +74 -0
  62. package/dist/__tests__/networkFetch.test.js +125 -3
  63. package/dist/__tests__/nextAdapter.test.js +175 -60
  64. package/dist/__tests__/preview.test.js +217 -0
  65. package/dist/__tests__/previewServer.test.js +118 -0
  66. package/dist/__tests__/previewState.test.js +63 -0
  67. package/dist/__tests__/serverConfig.test.js +62 -0
  68. package/dist/components/BuyButton/index.d.ts +2 -2
  69. package/dist/components/BuyButton/index.js +3 -3
  70. package/dist/components/CharacterViewer/index.d.ts +2 -2
  71. package/dist/components/CharacterViewer/index.js +20 -11
  72. package/dist/components/ConfigContext.d.ts +21 -2
  73. package/dist/components/ConfigContext.js +12 -2
  74. package/dist/components/ConnectionErrorToolbar.d.ts +1 -0
  75. package/dist/components/ConnectionErrorToolbar.js +106 -0
  76. package/dist/components/FontdueAdminToolbar/index.d.ts +2 -0
  77. package/dist/components/FontdueAdminToolbar/index.js +299 -0
  78. package/dist/components/FontdueAdminToolbar/previewState.d.ts +7 -0
  79. package/dist/components/FontdueAdminToolbar/previewState.js +58 -0
  80. package/dist/components/FontdueContextProvider/index.js +4 -2
  81. package/dist/components/FontdueProvider/index.js +6 -1
  82. package/dist/components/FontdueProvider/index.server.d.ts +1 -0
  83. package/dist/components/FontdueProvider/index.server.js +10 -0
  84. package/dist/components/NewsletterSignup/index.d.ts +2 -2
  85. package/dist/components/NewsletterSignup/index.js +2 -2
  86. package/dist/components/Root/index.js +16 -2
  87. package/dist/components/TestFontsForm/index.d.ts +2 -2
  88. package/dist/components/TestFontsForm/index.js +2 -2
  89. package/dist/components/TypeTester/TypeTesterStandalone.d.ts +2 -2
  90. package/dist/components/TypeTester/TypeTesterStandalone.js +2 -2
  91. package/dist/components/TypeTesters/index.d.ts +2 -2
  92. package/dist/components/TypeTesters/index.js +3 -3
  93. package/dist/components/useFontStyle.d.ts +1 -0
  94. package/dist/components/useFontStyle.js +12 -3
  95. package/dist/corsError.d.ts +1 -5
  96. package/dist/corsError.js +23 -13
  97. package/dist/data/unicodeNamesUrl.d.ts +2 -0
  98. package/dist/data/unicodeNamesUrl.js +18 -0
  99. package/dist/data/unicodeNamesVersion.d.ts +1 -0
  100. package/dist/data/unicodeNamesVersion.js +4 -0
  101. package/dist/fallbackFontData.d.ts +2 -0
  102. package/dist/fallbackFontData.js +10 -0
  103. package/dist/fontdue.css +231 -4
  104. package/dist/loadFontdueProviderQuery.d.ts +2 -1
  105. package/dist/loadFontdueProviderQuery.js +5 -2
  106. package/dist/metricFallback.d.ts +48 -0
  107. package/dist/metricFallback.js +98 -0
  108. package/dist/next/image-loader.js +22 -3
  109. package/dist/next/index.d.ts +1 -2
  110. package/dist/next/index.js +14 -6
  111. package/dist/next/registerSingleTenantResolver.d.ts +1 -0
  112. package/dist/next/registerSingleTenantResolver.js +35 -0
  113. package/dist/next/revalidate.js +1 -1
  114. package/dist/next/tenant.d.ts +4 -4
  115. package/dist/next/tenant.js +89 -58
  116. package/dist/preview/constants.d.ts +9 -0
  117. package/dist/preview/constants.js +117 -0
  118. package/dist/preview/index.d.ts +53 -0
  119. package/dist/preview/index.js +190 -0
  120. package/dist/preview/server.d.ts +20 -0
  121. package/dist/preview/server.js +89 -0
  122. package/dist/relay/environment.d.ts +8 -0
  123. package/dist/relay/environment.js +81 -35
  124. package/dist/relay/loadSerializableQuery.d.ts +13 -3
  125. package/dist/relay/loadSerializableQuery.js +2 -0
  126. package/dist/relay/serverConfig.d.ts +5 -7
  127. package/dist/relay/serverConfig.js +83 -8
  128. package/dist/scripts/publishUnicodeData.js +68 -0
  129. package/dist/scripts/updateUnicodeData.js +41 -6
  130. package/dist/server/index.d.ts +37 -0
  131. package/dist/server/index.js +160 -0
  132. package/package.json +5 -1
  133. package/types/next-headers.d.ts +9 -0
  134. package/types/next-navigation.d.ts +4 -0
  135. 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('fontdueEndpoint', () => {
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
- fontdueEndpoint
115
+ endpointForDomain
72
116
  } = await importTenant();
73
- expect(fontdueEndpoint('acme.fontdue.com')).toEqual({
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
- fontdueEndpoint
128
+ endpointForDomain
85
129
  } = await importTenant();
86
- expect(() => fontdueEndpoint('acme.fontdue.com')).toThrow(/NEXT_PUBLIC_FONTDUE_URL/);
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
- fontdueEndpoint
138
+ endpointForDomain
95
139
  } = await importTenant();
96
- expect(fontdueEndpoint('acme.fontdue.com')).toEqual({
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
- fontdueEndpoint
155
+ endpointForDomain
112
156
  } = await importTenant();
113
- expect(fontdueEndpoint('acme.fontdue.com').headers).toEqual({
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
- fontdueEndpoint
164
+ endpointForDomain
121
165
  } = await importTenant();
122
- expect(fontdueEndpoint('acme.fontdue.com').origin).toBe('https://acme.fontdue.com');
166
+ expect(endpointForDomain('acme.fontdue.com').origin).toBe('https://acme.fontdue.com');
123
167
  });
124
168
  });
125
169
  describe('configureFontdueRender', () => {
@@ -130,11 +174,8 @@ describe('configureFontdueRender', () => {
130
174
  const {
131
175
  configureFontdueRender
132
176
  } = await importTenant();
133
- const {
134
- getFontdueServerConfig
135
- } = await import("../relay/serverConfig.js");
177
+ // Invalid domain returns null before any setFontdueServerConfig call.
136
178
  expect(configureFontdueRender('not a domain')).toBeNull();
137
- expect(getFontdueServerConfig()).toBeUndefined();
138
179
  });
139
180
  it('sets the per-render server config and returns the endpoint', async () => {
140
181
  stubMultiTenant({
@@ -144,9 +185,6 @@ describe('configureFontdueRender', () => {
144
185
  const {
145
186
  configureFontdueRender
146
187
  } = await importTenant();
147
- const {
148
- getFontdueServerConfig
149
- } = await import("../relay/serverConfig.js");
150
188
  const endpoint = configureFontdueRender('acme.fontdue.com');
151
189
  expect(endpoint === null || endpoint === void 0 ? void 0 : endpoint.origin).toBe('http://app:4000');
152
190
  // Outside an RSC render React.cache doesn't memoize, so the write is a
@@ -161,12 +199,11 @@ describe('configureFontdueRender', () => {
161
199
  'x-forwarded-host': 'acme.fontdue.com',
162
200
  'x-fontdue-proxy-secret': 's3cret'
163
201
  },
164
- cacheTags: ['graphql:acme.fontdue.com'],
165
- domain: 'acme.fontdue.com'
202
+ cacheTags: ['graphql:acme.fontdue.com']
166
203
  });
167
204
  });
168
205
  });
169
- describe('prepareFontdueRender', () => {
206
+ describe('__prepareFontdueRender', () => {
170
207
  const props = params => ({
171
208
  params: Promise.resolve(params)
172
209
  });
@@ -175,9 +212,9 @@ describe('prepareFontdueRender', () => {
175
212
  origin: 'http://app:4000'
176
213
  });
177
214
  const {
178
- prepareFontdueRender
215
+ __prepareFontdueRender
179
216
  } = await importTenant();
180
- const endpoint = await prepareFontdueRender(props({
217
+ const endpoint = await __prepareFontdueRender(props({
181
218
  domain: 'acme.fontdue.com',
182
219
  slug: 'sans'
183
220
  }));
@@ -187,62 +224,140 @@ describe('prepareFontdueRender', () => {
187
224
  it('404s invalid or missing domains', async () => {
188
225
  stubMultiTenant();
189
226
  const {
190
- prepareFontdueRender
227
+ __prepareFontdueRender
191
228
  } = await importTenant();
192
- await expect(prepareFontdueRender(props({
229
+ await expect(__prepareFontdueRender(props({
193
230
  domain: 'not a domain'
194
231
  }))).rejects.toThrow('NEXT_NOT_FOUND');
195
- await expect(prepareFontdueRender(props({
232
+ await expect(__prepareFontdueRender(props({
196
233
  slug: 'sans'
197
234
  }))).rejects.toThrow('NEXT_NOT_FOUND');
198
235
  });
199
- });
200
- describe('currentFontdueEndpoint', () => {
201
- it('multi-tenant: throws when no render config was set', async () => {
236
+
237
+ // React.cache doesn't memoize outside an RSC render (see configureFontdueRender
238
+ // above), so the slot write is a no-op here capture what __prepareFontdueRender
239
+ // passes to setFontdueServerConfig instead.
240
+ async function captureRenderConfig(params) {
241
+ let captured;
242
+ vi.doMock('../relay/serverConfig', async importActual => ({
243
+ ...(await importActual()),
244
+ setFontdueServerConfig: c => {
245
+ captured = c;
246
+ }
247
+ }));
248
+ const {
249
+ __prepareFontdueRender
250
+ } = await importTenant();
251
+ await __prepareFontdueRender(props(params));
252
+ vi.doUnmock('../relay/serverConfig');
253
+ return captured;
254
+ }
255
+ it('public render: sets the tenant config with cache tags and no token', async () => {
256
+ var _config$headers;
202
257
  stubMultiTenant({
203
258
  origin: 'http://app:4000'
204
259
  });
260
+ const config = await captureRenderConfig({
261
+ domain: 'acme.fontdue.com'
262
+ });
263
+ expect(config.cacheTags).toEqual(['graphql:acme.fontdue.com']);
264
+ expect((_config$headers = config.headers) === null || _config$headers === void 0 ? void 0 : _config$headers.authorization).toBeUndefined();
265
+ });
266
+ it('preview render: folds the token into headers and drops cache tags', async () => {
267
+ var _config$headers2;
268
+ stubMultiTenant({
269
+ origin: 'http://app:4000'
270
+ });
271
+ draft.enabled = true;
272
+ draft.token = 'admin-tok';
273
+ const config = await captureRenderConfig({
274
+ domain: 'acme.fontdue.com'
275
+ });
276
+ // Embeds (createNetworkFetch) and the app's fetch (createFontdueFetch) both
277
+ // read this, so both reveal hidden fonts; no tags keeps the render live.
278
+ expect((_config$headers2 = config.headers) === null || _config$headers2 === void 0 ? void 0 : _config$headers2.authorization).toBe('Bearer admin-tok');
279
+ expect(config.cacheTags).toBeUndefined();
280
+ });
281
+ });
282
+ describe('single-tenant ambient resolver (no per-render call)', () => {
283
+ // Importing registerSingleTenantResolver registers the resolver as a module
284
+ // side effect — the FontdueProvider RSC entrypoint does this import, so
285
+ // merely mounting the provider wires it up. No per-render setup call is made
286
+ // in any of these tests; the config is pulled at fetch time.
287
+ it('public render: feeds the env URL + cache tags, no token', async () => {
288
+ var _config$headers3;
289
+ stubSingleTenant('https://acme.fontdue.com');
290
+ await importRegister();
205
291
  const {
206
- currentFontdueEndpoint
207
- } = await importTenant();
208
- expect(() => currentFontdueEndpoint()).toThrow(/prepareFontdueRender/);
292
+ resolveFontdueServerConfig
293
+ } = await import("../relay/serverConfig.js");
294
+ const config = await resolveFontdueServerConfig();
295
+ expect(config === null || config === void 0 ? void 0 : config.url).toBe('https://acme.fontdue.com');
296
+ expect(config === null || config === void 0 ? void 0 : config.cacheTags).toEqual(['graphql:acme.fontdue.com']);
297
+ expect(config === null || config === void 0 ? void 0 : (_config$headers3 = config.headers) === null || _config$headers3 === void 0 ? void 0 : _config$headers3.authorization).toBeUndefined();
209
298
  });
210
- it('single-tenant: derives the endpoint from NEXT_PUBLIC_FONTDUE_URL', async () => {
299
+ it('preview render: folds in the admin token and drops cache tags', async () => {
300
+ var _config$headers4;
211
301
  stubSingleTenant('https://acme.fontdue.com');
302
+ draft.enabled = true;
303
+ draft.token = 'admin-tok';
304
+ await importRegister();
212
305
  const {
213
- currentFontdueEndpoint
214
- } = await importTenant();
215
- expect(currentFontdueEndpoint()).toEqual({
216
- domain: 'acme.fontdue.com',
217
- origin: 'https://acme.fontdue.com',
218
- headers: {},
219
- tags: ['graphql', 'graphql:acme.fontdue.com']
220
- });
306
+ resolveFontdueServerConfig
307
+ } = await import("../relay/serverConfig.js");
308
+ const config = await resolveFontdueServerConfig();
309
+ // This is what reveals hidden fonts in an embedded component's own preload
310
+ // on a page that never calls the app's GraphQL fetcher.
311
+ 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');
312
+ expect(config === null || config === void 0 ? void 0 : config.cacheTags).toBeUndefined();
221
313
  });
222
- it('uses the render-configured domain when set', async () => {
314
+ it('multi-tenant: resolver stays out of the way so the slot drives config', async () => {
223
315
  stubMultiTenant({
224
316
  origin: 'http://app:4000'
225
317
  });
226
- vi.doMock('../relay/serverConfig', async importActual => ({
227
- ...(await importActual()),
228
- getFontdueServerConfig: () => ({
229
- domain: 'acme.fontdue.com'
230
- })
231
- }));
318
+ await importRegister();
232
319
  const {
233
- currentFontdueEndpoint
234
- } = await importTenant();
235
- expect(currentFontdueEndpoint().headers['x-forwarded-host']).toBe('acme.fontdue.com');
236
- vi.doUnmock('../relay/serverConfig');
320
+ resolveFontdueServerConfig
321
+ } = await import("../relay/serverConfig.js");
322
+ expect(await resolveFontdueServerConfig()).toBeUndefined();
237
323
  });
238
- });
239
- describe('generateStaticParams', () => {
240
- it('returns no build-time params (everything renders on demand)', async () => {
241
- stubMultiTenant();
324
+
325
+ // Regression for FD-712: a preview render that begins as a static/prerender
326
+ // pass must NOT swallow Next's dynamic bailout re-throwing it is what takes
327
+ // the route off the full-route cache so the embeds' preloads re-run with the
328
+ // token. If this were swallowed, every server fetch (including a <BuyButton>
329
+ // node(id) @required(THROW) preload) would silently get the public view.
330
+ it('preview render: propagates Next’s dynamic bailout instead of dropping the token', async () => {
331
+ stubSingleTenant('https://acme.fontdue.com');
332
+ draft.enabled = true;
333
+ draft.token = 'admin-tok';
334
+ draft.cookiesError = Object.assign(new Error('Dynamic server usage'), {
335
+ digest: 'DYNAMIC_SERVER_USAGE'
336
+ });
337
+ await importRegister();
242
338
  const {
243
- generateStaticParams
244
- } = await importTenant();
245
- await expect(generateStaticParams()).resolves.toEqual([]);
339
+ resolveFontdueServerConfig
340
+ } = await import("../relay/serverConfig.js");
341
+ await expect(resolveFontdueServerConfig()).rejects.toBe(draft.cookiesError);
342
+ });
343
+
344
+ // A non-control-flow throw (e.g. no request scope at all) is not a bailout,
345
+ // so it's swallowed and the render degrades to the public (token-less) config
346
+ // rather than crashing.
347
+ it('preview render: swallows a non-control-flow throw and falls back to public', async () => {
348
+ var _config$headers5;
349
+ stubSingleTenant('https://acme.fontdue.com');
350
+ draft.enabled = true;
351
+ draft.token = 'admin-tok';
352
+ draft.cookiesError = new Error('no request scope');
353
+ await importRegister();
354
+ const {
355
+ resolveFontdueServerConfig
356
+ } = await import("../relay/serverConfig.js");
357
+ const config = await resolveFontdueServerConfig();
358
+ expect(config === null || config === void 0 ? void 0 : config.url).toBe('https://acme.fontdue.com');
359
+ expect(config === null || config === void 0 ? void 0 : config.cacheTags).toEqual(['graphql:acme.fontdue.com']);
360
+ expect(config === null || config === void 0 ? void 0 : (_config$headers5 = config.headers) === null || _config$headers5 === void 0 ? void 0 : _config$headers5.authorization).toBeUndefined();
246
361
  });
247
362
  });
248
363
 
@@ -0,0 +1,217 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { handlePreviewRequest, readPreviewToken, previewAuthHeaders, PREVIEW_TOKEN_COOKIE, PREVIEW_MARKER_COOKIE } from '../preview/index.js';
3
+ import { setPreviewMarkerCookie, hasPreviewMarkerCookie, getPreviewExpiry } from '../preview/constants.js';
4
+ function postPreview(token) {
5
+ let url = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'https://site.test/api/preview';
6
+ return handlePreviewRequest(new Request(url, {
7
+ method: 'POST',
8
+ headers: {
9
+ 'content-type': 'application/json'
10
+ },
11
+ body: JSON.stringify({
12
+ token
13
+ })
14
+ }));
15
+ }
16
+ describe('handlePreviewRequest', () => {
17
+ it('enters preview: sets an httpOnly token cookie + a readable marker', async () => {
18
+ const before = Date.now();
19
+ const res = await postPreview('admin.tok.123');
20
+ expect(res.status).toBe(200);
21
+ const body = await res.json();
22
+ expect(body.ok).toBe(true);
23
+ expect(body.preview).toBe(true);
24
+ // No expiresAt in the body → defaults to now + the 1h TTL.
25
+ expect(body.expiresAt).toBeGreaterThanOrEqual(before + 60 * 60 * 1000 - 5_000);
26
+ const cookies = res.headers.getSetCookie();
27
+ const tokenCookie = cookies.find(c => c.startsWith(`${PREVIEW_TOKEN_COOKIE}=`));
28
+ const markerCookie = cookies.find(c => c.startsWith(`${PREVIEW_MARKER_COOKIE}=`));
29
+ expect(tokenCookie).toContain(`${PREVIEW_TOKEN_COOKIE}=admin.tok.123`);
30
+ expect(tokenCookie).toContain('HttpOnly');
31
+ expect(tokenCookie).toContain('SameSite=Lax');
32
+ expect(tokenCookie).toContain('Path=/');
33
+
34
+ // The marker now carries the token's expiry (epoch-ms), and outlives the
35
+ // token via a grace window so the toolbar can reach the "expired" warning.
36
+ expect(markerCookie).toContain(`${PREVIEW_MARKER_COOKIE}=${body.expiresAt}`);
37
+ expect(markerCookie).toMatch(/Max-Age=\d+/);
38
+ // The marker must be readable by the client toolbar.
39
+ expect(markerCookie).not.toContain('HttpOnly');
40
+ });
41
+ it('threads an explicit expiresAt (ISO + epoch-ms) into the marker + body', async () => {
42
+ const iso = '2030-01-01T00:00:00.000Z';
43
+ const epoch = Date.parse(iso);
44
+ const fromIso = await handlePreviewRequest(new Request('https://site.test/api/preview', {
45
+ method: 'POST',
46
+ headers: {
47
+ 'content-type': 'application/json'
48
+ },
49
+ body: JSON.stringify({
50
+ token: 't',
51
+ expiresAt: iso
52
+ })
53
+ }));
54
+ expect((await fromIso.json()).expiresAt).toBe(epoch);
55
+ const fromEpoch = await handlePreviewRequest(new Request('https://site.test/api/preview', {
56
+ method: 'POST',
57
+ headers: {
58
+ 'content-type': 'application/json'
59
+ },
60
+ body: JSON.stringify({
61
+ token: 't',
62
+ expiresAt: epoch
63
+ })
64
+ }));
65
+ expect((await fromEpoch.json()).expiresAt).toBe(epoch);
66
+ const marker = fromEpoch.headers.getSetCookie().find(c => c.startsWith(`${PREVIEW_MARKER_COOKIE}=`));
67
+ expect(marker).toContain(`${PREVIEW_MARKER_COOKIE}=${epoch}`);
68
+ });
69
+ it('marks cookies Secure on https and not on http (local dev)', async () => {
70
+ const httpsCookies = (await postPreview('t')).headers.getSetCookie();
71
+ expect(httpsCookies.every(c => c.includes('Secure'))).toBe(true);
72
+ const httpCookies = (await postPreview('t', 'http://localhost:4321/api/preview')).headers.getSetCookie();
73
+ expect(httpCookies.some(c => c.includes('Secure'))).toBe(false);
74
+ });
75
+ it('rejects a POST with no token', async () => {
76
+ const res = await postPreview('');
77
+ expect(res.status).toBe(400);
78
+ expect(res.headers.getSetCookie()).toHaveLength(0);
79
+ });
80
+ it('exits preview: clears both cookies', async () => {
81
+ const res = await handlePreviewRequest(new Request('https://site.test/api/preview', {
82
+ method: 'DELETE'
83
+ }));
84
+ expect(res.status).toBe(200);
85
+ expect(await res.json()).toEqual({
86
+ ok: true,
87
+ preview: false
88
+ });
89
+ const cookies = res.headers.getSetCookie();
90
+ expect(cookies).toHaveLength(2);
91
+ // Expiry via Max-Age=0 clears the cookie.
92
+ expect(cookies.every(c => c.includes('Max-Age=0'))).toBe(true);
93
+ });
94
+ it('rejects unsupported methods', async () => {
95
+ const res = await handlePreviewRequest(new Request('https://site.test/api/preview', {
96
+ method: 'GET'
97
+ }));
98
+ expect(res.status).toBe(405);
99
+ });
100
+ it('round-trips a token with cookie-unsafe characters', async () => {
101
+ const token = 'tok+en/with=specials';
102
+ const res = await postPreview(token);
103
+ const setCookie = res.headers.getSetCookie().find(c => c.startsWith(`${PREVIEW_TOKEN_COOKIE}=`));
104
+ // Reconstruct the Cookie header the browser would send back (name=value).
105
+ const cookieHeader = setCookie.split(';')[0];
106
+ expect(readPreviewToken(cookieHeader)).toBe(token);
107
+ });
108
+ });
109
+ describe('readPreviewToken', () => {
110
+ it('extracts the token from a multi-cookie header', () => {
111
+ expect(readPreviewToken(`a=1; ${PREVIEW_TOKEN_COOKIE}=abc; ${PREVIEW_MARKER_COOKIE}=1`)).toBe('abc');
112
+ });
113
+ it('returns undefined when there is no token cookie', () => {
114
+ expect(readPreviewToken('a=1; b=2')).toBeUndefined();
115
+ expect(readPreviewToken('')).toBeUndefined();
116
+ expect(readPreviewToken(null)).toBeUndefined();
117
+ expect(readPreviewToken(undefined)).toBeUndefined();
118
+ });
119
+ });
120
+ describe('setPreviewMarkerCookie (client-only embeds)', () => {
121
+ afterEach(() => vi.unstubAllGlobals());
122
+
123
+ // Captures the last `document.cookie = ...` write through a setter, the way
124
+ // the browser would receive it.
125
+ function stubBrowser(protocol) {
126
+ const state = {
127
+ written: ''
128
+ };
129
+ vi.stubGlobal('document', {
130
+ set cookie(value) {
131
+ state.written = value;
132
+ },
133
+ get cookie() {
134
+ return state.written;
135
+ }
136
+ });
137
+ vi.stubGlobal('location', {
138
+ protocol
139
+ });
140
+ return state;
141
+ }
142
+ it('is a no-op during server rendering (no document)', () => {
143
+ // The node test env has no `document`, so this must stay inert, not throw.
144
+ expect(() => setPreviewMarkerCookie(true)).not.toThrow();
145
+ });
146
+ it('sets a JS-readable marker carrying the expiry, Secure on https', () => {
147
+ const state = stubBrowser('https:');
148
+ const expiresAt = Date.now() + 60 * 60 * 1000;
149
+ setPreviewMarkerCookie(expiresAt);
150
+ expect(state.written).toContain(`${PREVIEW_MARKER_COOKIE}=${expiresAt}`);
151
+ expect(state.written).toContain('Path=/');
152
+ expect(state.written).toContain('SameSite=Lax');
153
+ expect(state.written).toContain('Secure');
154
+ // A positive Max-Age (not a clear) that outlives the token.
155
+ expect(state.written).toMatch(/Max-Age=\d+/);
156
+ expect(state.written).not.toContain('Max-Age=0');
157
+ });
158
+ it('defaults to the 1h TTL when passed a bare true', () => {
159
+ const state = stubBrowser('https:');
160
+ const before = Date.now();
161
+ setPreviewMarkerCookie(true);
162
+ const match = state.written.match(new RegExp(`${PREVIEW_MARKER_COOKIE}=(\\d+)`));
163
+ expect(match).not.toBeNull();
164
+ expect(Number(match[1])).toBeGreaterThanOrEqual(before + 60 * 60 * 1000 - 5_000);
165
+ });
166
+ it('omits Secure on http so it persists in local dev', () => {
167
+ const state = stubBrowser('http:');
168
+ setPreviewMarkerCookie(Date.now() + 60 * 60 * 1000);
169
+ expect(state.written).not.toContain('Secure');
170
+ });
171
+ it('clears the marker with Max-Age=0 on exit', () => {
172
+ const state = stubBrowser('https:');
173
+ setPreviewMarkerCookie(false);
174
+ expect(state.written).toContain(`${PREVIEW_MARKER_COOKIE}=`);
175
+ expect(state.written).toContain('Max-Age=0');
176
+ });
177
+ });
178
+ describe('previewAuthHeaders', () => {
179
+ it('builds a Bearer header when a token is present', () => {
180
+ expect(previewAuthHeaders('abc')).toEqual({
181
+ authorization: 'Bearer abc'
182
+ });
183
+ });
184
+ it('returns an empty object when there is no token (safe to spread)', () => {
185
+ expect(previewAuthHeaders(undefined)).toEqual({});
186
+ expect(previewAuthHeaders(null)).toEqual({});
187
+ expect(previewAuthHeaders('')).toEqual({});
188
+ });
189
+ });
190
+ describe('marker cookie readers (hasPreviewMarkerCookie / getPreviewExpiry)', () => {
191
+ afterEach(() => vi.unstubAllGlobals());
192
+ function stubCookies(cookie) {
193
+ vi.stubGlobal('document', {
194
+ cookie
195
+ });
196
+ }
197
+ it('reads presence + expiry from an expiry-encoded marker', () => {
198
+ stubCookies(`a=1; ${PREVIEW_MARKER_COOKIE}=1781700000000; b=2`);
199
+ expect(hasPreviewMarkerCookie()).toBe(true);
200
+ expect(getPreviewExpiry()).toBe(1781700000000);
201
+ });
202
+ it('treats a legacy `=1` marker as present, expiry 1ms (always expired)', () => {
203
+ stubCookies(`${PREVIEW_MARKER_COOKIE}=1`);
204
+ expect(hasPreviewMarkerCookie()).toBe(true);
205
+ expect(getPreviewExpiry()).toBe(1);
206
+ });
207
+ it('reports absent when the marker cookie is missing', () => {
208
+ stubCookies('a=1; b=2');
209
+ expect(hasPreviewMarkerCookie()).toBe(false);
210
+ expect(getPreviewExpiry()).toBeUndefined();
211
+ });
212
+ it('returns undefined expiry for a non-numeric marker value', () => {
213
+ stubCookies(`${PREVIEW_MARKER_COOKIE}=bogus`);
214
+ expect(hasPreviewMarkerCookie()).toBe(true);
215
+ expect(getPreviewExpiry()).toBeUndefined();
216
+ });
217
+ });