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.
- package/CHANGELOG.md +14 -0
- package/README.md +182 -13
- package/dist/__generated__/CartOrderCompleteOrderMutation.graphql.d.ts +1 -1
- package/dist/__generated__/CartOrderCompleteOrderMutation.graphql.js +9 -3
- package/dist/__generated__/CartOrderRemoveDiscountMutation.graphql.d.ts +1 -1
- package/dist/__generated__/CartOrderRemoveDiscountMutation.graphql.js +9 -3
- package/dist/__generated__/CartOrderUpdateMutation.graphql.d.ts +1 -1
- package/dist/__generated__/CartOrderUpdateMutation.graphql.js +9 -3
- package/dist/__generated__/CartQuery.graphql.d.ts +1 -1
- package/dist/__generated__/CartQuery.graphql.js +9 -3
- package/dist/__generated__/CartStateUpdateMutation.graphql.d.ts +1 -1
- package/dist/__generated__/CartStateUpdateMutation.graphql.js +9 -3
- package/dist/__generated__/CharacterViewerIDQuery.graphql.d.ts +1 -1
- package/dist/__generated__/CharacterViewerIDQuery.graphql.js +9 -3
- package/dist/__generated__/CharacterViewerSlugQuery.graphql.d.ts +1 -1
- package/dist/__generated__/CharacterViewerSlugQuery.graphql.js +9 -3
- package/dist/__generated__/CharacterViewerStyleRefetchQuery.graphql.d.ts +1 -1
- package/dist/__generated__/CharacterViewerStyleRefetchQuery.graphql.js +9 -3
- package/dist/__generated__/CheckoutUpdateCustomerMutation.graphql.d.ts +1 -1
- package/dist/__generated__/CheckoutUpdateCustomerMutation.graphql.js +9 -3
- package/dist/__generated__/CheckoutUpdateOrderMutation.graphql.d.ts +1 -1
- package/dist/__generated__/CheckoutUpdateOrderMutation.graphql.js +9 -3
- package/dist/__generated__/CollectionAa_Query.graphql.d.ts +1 -1
- package/dist/__generated__/CollectionAa_Query.graphql.js +9 -3
- package/dist/__generated__/FontFamiliesQuery.graphql.d.ts +1 -1
- package/dist/__generated__/FontFamiliesQuery.graphql.js +9 -3
- package/dist/__generated__/FontdueAdminToolbarQuery.graphql.d.ts +20 -0
- package/dist/__generated__/FontdueAdminToolbarQuery.graphql.js +80 -0
- package/dist/__generated__/FontdueAdminToolbarTokenMutation.graphql.d.ts +18 -0
- package/dist/__generated__/FontdueAdminToolbarTokenMutation.graphql.js +56 -0
- package/dist/__generated__/PrecartAddToCartMutation.graphql.d.ts +1 -1
- package/dist/__generated__/PrecartAddToCartMutation.graphql.js +9 -3
- package/dist/__generated__/StoreModalCartQuery.graphql.d.ts +1 -1
- package/dist/__generated__/StoreModalCartQuery.graphql.js +9 -3
- package/dist/__generated__/StoreModalContainerQuery.graphql.d.ts +1 -1
- package/dist/__generated__/StoreModalContainerQuery.graphql.js +9 -3
- package/dist/__generated__/StoreModalIndexQuery.graphql.d.ts +1 -1
- package/dist/__generated__/StoreModalIndexQuery.graphql.js +9 -3
- package/dist/__generated__/StoreModalProductQuery.graphql.d.ts +1 -1
- package/dist/__generated__/StoreModalProductQuery.graphql.js +9 -3
- package/dist/__generated__/StoreModalProductRefetchQuery.graphql.d.ts +1 -1
- package/dist/__generated__/StoreModalProductRefetchQuery.graphql.js +9 -3
- package/dist/__generated__/TestFontsFormUpdateCustomerMutation.graphql.d.ts +1 -1
- package/dist/__generated__/TestFontsFormUpdateCustomerMutation.graphql.js +9 -3
- package/dist/__generated__/TypeTesterStandaloneChangedStylesQuery.graphql.d.ts +1 -1
- package/dist/__generated__/TypeTesterStandaloneChangedStylesQuery.graphql.js +9 -3
- package/dist/__generated__/TypeTesterStandaloneQuery.graphql.d.ts +1 -1
- package/dist/__generated__/TypeTesterStandaloneQuery.graphql.js +9 -3
- package/dist/__generated__/TypeTestersChangedStylesQuery.graphql.d.ts +1 -1
- package/dist/__generated__/TypeTestersChangedStylesQuery.graphql.js +9 -3
- package/dist/__generated__/TypeTestersIDQuery.graphql.d.ts +1 -1
- package/dist/__generated__/TypeTestersIDQuery.graphql.js +9 -3
- package/dist/__generated__/TypeTestersRefetchQuery.graphql.d.ts +1 -1
- package/dist/__generated__/TypeTestersRefetchQuery.graphql.js +9 -3
- package/dist/__generated__/TypeTestersSlugQuery.graphql.d.ts +1 -1
- package/dist/__generated__/TypeTestersSlugQuery.graphql.js +9 -3
- package/dist/__generated__/useFontStyle_fontStyle.graphql.d.ts +2 -1
- package/dist/__generated__/useFontStyle_fontStyle.graphql.js +8 -2
- package/dist/__tests__/createFontdueFetch.test.js +276 -0
- package/dist/__tests__/imageLoader.test.js +62 -0
- package/dist/__tests__/metricFallback.test.js +74 -0
- package/dist/__tests__/networkFetch.test.js +125 -3
- package/dist/__tests__/nextAdapter.test.js +175 -60
- package/dist/__tests__/preview.test.js +217 -0
- package/dist/__tests__/previewServer.test.js +118 -0
- package/dist/__tests__/previewState.test.js +63 -0
- package/dist/__tests__/serverConfig.test.js +62 -0
- package/dist/components/BuyButton/index.d.ts +2 -2
- package/dist/components/BuyButton/index.js +3 -3
- package/dist/components/CharacterViewer/index.d.ts +2 -2
- package/dist/components/CharacterViewer/index.js +20 -11
- package/dist/components/ConfigContext.d.ts +21 -2
- package/dist/components/ConfigContext.js +12 -2
- package/dist/components/ConnectionErrorToolbar.d.ts +1 -0
- package/dist/components/ConnectionErrorToolbar.js +106 -0
- package/dist/components/FontdueAdminToolbar/index.d.ts +2 -0
- package/dist/components/FontdueAdminToolbar/index.js +299 -0
- package/dist/components/FontdueAdminToolbar/previewState.d.ts +7 -0
- package/dist/components/FontdueAdminToolbar/previewState.js +58 -0
- package/dist/components/FontdueContextProvider/index.js +4 -2
- package/dist/components/FontdueProvider/index.js +6 -1
- package/dist/components/FontdueProvider/index.server.d.ts +1 -0
- package/dist/components/FontdueProvider/index.server.js +10 -0
- package/dist/components/NewsletterSignup/index.d.ts +2 -2
- package/dist/components/NewsletterSignup/index.js +2 -2
- package/dist/components/Root/index.js +16 -2
- package/dist/components/TestFontsForm/index.d.ts +2 -2
- package/dist/components/TestFontsForm/index.js +2 -2
- package/dist/components/TypeTester/TypeTesterStandalone.d.ts +2 -2
- package/dist/components/TypeTester/TypeTesterStandalone.js +2 -2
- package/dist/components/TypeTesters/index.d.ts +2 -2
- package/dist/components/TypeTesters/index.js +3 -3
- package/dist/components/useFontStyle.d.ts +1 -0
- package/dist/components/useFontStyle.js +12 -3
- package/dist/corsError.d.ts +1 -5
- package/dist/corsError.js +23 -13
- package/dist/data/unicodeNamesUrl.d.ts +2 -0
- package/dist/data/unicodeNamesUrl.js +18 -0
- package/dist/data/unicodeNamesVersion.d.ts +1 -0
- package/dist/data/unicodeNamesVersion.js +4 -0
- package/dist/fallbackFontData.d.ts +2 -0
- package/dist/fallbackFontData.js +10 -0
- package/dist/fontdue.css +231 -4
- package/dist/loadFontdueProviderQuery.d.ts +2 -1
- package/dist/loadFontdueProviderQuery.js +5 -2
- package/dist/metricFallback.d.ts +48 -0
- package/dist/metricFallback.js +98 -0
- package/dist/next/image-loader.js +22 -3
- package/dist/next/index.d.ts +1 -2
- package/dist/next/index.js +14 -6
- package/dist/next/registerSingleTenantResolver.d.ts +1 -0
- package/dist/next/registerSingleTenantResolver.js +35 -0
- package/dist/next/revalidate.js +1 -1
- package/dist/next/tenant.d.ts +4 -4
- package/dist/next/tenant.js +89 -58
- package/dist/preview/constants.d.ts +9 -0
- package/dist/preview/constants.js +117 -0
- package/dist/preview/index.d.ts +53 -0
- package/dist/preview/index.js +190 -0
- package/dist/preview/server.d.ts +20 -0
- package/dist/preview/server.js +89 -0
- package/dist/relay/environment.d.ts +8 -0
- package/dist/relay/environment.js +81 -35
- package/dist/relay/loadSerializableQuery.d.ts +13 -3
- package/dist/relay/loadSerializableQuery.js +2 -0
- package/dist/relay/serverConfig.d.ts +5 -7
- package/dist/relay/serverConfig.js +83 -8
- package/dist/scripts/publishUnicodeData.js +68 -0
- package/dist/scripts/updateUnicodeData.js +41 -6
- package/dist/server/index.d.ts +37 -0
- package/dist/server/index.js +160 -0
- package/package.json +5 -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', () => {
|
|
@@ -130,11 +174,8 @@ describe('configureFontdueRender', () => {
|
|
|
130
174
|
const {
|
|
131
175
|
configureFontdueRender
|
|
132
176
|
} = await importTenant();
|
|
133
|
-
|
|
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('
|
|
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
|
-
|
|
215
|
+
__prepareFontdueRender
|
|
179
216
|
} = await importTenant();
|
|
180
|
-
const endpoint = await
|
|
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
|
-
|
|
227
|
+
__prepareFontdueRender
|
|
191
228
|
} = await importTenant();
|
|
192
|
-
await expect(
|
|
229
|
+
await expect(__prepareFontdueRender(props({
|
|
193
230
|
domain: 'not a domain'
|
|
194
231
|
}))).rejects.toThrow('NEXT_NOT_FOUND');
|
|
195
|
-
await expect(
|
|
232
|
+
await expect(__prepareFontdueRender(props({
|
|
196
233
|
slug: 'sans'
|
|
197
234
|
}))).rejects.toThrow('NEXT_NOT_FOUND');
|
|
198
235
|
});
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
207
|
-
} = await
|
|
208
|
-
|
|
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('
|
|
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
|
-
|
|
214
|
-
} = await
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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('
|
|
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
|
-
|
|
227
|
-
...(await importActual()),
|
|
228
|
-
getFontdueServerConfig: () => ({
|
|
229
|
-
domain: 'acme.fontdue.com'
|
|
230
|
-
})
|
|
231
|
-
}));
|
|
318
|
+
await importRegister();
|
|
232
319
|
const {
|
|
233
|
-
|
|
234
|
-
} = await
|
|
235
|
-
expect(
|
|
236
|
-
vi.doUnmock('../relay/serverConfig');
|
|
320
|
+
resolveFontdueServerConfig
|
|
321
|
+
} = await import("../relay/serverConfig.js");
|
|
322
|
+
expect(await resolveFontdueServerConfig()).toBeUndefined();
|
|
237
323
|
});
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
244
|
-
} = await
|
|
245
|
-
await expect(
|
|
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
|
+
});
|