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
@@ -0,0 +1,53 @@
1
+ import { PREVIEW_TOKEN_COOKIE, PREVIEW_MARKER_COOKIE, PREVIEW_ENDPOINT, DEFAULT_PREVIEW_TTL_MS, PREVIEW_MARKER_GRACE_MS } from './constants.js';
2
+ export { PREVIEW_TOKEN_COOKIE, PREVIEW_MARKER_COOKIE, PREVIEW_ENDPOINT, DEFAULT_PREVIEW_TTL_MS, PREVIEW_MARKER_GRACE_MS, };
3
+ export interface PreviewCookieOptions {
4
+ /**
5
+ * Whether to set the cookies' Secure attribute. Defaults to true on https
6
+ * requests and false otherwise, so cookies work over http in local dev.
7
+ */
8
+ secure?: boolean;
9
+ /** SameSite attribute. Defaults to 'lax' — the toolbar is same-site. */
10
+ sameSite?: 'lax' | 'strict' | 'none';
11
+ /** Cookie path. Defaults to '/'. */
12
+ path?: string;
13
+ }
14
+ /**
15
+ * Web-standard route handler implementing the portable preview contract. Wire
16
+ * it into your framework's route at the path the toolbar targets
17
+ * (`/api/preview` by default):
18
+ *
19
+ * // Astro: src/pages/api/preview.ts
20
+ * import { handlePreviewRequest } from 'fontdue-js/preview';
21
+ * export const ALL: APIRoute = ({ request }) => handlePreviewRequest(request);
22
+ *
23
+ * // React Router 7: app/routes/api.preview.ts
24
+ * import { handlePreviewRequest } from 'fontdue-js/preview';
25
+ * export const action = ({ request }: Route.ActionArgs) =>
26
+ * handlePreviewRequest(request);
27
+ *
28
+ * POST expects a JSON body `{ token: string, expiresAt?: string | number }`;
29
+ * DELETE takes no body. `expiresAt` (ISO-8601 or epoch-ms) is the token's
30
+ * expiry; it's stored in the readable marker cookie so the toolbar can warn
31
+ * when preview has lapsed, and defaults to now + DEFAULT_PREVIEW_TTL_MS when
32
+ * omitted. The token is opaque here — the Fontdue GraphQL server validates it
33
+ * on each request, so a forged token simply reveals nothing. Frameworks that
34
+ * cache HTML should also bypass their shared/CDN cache when
35
+ * PREVIEW_MARKER_COOKIE is present, so an admin preview is never served to the
36
+ * public.
37
+ */
38
+ export declare function handlePreviewRequest(request: Request, options?: PreviewCookieOptions): Promise<Response>;
39
+ /**
40
+ * Extract the preview token from a Cookie header string, e.g.
41
+ * `readPreviewToken(request.headers.get('cookie'))`. This is the canonical
42
+ * reader — it reverses the encoding `handlePreviewRequest` applies, so the
43
+ * token comes back verbatim. Returns undefined when not in preview.
44
+ */
45
+ export declare function readPreviewToken(cookieHeader: string | null | undefined): string | undefined;
46
+ /**
47
+ * Headers to merge into a server-side GraphQL fetch so the request reveals
48
+ * hidden fonts. Spread the result into your app's fetch headers, into
49
+ * fontdue-js's per-render `serverConfig.headers`, or into the `headers` option
50
+ * of a preload helper (loadFontdueProviderQuery, loadTypeTesterQuery, …).
51
+ * Returns {} when there is no token, so it's safe to spread unconditionally.
52
+ */
53
+ export declare function previewAuthHeaders(token: string | null | undefined): Record<string, string>;
@@ -0,0 +1,190 @@
1
+ // Portable admin-preview contract, shared by every framework adapter.
2
+ //
3
+ // The admin toolbar (FontdueAdminToolbar, auto-mounted by FontdueProvider)
4
+ // detects a logged-in admin session, brokers a short-lived admin token, and
5
+ // POSTs it to a small preview route on the storefront's own origin to "enter
6
+ // preview"; a DELETE exits. In Next.js that route additionally toggles draft
7
+ // mode, but the underlying contract is framework-agnostic:
8
+ //
9
+ // - Enter (POST { token }): set an httpOnly token cookie + a readable marker
10
+ // cookie, so server renders forward the token and the client toolbar can
11
+ // tell preview is on.
12
+ // - Exit (DELETE): clear both cookies.
13
+ // - Server fetch: when the token cookie is present, forward it as
14
+ // `Authorization: Bearer <token>` so GraphQL reveals hidden (unpublished)
15
+ // fonts. The public never has these cookies, so their renders stay
16
+ // sessionless and cacheable.
17
+ //
18
+ // Astro, React Router 7 and TanStack Start all speak the Web Fetch API, so
19
+ // `handlePreviewRequest` is a drop-in route handler for all of them. Next keeps
20
+ // its own route because it layers Next draft mode on top of the same cookies.
21
+ //
22
+ // Why not route this through fontdue-js's per-render `setFontdueServerConfig`
23
+ // store? That store is React.cache-backed and only isolates per request inside
24
+ // an RSC render (Next). Outside RSC — Astro frontmatter, RR7 loaders — writes
25
+ // are no-ops (see relay/serverConfig.ts). Two portable ways to get the token to
26
+ // server fetches instead:
27
+ //
28
+ // - Ambient (recommended): wrap requests in `runWithPreview` from
29
+ // fontdue-js/preview/server, which holds the token in AsyncLocalStorage for
30
+ // the request so every fontdue-js fetch/preload forwards it with no
31
+ // per-call plumbing — and forces preview responses out of shared caches.
32
+ // - Explicit: read the cookie per request and pass `previewAuthHeaders(token)`
33
+ // via the `headers` option on the app's own GraphQL fetch and on each
34
+ // preload helper (loadFontdueProviderQuery, loadTypeTesterQuery, …). This
35
+ // mirrors how headless-CMS previews (e.g. Sanity's loadQuery) pass
36
+ // per-request state explicitly, and is the fallback where ambient context
37
+ // can't propagate.
38
+
39
+ import { PREVIEW_TOKEN_COOKIE, PREVIEW_MARKER_COOKIE, PREVIEW_ENDPOINT, DEFAULT_PREVIEW_TTL_MS, PREVIEW_MARKER_GRACE_MS } from './constants.js';
40
+ export { PREVIEW_TOKEN_COOKIE, PREVIEW_MARKER_COOKIE, PREVIEW_ENDPOINT, DEFAULT_PREVIEW_TTL_MS, PREVIEW_MARKER_GRACE_MS };
41
+ function serializeCookie(name, value, opts) {
42
+ const sameSite = opts.sameSite === 'strict' ? 'Strict' : opts.sameSite === 'none' ? 'None' : 'Lax';
43
+ const parts = [`${name}=${encodeURIComponent(value)}`, `Path=${opts.path ?? '/'}`, `SameSite=${sameSite}`];
44
+ if (opts.httpOnly) parts.push('HttpOnly');
45
+ if (opts.secure) parts.push('Secure');
46
+ if (opts.maxAge != null) parts.push(`Max-Age=${opts.maxAge}`);
47
+ return parts.join('; ');
48
+ }
49
+
50
+ // Coerce an `expiresAt` from the POST body — an ISO-8601 string, an epoch-ms
51
+ // number, or a numeric string — into epoch-ms. Falls back to
52
+ // now + DEFAULT_PREVIEW_TTL_MS when absent or unparseable, since the canonical
53
+ // 1h TTL lives in the backend token and the field may not be present yet (the
54
+ // backend half ships separately). Never throws.
55
+ function resolveExpiresAt(value) {
56
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
57
+ if (typeof value === 'string' && value !== '') {
58
+ const asNumber = Number(value);
59
+ if (Number.isFinite(asNumber)) return asNumber;
60
+ const asDate = Date.parse(value);
61
+ if (!Number.isNaN(asDate)) return asDate;
62
+ }
63
+ return Date.now() + DEFAULT_PREVIEW_TTL_MS;
64
+ }
65
+ function isSecureRequest(request) {
66
+ try {
67
+ return new URL(request.url).protocol === 'https:';
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
72
+ function jsonResponse(body, status) {
73
+ let setCookies = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : [];
74
+ const headers = new Headers({
75
+ 'content-type': 'application/json'
76
+ });
77
+ for (const cookie of setCookies) headers.append('Set-Cookie', cookie);
78
+ return new Response(JSON.stringify(body), {
79
+ status,
80
+ headers
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Web-standard route handler implementing the portable preview contract. Wire
86
+ * it into your framework's route at the path the toolbar targets
87
+ * (`/api/preview` by default):
88
+ *
89
+ * // Astro: src/pages/api/preview.ts
90
+ * import { handlePreviewRequest } from 'fontdue-js/preview';
91
+ * export const ALL: APIRoute = ({ request }) => handlePreviewRequest(request);
92
+ *
93
+ * // React Router 7: app/routes/api.preview.ts
94
+ * import { handlePreviewRequest } from 'fontdue-js/preview';
95
+ * export const action = ({ request }: Route.ActionArgs) =>
96
+ * handlePreviewRequest(request);
97
+ *
98
+ * POST expects a JSON body `{ token: string, expiresAt?: string | number }`;
99
+ * DELETE takes no body. `expiresAt` (ISO-8601 or epoch-ms) is the token's
100
+ * expiry; it's stored in the readable marker cookie so the toolbar can warn
101
+ * when preview has lapsed, and defaults to now + DEFAULT_PREVIEW_TTL_MS when
102
+ * omitted. The token is opaque here — the Fontdue GraphQL server validates it
103
+ * on each request, so a forged token simply reveals nothing. Frameworks that
104
+ * cache HTML should also bypass their shared/CDN cache when
105
+ * PREVIEW_MARKER_COOKIE is present, so an admin preview is never served to the
106
+ * public.
107
+ */
108
+ export async function handlePreviewRequest(request) {
109
+ let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
110
+ const base = {
111
+ secure: options.secure ?? isSecureRequest(request),
112
+ sameSite: options.sameSite ?? 'lax',
113
+ path: options.path ?? '/'
114
+ };
115
+ if (request.method === 'DELETE') {
116
+ return jsonResponse({
117
+ ok: true,
118
+ preview: false
119
+ }, 200, [serializeCookie(PREVIEW_TOKEN_COOKIE, '', {
120
+ ...base,
121
+ httpOnly: true,
122
+ maxAge: 0
123
+ }), serializeCookie(PREVIEW_MARKER_COOKIE, '', {
124
+ ...base,
125
+ httpOnly: false,
126
+ maxAge: 0
127
+ })]);
128
+ }
129
+ if (request.method === 'POST') {
130
+ const body = await request.json().catch(() => ({}));
131
+ const token = body.token;
132
+ if (typeof token !== 'string' || token === '') {
133
+ return jsonResponse({
134
+ ok: false,
135
+ error: 'Missing preview token'
136
+ }, 400);
137
+ }
138
+ const expiresAt = resolveExpiresAt(body.expiresAt);
139
+ // Marker outlives the token by a grace window so the toolbar can reach the
140
+ // "expired" warning state instead of the cookie silently vanishing.
141
+ const markerMaxAge = Math.max(0, Math.ceil((expiresAt - Date.now() + PREVIEW_MARKER_GRACE_MS) / 1000));
142
+ return jsonResponse({
143
+ ok: true,
144
+ preview: true,
145
+ expiresAt
146
+ }, 200, [serializeCookie(PREVIEW_TOKEN_COOKIE, token, {
147
+ ...base,
148
+ httpOnly: true
149
+ }), serializeCookie(PREVIEW_MARKER_COOKIE, String(expiresAt), {
150
+ ...base,
151
+ httpOnly: false,
152
+ maxAge: markerMaxAge
153
+ })]);
154
+ }
155
+ return jsonResponse({
156
+ ok: false,
157
+ error: 'Method not allowed'
158
+ }, 405);
159
+ }
160
+
161
+ /**
162
+ * Extract the preview token from a Cookie header string, e.g.
163
+ * `readPreviewToken(request.headers.get('cookie'))`. This is the canonical
164
+ * reader — it reverses the encoding `handlePreviewRequest` applies, so the
165
+ * token comes back verbatim. Returns undefined when not in preview.
166
+ */
167
+ export function readPreviewToken(cookieHeader) {
168
+ if (!cookieHeader) return undefined;
169
+ for (const part of cookieHeader.split(';')) {
170
+ const idx = part.indexOf('=');
171
+ if (idx === -1) continue;
172
+ if (part.slice(0, idx).trim() !== PREVIEW_TOKEN_COOKIE) continue;
173
+ const value = part.slice(idx + 1).trim();
174
+ return value === '' ? undefined : decodeURIComponent(value);
175
+ }
176
+ return undefined;
177
+ }
178
+
179
+ /**
180
+ * Headers to merge into a server-side GraphQL fetch so the request reveals
181
+ * hidden fonts. Spread the result into your app's fetch headers, into
182
+ * fontdue-js's per-render `serverConfig.headers`, or into the `headers` option
183
+ * of a preload helper (loadFontdueProviderQuery, loadTypeTesterQuery, …).
184
+ * Returns {} when there is no token, so it's safe to spread unconditionally.
185
+ */
186
+ export function previewAuthHeaders(token) {
187
+ return token ? {
188
+ authorization: `Bearer ${token}`
189
+ } : {};
190
+ }
@@ -0,0 +1,20 @@
1
+ /** Whether the current async context is an admin preview. */
2
+ export declare function isPreviewing(): boolean;
3
+ /**
4
+ * Preview auth headers for the current async context, or {} when not previewing.
5
+ * Spread into a hand-rolled fetch you make inside `runWithPreview`; the
6
+ * fontdue-js fetch/preload helpers pick these up automatically and don't need
7
+ * it.
8
+ */
9
+ export declare function ambientPreviewHeaders(): Record<string, string>;
10
+ /**
11
+ * Run `next` (your framework's render/loader chain) with the request's preview
12
+ * token in ambient context, then return its Response. When the request is
13
+ * previewing, the response's cache headers are rewritten so it is never stored
14
+ * in a shared/CDN cache. Public (no-token) requests pass through untouched and
15
+ * stay fully cacheable.
16
+ *
17
+ * `next` is your middleware's continuation — `next` in Astro, `next` in a React
18
+ * Router 7 middleware. It must return (a promise of) the Response.
19
+ */
20
+ export declare function runWithPreview(request: Request, next: () => Response | Promise<Response>): Promise<Response>;
@@ -0,0 +1,89 @@
1
+ // Ambient (request-scoped) preview for non-RSC server frameworks.
2
+ //
3
+ // fontdue-js/preview gives you the cookie contract and an explicit option:
4
+ // read the token per request and thread `previewAuthHeaders(token)` into every
5
+ // GraphQL fetch and preload helper. That's portable but easy to get wrong — miss
6
+ // one preload and that island silently renders the public view.
7
+ //
8
+ // This module removes the threading. `runWithPreview` establishes an
9
+ // AsyncLocalStorage context for the duration of a request; while it's active,
10
+ // every fontdue-js server fetch (createFontdueFetch and all the preload helpers)
11
+ // automatically forwards the admin preview token, with no per-call plumbing —
12
+ // the same ergonomics RSC gets from React.cache, and the same mechanism Next's
13
+ // own headers()/draftMode() use under the hood. Install it once in your
14
+ // framework's middleware:
15
+ //
16
+ // // Astro: src/middleware.ts
17
+ // export const onRequest = (ctx, next) => runWithPreview(ctx.request, next);
18
+ //
19
+ // // React Router 7 (root route, with future.v8_middleware):
20
+ // export const middleware = [({ request }, next) => runWithPreview(request, next)];
21
+ //
22
+ // runWithPreview also binds the cache guarantee to the same call: when a request
23
+ // is previewing, the response is forced out of any shared/CDN cache. Because the
24
+ // context and the no-cache rule are set in one place, you can't be ambiently
25
+ // previewing without the response being uncacheable — so an admin render is never
26
+ // served to the public.
27
+ //
28
+ // Requirements & fallback: AsyncLocalStorage must propagate from the install
29
+ // point into the render. That holds for in-process middleware — Node (Netlify
30
+ // Functions, the default SSR target), Deno (Netlify Edge), Bun. It does NOT hold
31
+ // when middleware runs in a separate runtime from the render (e.g. Astro with
32
+ // `edgeMiddleware: true`, where locals cross the boundary as a serialized
33
+ // header). In that case fall back to the explicit path: read the token in
34
+ // middleware and thread `previewAuthHeaders(token)` via the `headers` option on
35
+ // fetches/preloads. The explicit `headers` option always overrides the ambient
36
+ // context, so the two compose.
37
+
38
+ import { AsyncLocalStorage } from 'node:async_hooks';
39
+ import { readPreviewToken, previewAuthHeaders } from './index.js';
40
+ import { registerAmbientConfigResolver } from '../relay/serverConfig.js';
41
+ const previewStore = new AsyncLocalStorage();
42
+
43
+ // Feed the ambient token into fontdue-js's server config resolution. Returns
44
+ // undefined outside a preview context, so non-preview requests are untouched.
45
+ registerAmbientConfigResolver(() => {
46
+ const store = previewStore.getStore();
47
+ return store ? {
48
+ headers: store.headers
49
+ } : undefined;
50
+ });
51
+
52
+ /** Whether the current async context is an admin preview. */
53
+ export function isPreviewing() {
54
+ return previewStore.getStore() != null;
55
+ }
56
+
57
+ /**
58
+ * Preview auth headers for the current async context, or {} when not previewing.
59
+ * Spread into a hand-rolled fetch you make inside `runWithPreview`; the
60
+ * fontdue-js fetch/preload helpers pick these up automatically and don't need
61
+ * it.
62
+ */
63
+ export function ambientPreviewHeaders() {
64
+ var _previewStore$getStor;
65
+ return ((_previewStore$getStor = previewStore.getStore()) === null || _previewStore$getStor === void 0 ? void 0 : _previewStore$getStor.headers) ?? {};
66
+ }
67
+ const NO_STORE = 'private, no-store';
68
+
69
+ /**
70
+ * Run `next` (your framework's render/loader chain) with the request's preview
71
+ * token in ambient context, then return its Response. When the request is
72
+ * previewing, the response's cache headers are rewritten so it is never stored
73
+ * in a shared/CDN cache. Public (no-token) requests pass through untouched and
74
+ * stay fully cacheable.
75
+ *
76
+ * `next` is your middleware's continuation — `next` in Astro, `next` in a React
77
+ * Router 7 middleware. It must return (a promise of) the Response.
78
+ */
79
+ export async function runWithPreview(request, next) {
80
+ const token = readPreviewToken(request.headers.get('cookie'));
81
+ if (!token) return next();
82
+ const response = await previewStore.run({
83
+ headers: previewAuthHeaders(token)
84
+ }, next);
85
+ response.headers.set('Cache-Control', NO_STORE);
86
+ response.headers.delete('Netlify-CDN-Cache-Control');
87
+ response.headers.delete('CDN-Cache-Control');
88
+ return response;
89
+ }
@@ -1,9 +1,17 @@
1
1
  import { Environment, RequestParameters, QueryResponseCache, Variables, GraphQLResponse } from 'relay-runtime';
2
+ export declare const version: string;
3
+ export declare function fontdueBaseUrl(): string | undefined;
2
4
  export declare function createNetworkFetch(options?: CreateRelayEnvironmentOptions): (request: RequestParameters, variables: Variables) => Promise<GraphQLResponse>;
3
5
  export declare const networkFetch: (request: RequestParameters, variables: Variables) => Promise<GraphQLResponse>;
4
6
  export declare const responseCache: QueryResponseCache | null;
5
7
  interface CreateRelayEnvironmentOptions {
6
8
  url?: string;
9
+ /**
10
+ * Extra headers for this fetch (server-side). Used to forward a per-request
11
+ * preview `Authorization: Bearer <token>` in non-RSC frameworks, where the
12
+ * render-scoped serverConfig store is a no-op. See ../preview.
13
+ */
14
+ headers?: Record<string, string>;
7
15
  stripeIntegration?: 'card-element' | 'dynamic';
8
16
  }
9
17
  export declare function createEnvironment(options: CreateRelayEnvironmentOptions): Environment;
@@ -1,12 +1,24 @@
1
1
  import { Environment, Network, RecordSource, Store, QueryResponseCache } from 'relay-runtime';
2
2
  import { handlePossibleCorsError } from '../corsError.js';
3
- import { getFontdueServerConfig } from './serverConfig.js';
3
+ import { resolveFontdueServerConfig } from './serverConfig.js';
4
+ import { PREVIEW_HEADER, hasPreviewMarkerCookie } from '../preview/constants.js';
4
5
 
5
6
  // `__FONTDUE_JS_VERSION__` is replaced by an inline babel plugin
6
7
  // (defineVersionPlugin in .babelrc.cjs) with the literal package.json#version.
7
- const version = "3.0.0-alpha9";
8
+ // Exported so UI (the admin toolbar) can surface it without re-reading the
9
+ // build-time global in a 'use client' module.
10
+ export const version = "3.0.0";
8
11
  const IS_SERVER = typeof window === typeof undefined;
9
12
 
13
+ // Opt server fetches into Next's data cache only in production; dev stays
14
+ // uncached so storefront content is always fresh (see the init.cache note
15
+ // below). Read per call (not memoized) so the literal `process.env.NODE_ENV` is
16
+ // inlined at build time and tests can stub it; `typeof process` guards non-Node
17
+ // bundles from throwing.
18
+ function cacheInProduction() {
19
+ return typeof process !== 'undefined' && process.env.NODE_ENV === 'production';
20
+ }
21
+
10
22
  // Read env from either process.env (Node/Next.js) or import.meta.env (Vite/Astro).
11
23
  // Prefer the framework-agnostic FONTDUE_URL name; fall back to NEXT_PUBLIC_FONTDUE_URL
12
24
  // for backwards compatibility with existing Next.js integrations.
@@ -36,48 +48,82 @@ const FONTDUE_URL = readEnv('FONTDUE_URL') ?? readEnv('NEXT_PUBLIC_FONTDUE_URL')
36
48
  const STRIPE_INTEGRATION = readEnv('FONTDUE_STRIPE_INTEGRATION') ?? readEnv('NEXT_PUBLIC_FONTDUE_STRIPE_INTEGRATION') ?? NEXT_PUBLIC_STRIPE;
37
49
  const CACHE_TTL = 10 * 1000; // 10 seconds, to resolve preloaded results
38
50
 
51
+ // The configured Fontdue base URL resolved for the current runtime, or
52
+ // undefined when it can't be determined (e.g. multi-tenant, where fetches go to
53
+ // the page's own origin). The admin toolbar uses it to show where the admin is
54
+ // signed in and to link back to the Fontdue admin.
55
+ export function fontdueBaseUrl() {
56
+ return FONTDUE_URL || undefined;
57
+ }
39
58
  export function createNetworkFetch(options) {
40
59
  return async function networkFetch(request, variables) {
41
- // Per-render server config (set via setFontdueServerConfig or the
42
- // FontdueProvider server entrypoint). Resolved per call, not per
43
- // createNetworkFetch, because module-level fetchers outlive renders.
44
- const serverConfig = IS_SERVER ? getFontdueServerConfig() : undefined;
60
+ // Per-render server config (the RSC slot set via setFontdueServerConfig /
61
+ // the FontdueProvider server entrypoint, the runWithPreview AsyncLocalStorage
62
+ // store, or the Next single-tenant ambient resolver). Awaited per call, not
63
+ // per createNetworkFetch, because module-level fetchers outlive renders — so
64
+ // an embedded component's own preload reveals hidden fonts in preview even
65
+ // on a page that never calls the app's GraphQL fetcher.
66
+ const serverConfig = IS_SERVER ? await resolveFontdueServerConfig() : undefined;
45
67
  const base = (options === null || options === void 0 ? void 0 : options.url) ?? (serverConfig === null || serverConfig === void 0 ? void 0 : serverConfig.url) ?? FONTDUE_URL;
46
68
  if (IS_SERVER && (base == null || base === '')) {
47
69
  throw new Error('fontdue-js: no Fontdue URL configured for server-side fetch. ' + 'Set FONTDUE_URL / PUBLIC_FONTDUE_URL / VITE_FONTDUE_URL in your environment, ' + 'pass `url` to loadSerializableQuery, or call setFontdueServerConfig ' + '(or render <FontdueProvider url=…>) earlier in the server render.');
48
70
  }
49
71
  const url = `${base ?? ''}/graphql`;
72
+ const headers = {
73
+ ...(serverConfig === null || serverConfig === void 0 ? void 0 : serverConfig.headers),
74
+ // Per-call headers (e.g. a preview Authorization: Bearer token passed to
75
+ // a preload helper) override the render-store headers.
76
+ ...(options === null || options === void 0 ? void 0 : options.headers),
77
+ Accept: 'application/json',
78
+ 'Content-Type': 'application/json',
79
+ 'fontdue-stripe-integration': (options === null || options === void 0 ? void 0 : options.stripeIntegration) ?? STRIPE_INTEGRATION ?? 'dynamic',
80
+ 'fontdue-client-version': version
81
+ };
82
+
83
+ // Whether this request is an admin *preview*. On the server that's a
84
+ // forwarded admin token; in the browser it's the readable preview marker
85
+ // cookie — NOT a bare admin session cookie that happens to ride the fetch
86
+ // (that conflation was the leak: a logged-in admin browsing normally would
87
+ // see hidden fonts). It's sent explicitly as the `fontdue-preview` header so
88
+ // the GraphQL server reveals hidden (unpublished) fonts only in preview:
89
+ // "false" forces the public view even for a logged-in admin, and an absent
90
+ // header (older clients) keeps the legacy "any admin sees hidden" behavior.
91
+ const previewing = IS_SERVER ? headers.authorization != null || headers.Authorization != null : hasPreviewMarkerCookie();
92
+ headers[PREVIEW_HEADER] = previewing ? 'true' : 'false';
93
+
94
+ // Public server fetches are all site content (per-session data is fetched
95
+ // client-side only), so opt them into Next's data cache — without this,
96
+ // Next 15 treats them as no-store and silently makes every page fully
97
+ // dynamic. The tags let the revalidate handler purge them. A preview render,
98
+ // though, carries an admin Authorization token: those fetches must stay live
99
+ // and never be written to or served from the shared data cache (a public
100
+ // request could otherwise be handed the hidden-fonts response), so opt them
101
+ // out with no-store. Both hints are inert outside Next.
102
+ //
103
+ // In local dev (`next dev`) we also skip the cache: the data cache plus
104
+ // on-demand `revalidateTag` don't reliably refresh, and the storefront proxy
105
+ // strips the browser's `no-cache`, so a stale entry can't be busted — leave
106
+ // dev fetches uncached so every render is fresh. See CACHE_IN_PRODUCTION.
107
+ const init = {
108
+ method: 'POST',
109
+ credentials: 'include',
110
+ headers,
111
+ body: JSON.stringify({
112
+ query: request.text,
113
+ variables
114
+ })
115
+ };
116
+ if (IS_SERVER && (previewing || !cacheInProduction())) {
117
+ init.cache = 'no-store';
118
+ } else if (IS_SERVER) {
119
+ init.cache = 'force-cache';
120
+ init.next = {
121
+ tags: ['graphql', ...((serverConfig === null || serverConfig === void 0 ? void 0 : serverConfig.cacheTags) ?? []), `operation:${request.name}`]
122
+ };
123
+ }
50
124
  for (let attempt = 0; attempt <= 2; attempt++) {
51
125
  try {
52
- const resp = await fetch(url + `?queryName=${request.name}`, {
53
- method: 'POST',
54
- credentials: 'include',
55
- headers: {
56
- ...(serverConfig === null || serverConfig === void 0 ? void 0 : serverConfig.headers),
57
- Accept: 'application/json',
58
- 'Content-Type': 'application/json',
59
- 'fontdue-stripe-integration': (options === null || options === void 0 ? void 0 : options.stripeIntegration) ?? STRIPE_INTEGRATION ?? 'dynamic',
60
- 'fontdue-client-version': version
61
- },
62
- body: JSON.stringify({
63
- query: request.text,
64
- variables
65
- }),
66
- // Server-side fetches are all site content (per-session data is
67
- // fetched client-side only), so opt them into Next's data cache —
68
- // without this, Next 15 treats them as no-store and silently makes
69
- // every page fully dynamic. The tags below let the revalidate
70
- // handler purge them when content changes. Inert outside Next:
71
- // Node's fetch accepts and ignores this cache mode, and the
72
- // browser path doesn't set it.
73
- ...(IS_SERVER ? {
74
- cache: 'force-cache'
75
- } : {}),
76
- // @ts-ignore
77
- next: {
78
- tags: ['graphql', ...((serverConfig === null || serverConfig === void 0 ? void 0 : serverConfig.cacheTags) ?? []), `operation:${request.name}`]
79
- }
80
- });
126
+ const resp = await fetch(url + `?queryName=${request.name}`, init);
81
127
  const json = await resp.json();
82
128
 
83
129
  // GraphQL returns exceptions (for example, a missing required variable) in the "errors"
@@ -4,6 +4,18 @@ export interface SerializablePreloadedQuery<TQuery extends OperationType> {
4
4
  variables: VariablesOf<TQuery>;
5
5
  response: GraphQLResponse;
6
6
  }
7
+ /** Per-call options shared by every preload helper. */
8
+ export interface LoadQueryOptions {
9
+ /** Override the GraphQL base URL for this fetch. */
10
+ url?: string;
11
+ /**
12
+ * Extra headers for this server-side fetch. Pass
13
+ * `previewAuthHeaders(token)` (from fontdue-js/preview) to reveal hidden
14
+ * fonts in preview when the render-scoped serverConfig store can't carry the
15
+ * token (Astro frontmatter, RR7 loaders — anything that isn't an RSC render).
16
+ */
17
+ headers?: Record<string, string>;
18
+ }
7
19
  /**
8
20
  * RELAY CACHE KEY NORMALIZATION
9
21
  *
@@ -43,7 +55,5 @@ export interface SerializablePreloadedQuery<TQuery extends OperationType> {
43
55
  type RequireAllWithNull<T> = {
44
56
  [K in keyof T]-?: T[K] | null;
45
57
  };
46
- export default function loadSerializableQuery<TQuery extends OperationType>(query: GraphQLTaggedNode, variables: RequireAllWithNull<VariablesOf<TQuery>>, options?: {
47
- url?: string;
48
- }): Promise<SerializablePreloadedQuery<TQuery>>;
58
+ export default function loadSerializableQuery<TQuery extends OperationType>(query: GraphQLTaggedNode, variables: RequireAllWithNull<VariablesOf<TQuery>>, options?: LoadQueryOptions): Promise<SerializablePreloadedQuery<TQuery>>;
49
59
  export {};
@@ -1,5 +1,7 @@
1
1
  import { createNetworkFetch } from './environment.js';
2
2
 
3
+ /** Per-call options shared by every preload helper. */
4
+
3
5
  /**
4
6
  * RELAY CACHE KEY NORMALIZATION
5
7
  *
@@ -5,12 +5,10 @@ export interface FontdueServerConfig {
5
5
  headers?: Record<string, string>;
6
6
  /** Extra Next.js fetch cache tags applied to every server-side GraphQL fetch. */
7
7
  cacheTags?: string[];
8
- /**
9
- * The site domain this render is for, when known. Set by
10
- * fontdue-js/next's prepareFontdueRender so render-scoped helpers
11
- * (currentFontdueEndpoint) can recover it.
12
- */
13
- domain?: string;
14
8
  }
9
+ type MaybePromise<T> = T | Promise<T>;
10
+ type AmbientConfigResolver = () => MaybePromise<FontdueServerConfig | undefined>;
15
11
  export declare function setFontdueServerConfig(config: FontdueServerConfig): void;
16
- export declare function getFontdueServerConfig(): FontdueServerConfig | undefined;
12
+ export declare function registerAmbientConfigResolver(resolver: AmbientConfigResolver): void;
13
+ export declare function resolveFontdueServerConfig(): Promise<FontdueServerConfig | undefined>;
14
+ export {};