fontdue-js 3.0.6 → 3.1.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 (61) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/__generated__/FeatureTesterCard_fontStyle.graphql.d.ts +23 -0
  3. package/dist/__generated__/FeatureTesterCard_fontStyle.graphql.js +62 -0
  4. package/dist/__generated__/FeatureTesterStandaloneQuery.graphql.d.ts +25 -0
  5. package/dist/__generated__/FeatureTesterStandaloneQuery.graphql.js +240 -0
  6. package/dist/__generated__/FeatureTestersIdQuery.graphql.d.ts +21 -0
  7. package/dist/__generated__/FeatureTestersIdQuery.graphql.js +344 -0
  8. package/dist/__generated__/FeatureTestersSlugQuery.graphql.d.ts +25 -0
  9. package/dist/__generated__/FeatureTestersSlugQuery.graphql.js +364 -0
  10. package/dist/__generated__/FeatureTesters_collection.graphql.d.ts +39 -0
  11. package/dist/__generated__/FeatureTesters_collection.graphql.js +145 -0
  12. package/dist/__generated__/NodePasswordFormAccessNodeMutation.graphql.d.ts +2 -1
  13. package/dist/__generated__/NodePasswordFormAccessNodeMutation.graphql.js +10 -4
  14. package/dist/__generated__/NodePasswordFormElementIDQuery.graphql.d.ts +21 -0
  15. package/dist/__generated__/NodePasswordFormElementIDQuery.graphql.js +86 -0
  16. package/dist/__generated__/NodePasswordFormElementSlugQuery.graphql.d.ts +25 -0
  17. package/dist/__generated__/NodePasswordFormElementSlugQuery.graphql.js +108 -0
  18. package/dist/__tests__/createFontdueFetch.test.js +128 -4
  19. package/dist/__tests__/highlight.test.js +141 -0
  20. package/dist/__tests__/middleware.test.js +132 -0
  21. package/dist/components/FeatureTester/FeatureTesterCard.d.ts +30 -0
  22. package/dist/components/FeatureTester/FeatureTesterCard.js +167 -0
  23. package/dist/components/FeatureTester/FeatureTesterElement.d.ts +19 -0
  24. package/dist/components/FeatureTester/FeatureTesterElement.js +42 -0
  25. package/dist/components/FeatureTester/FeatureTesters.d.ts +43 -0
  26. package/dist/components/FeatureTester/FeatureTesters.js +156 -0
  27. package/dist/components/FeatureTester/FeatureTesters.server.d.ts +9 -0
  28. package/dist/components/FeatureTester/FeatureTesters.server.js +40 -0
  29. package/dist/components/FeatureTester/FeatureTestersElement.d.ts +10 -0
  30. package/dist/components/FeatureTester/FeatureTestersElement.js +27 -0
  31. package/dist/components/FeatureTester/highlight.d.ts +19 -0
  32. package/dist/components/FeatureTester/highlight.js +71 -0
  33. package/dist/components/FeatureTester/index.d.ts +26 -0
  34. package/dist/components/FeatureTester/index.js +66 -0
  35. package/dist/components/FeatureTester/index.server.d.ts +7 -0
  36. package/dist/components/FeatureTester/index.server.js +30 -0
  37. package/dist/components/NodePasswordForm/NodePasswordFormElement.d.ts +3 -0
  38. package/dist/components/NodePasswordForm/NodePasswordFormElement.js +65 -0
  39. package/dist/components/NodePasswordForm/index.d.ts +16 -1
  40. package/dist/components/NodePasswordForm/index.js +30 -59
  41. package/dist/components/Root/index.js +2 -1
  42. package/dist/fontdue.css +145 -0
  43. package/dist/hooks/useFeatureTesterAutofit.d.ts +17 -0
  44. package/dist/hooks/useFeatureTesterAutofit.js +106 -0
  45. package/dist/next/index.d.ts +2 -0
  46. package/dist/next/index.js +16 -1
  47. package/dist/next/registerSingleTenantResolver.js +13 -1
  48. package/dist/next/tenant.d.ts +1 -0
  49. package/dist/next/tenant.js +27 -2
  50. package/dist/nodeAccess.d.ts +18 -0
  51. package/dist/nodeAccess.js +84 -0
  52. package/dist/preview/server.d.ts +1 -20
  53. package/dist/preview/server.js +16 -85
  54. package/dist/relay/environment.js +8 -2
  55. package/dist/relay/serverConfig.d.ts +3 -0
  56. package/dist/relay/serverConfig.js +22 -1
  57. package/dist/server/index.d.ts +8 -0
  58. package/dist/server/index.js +90 -7
  59. package/dist/server/middleware.d.ts +37 -0
  60. package/dist/server/middleware.js +144 -0
  61. package/package.json +8 -2
@@ -29,6 +29,7 @@ import { notFound, unstable_rethrow } from 'next/navigation';
29
29
  import { cookies, draftMode } from 'next/headers';
30
30
  import { setFontdueServerConfig } from '../relay/serverConfig.js';
31
31
  import { PREVIEW_TOKEN_COOKIE, previewAuthHeaders } from '../preview/index.js';
32
+ import { NODE_ACCESS_COOKIE, NODE_ACCESS_HEADER } from '../nodeAccess.js';
32
33
  export const isMultiTenant = process.env.FONTDUE_MULTI_TENANT === '1';
33
34
  export const singleTenantUrl = process.env.NEXT_PUBLIC_FONTDUE_URL;
34
35
  const internalOrigin = process.env.FONTDUE_ORIGIN;
@@ -155,6 +156,30 @@ export async function __prepareFontdueRender(props) {
155
156
  return endpointForDomain(domain);
156
157
  }
157
158
 
159
+ // The visitor's per-collection node-access header for the current request, or {}
160
+ // when they haven't unlocked anything. Reads the first-party cookie the password
161
+ // form set and returns it as `{ 'fontdue-node-access': <tokens> }`, ready to
162
+ // spread into a GraphQL fetch's headers so the unlocked collection resolves.
163
+ //
164
+ // Reading cookies() opts the route into dynamic rendering, so call this only
165
+ // when it's actually needed — after a public fetch comes back
166
+ // password-protected — to keep non-protected font pages static/cacheable. The
167
+ // catch mirrors readPreviewHeaders: re-throw Next's control-flow bailout so a
168
+ // protected page renders dynamically instead of being prerendered cookie-blind
169
+ // (which would serve the locked form to a visitor who has unlocked it).
170
+ export async function nodeAccessHeaders() {
171
+ try {
172
+ var _await$cookies$get;
173
+ const value = (_await$cookies$get = (await cookies()).get(NODE_ACCESS_COOKIE)) === null || _await$cookies$get === void 0 ? void 0 : _await$cookies$get.value;
174
+ return value ? {
175
+ [NODE_ACCESS_HEADER]: value
176
+ } : {};
177
+ } catch (error) {
178
+ unstable_rethrow(error);
179
+ return {};
180
+ }
181
+ }
182
+
158
183
  // The admin preview token for this render, as Authorization headers, or
159
184
  // undefined when not previewing. Draft mode gates it: reading draftMode().
160
185
  // isEnabled is static-safe (it returns false during static generation and only
@@ -182,9 +207,9 @@ export async function __prepareFontdueRender(props) {
182
207
  // so unstable_rethrow is a no-op and we fall through to "not previewing".
183
208
  async function readPreviewHeaders() {
184
209
  try {
185
- var _await$cookies$get;
210
+ var _await$cookies$get2;
186
211
  if (!(await draftMode()).isEnabled) return undefined;
187
- const token = (_await$cookies$get = (await cookies()).get(PREVIEW_TOKEN_COOKIE)) === null || _await$cookies$get === void 0 ? void 0 : _await$cookies$get.value;
212
+ const token = (_await$cookies$get2 = (await cookies()).get(PREVIEW_TOKEN_COOKIE)) === null || _await$cookies$get2 === void 0 ? void 0 : _await$cookies$get2.value;
188
213
  return token ? previewAuthHeaders(token) : undefined;
189
214
  } catch (error) {
190
215
  unstable_rethrow(error);
@@ -0,0 +1,18 @@
1
+ export declare const NODE_ACCESS_COOKIE = "fontdue_node_access";
2
+ export declare const NODE_ACCESS_HEADER = "fontdue-node-access";
3
+ /** The node-access cookie value from a Cookie header string, or undefined. */
4
+ export declare function readNodeAccessCookie(cookieHeader: string | null | undefined): string | undefined;
5
+ /**
6
+ * The node-access forwarding header for a Cookie header string, or {} when the
7
+ * cookie is absent. Spread into a server-side GraphQL fetch's headers so a
8
+ * collection the visitor unlocked resolves for them. Returns {} for public
9
+ * (no-cookie) requests, leaving them untouched and cacheable.
10
+ */
11
+ export declare function nodeAccessHeadersFromCookie(cookieHeader: string | null | undefined): Record<string, string>;
12
+ /**
13
+ * Remember a freshly minted access token in the browser's node-access cookie,
14
+ * preserving any collections already unlocked. First-party and JS-readable on
15
+ * purpose (see the module note); SameSite=Lax so it rides top-level navigations,
16
+ * Secure only on https so it still persists in local dev. No-op off the browser.
17
+ */
18
+ export declare function rememberNodeAccessToken(token: string | null | undefined): void;
@@ -0,0 +1,84 @@
1
+ // Per-collection node-access contract.
2
+ //
3
+ // When a visitor unlocks a password-protected collection (the `accessNode`
4
+ // mutation), the server returns a signed token. The browser remembers it in a
5
+ // first-party cookie, and a server-rendered storefront forwards that cookie to
6
+ // GraphQL as a request header so the collection keeps resolving for this visitor.
7
+ //
8
+ // This mirrors the admin-preview token contract (see ./preview/constants), with
9
+ // one deliberate difference: the node-access token is low sensitivity — it only
10
+ // re-grants access the visitor already earned by entering the password — so it is
11
+ // a plain, JS-readable first-party cookie the form sets directly in the browser,
12
+ // not an httpOnly cookie brokered through a server route. That keeps the
13
+ // template surface tiny: no `/api/...` route to mount, just the form plus the
14
+ // transport every framework already runs for preview.
15
+ //
16
+ // Kept free of Web/Node APIs (only `document`, behind a guard) so it can be
17
+ // imported from both server transport and the client component.
18
+
19
+ // First-party cookie the browser carries after unlocking. Value is a
20
+ // comma-separated list of signed tokens, one per unlocked collection.
21
+ export const NODE_ACCESS_COOKIE = 'fontdue_node_access';
22
+
23
+ // Request header a server-rendered storefront forwards the cookie value as, read
24
+ // back by the Fontdue GraphQL server (see FontageWeb.Schema.Context).
25
+ export const NODE_ACCESS_HEADER = 'fontdue-node-access';
26
+
27
+ // ~30 days, matching the token's `max_age` on the server (Fontage.Token
28
+ // `:node_access`). The cookie and the token expire together.
29
+ const NODE_ACCESS_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
30
+
31
+ // Cap on how many unlock tokens the cookie carries. The value is a
32
+ // comma-separated list of ~150-byte signed tokens, so an unbounded list would
33
+ // eventually pass the browser's ~4 KB per-cookie limit and be dropped whole —
34
+ // silently losing every unlock. We keep the most recent few (oldest are also
35
+ // the closest to their 30-day expiry, so the server would drop them anyway) and
36
+ // evict from the front. A visitor unlocking more than this many collections is
37
+ // well outside the intended "share a handful of drafts" use.
38
+ const MAX_NODE_ACCESS_TOKENS = 16;
39
+
40
+ /** The node-access cookie value from a Cookie header string, or undefined. */
41
+ export function readNodeAccessCookie(cookieHeader) {
42
+ if (!cookieHeader) return undefined;
43
+ for (const part of cookieHeader.split(';')) {
44
+ const idx = part.indexOf('=');
45
+ if (idx === -1) continue;
46
+ if (part.slice(0, idx).trim() !== NODE_ACCESS_COOKIE) continue;
47
+ const value = part.slice(idx + 1).trim();
48
+ return value === '' ? undefined : value;
49
+ }
50
+ return undefined;
51
+ }
52
+
53
+ /**
54
+ * The node-access forwarding header for a Cookie header string, or {} when the
55
+ * cookie is absent. Spread into a server-side GraphQL fetch's headers so a
56
+ * collection the visitor unlocked resolves for them. Returns {} for public
57
+ * (no-cookie) requests, leaving them untouched and cacheable.
58
+ */
59
+ export function nodeAccessHeadersFromCookie(cookieHeader) {
60
+ const value = readNodeAccessCookie(cookieHeader);
61
+ return value ? {
62
+ [NODE_ACCESS_HEADER]: value
63
+ } : {};
64
+ }
65
+
66
+ /**
67
+ * Remember a freshly minted access token in the browser's node-access cookie,
68
+ * preserving any collections already unlocked. First-party and JS-readable on
69
+ * purpose (see the module note); SameSite=Lax so it rides top-level navigations,
70
+ * Secure only on https so it still persists in local dev. No-op off the browser.
71
+ */
72
+ export function rememberNodeAccessToken(token) {
73
+ if (typeof document === 'undefined' || !token) return;
74
+ const existing = readNodeAccessCookie(document.cookie);
75
+ const tokens = existing ? existing.split(',').filter(Boolean) : [];
76
+ if (!tokens.includes(token)) tokens.push(token);
77
+ // Keep the list bounded so it can't grow past the browser's cookie-size limit
78
+ // and get dropped wholesale; the newest unlock is always retained.
79
+ if (tokens.length > MAX_NODE_ACCESS_TOKENS) {
80
+ tokens.splice(0, tokens.length - MAX_NODE_ACCESS_TOKENS);
81
+ }
82
+ const secure = typeof location !== 'undefined' && location.protocol === 'https:' ? '; Secure' : '';
83
+ document.cookie = `${NODE_ACCESS_COOKIE}=${tokens.join(',')}; Path=/; SameSite=Lax${secure}; Max-Age=${NODE_ACCESS_MAX_AGE_SECONDS}`;
84
+ }
@@ -1,20 +1 @@
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>;
1
+ export { runWithPreview, isPreviewing, ambientHeaders as ambientPreviewHeaders, } from '../server/middleware.js';
@@ -1,89 +1,20 @@
1
- // Ambient (request-scoped) preview for non-RSC server frameworks.
1
+ // Ambient (request-scoped) admin preview for non-RSC server frameworks.
2
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.
3
+ // The ambient-context machinery moved to ../server/middleware, because it is
4
+ // shared by admin preview and the per-collection password unlock (and a
5
+ // storefront usually wants both). This module re-exports the preview-facing
6
+ // pieces so existing `fontdue-js/preview/server` imports keep working:
7
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:
8
+ // - runWithPreview forward only the admin preview token (reveals
9
+ // unpublished fonts). Mount it in your framework's request middleware.
10
+ // - isPreviewing / ambientPreviewHeaders read the current ambient state.
15
11
  //
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';
12
+ // To also forward a visitor's password-unlock token, mount the precomposed
13
+ // `runWithFontdue` from `fontdue-js/server/middleware` in place of
14
+ // runWithPreview (or compose runWithPreview with runWithNodeAccess yourself).
15
+ // See the password-protected collections guide.
68
16
 
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
- }
17
+ export { runWithPreview, isPreviewing,
18
+ // Historical name kept for back-compat; returns the full ambient header set
19
+ // (preview and/or node-access), so it works under runWithFontdue too.
20
+ ambientHeaders as ambientPreviewHeaders } from '../server/middleware.js';
@@ -2,12 +2,13 @@ import { Environment, Network, RecordSource, Store, QueryResponseCache } from 'r
2
2
  import { handlePossibleCorsError } from '../corsError.js';
3
3
  import { resolveFontdueServerConfig } from './serverConfig.js';
4
4
  import { PREVIEW_HEADER, hasPreviewMarkerCookie } from '../preview/constants.js';
5
+ import { NODE_ACCESS_HEADER } from '../nodeAccess.js';
5
6
 
6
7
  // `__FONTDUE_JS_VERSION__` is replaced by an inline babel plugin
7
8
  // (defineVersionPlugin in .babelrc.cjs) with the literal package.json#version.
8
9
  // Exported so UI (the admin toolbar) can surface it without re-reading the
9
10
  // build-time global in a 'use client' module.
10
- export const version = "3.0.6";
11
+ export const version = "3.1.0";
11
12
  const IS_SERVER = typeof window === typeof undefined;
12
13
 
13
14
  // Opt server fetches into Next's data cache only in production; dev stays
@@ -91,6 +92,11 @@ export function createNetworkFetch(options) {
91
92
  const previewing = IS_SERVER ? headers.authorization != null || headers.Authorization != null : hasPreviewMarkerCookie();
92
93
  headers[PREVIEW_HEADER] = previewing ? 'true' : 'false';
93
94
 
95
+ // A forwarded node-access token (a collection this visitor unlocked) makes
96
+ // the server response per-visitor just like preview, so it must never be
97
+ // written to or served from the shared data cache.
98
+ const perVisitor = previewing || IS_SERVER && headers[NODE_ACCESS_HEADER] != null;
99
+
94
100
  // Public server fetches are all site content (per-session data is fetched
95
101
  // client-side only), so opt them into Next's data cache — without this,
96
102
  // Next 15 treats them as no-store and silently makes every page fully
@@ -113,7 +119,7 @@ export function createNetworkFetch(options) {
113
119
  variables
114
120
  })
115
121
  };
116
- if (IS_SERVER && (previewing || !cacheInProduction())) {
122
+ if (IS_SERVER && (perVisitor || !cacheInProduction())) {
117
123
  init.cache = 'no-store';
118
124
  } else if (IS_SERVER) {
119
125
  init.cache = 'force-cache';
@@ -8,7 +8,10 @@ export interface FontdueServerConfig {
8
8
  }
9
9
  type MaybePromise<T> = T | Promise<T>;
10
10
  type AmbientConfigResolver = () => MaybePromise<FontdueServerConfig | undefined>;
11
+ type NodeAccessRecovery = () => MaybePromise<Record<string, string>>;
11
12
  export declare function setFontdueServerConfig(config: FontdueServerConfig): void;
12
13
  export declare function registerAmbientConfigResolver(resolver: AmbientConfigResolver): void;
14
+ export declare function registerNodeAccessRecovery(recovery: NodeAccessRecovery): void;
15
+ export declare function resolveNodeAccessRecovery(): NodeAccessRecovery | undefined;
13
16
  export declare function resolveFontdueServerConfig(): Promise<FontdueServerConfig | undefined>;
14
17
  export {};
@@ -26,6 +26,9 @@
26
26
 
27
27
  import * as React from 'react';
28
28
 
29
+ // Recovers a visitor's per-collection node-access headers when a server fetch
30
+ // comes back `password_protected` — see registerNodeAccessRecovery below.
31
+
29
32
  // This module owns two pieces of cross-cutting state — the render-scoped slot
30
33
  // and the ambient resolver — and both must be shared by every server-side
31
34
  // fetch in a render. The catch: a Next.js server build can bundle this same
@@ -58,7 +61,8 @@ function store() {
58
61
  })) : () => ({
59
62
  current: undefined
60
63
  }),
61
- ambientResolver: undefined
64
+ ambientResolver: undefined,
65
+ nodeAccessRecovery: undefined
62
66
  };
63
67
  g[STORE_KEY] = s;
64
68
  }
@@ -84,6 +88,23 @@ export function registerAmbientConfigResolver(resolver) {
84
88
  store().ambientResolver = resolver;
85
89
  }
86
90
 
91
+ // Node-access recovery — the seam that lets the browser-safe transport recover
92
+ // from a `password_protected` error without importing a framework's request
93
+ // APIs. The Next adapter registers a recovery that reads the visitor's
94
+ // node-access cookie (via next/headers); createFontdueFetch calls it when a
95
+ // fetch comes back locked, folds the returned headers into the per-render config
96
+ // so the page fetch AND the embedded server components' own preloads resolve,
97
+ // then retries once (see ../server/index.ts). Returns {} when the visitor hasn't
98
+ // unlocked the collection. Unregistered (non-Next, or frameworks that forward
99
+ // the token ambiently up-front via runWithNodeAccess) → no recovery, the error
100
+ // propagates so the page can render the password form.
101
+ export function registerNodeAccessRecovery(recovery) {
102
+ store().nodeAccessRecovery = recovery;
103
+ }
104
+ export function resolveNodeAccessRecovery() {
105
+ return store().nodeAccessRecovery;
106
+ }
107
+
87
108
  // Merge an ambient config under the RSC slot: the slot wins for scalar fields;
88
109
  // headers merge with the slot overriding.
89
110
  function mergeServerConfig(ambient, slot) {
@@ -2,6 +2,14 @@ export declare class FontdueNotFoundError extends Error {
2
2
  readonly queryName: string;
3
3
  constructor(queryName: string);
4
4
  }
5
+ export declare class FontdueGraphqlError extends Error {
6
+ readonly code: string | undefined;
7
+ readonly queryName: string;
8
+ constructor(message: string, code: string | undefined, queryName: string);
9
+ }
10
+ export declare class FontduePasswordProtectedError extends FontdueGraphqlError {
11
+ constructor(message: string, queryName: string);
12
+ }
5
13
  export interface CreateFontdueFetchOptions {
6
14
  /**
7
15
  * GraphQL base URL (without the trailing /graphql), e.g.
@@ -45,7 +45,7 @@
45
45
  // The Next fetch hints are inert in other runtimes: Node's fetch accepts and
46
46
  // ignores the cache mode, and `next` is just an unknown init property.
47
47
 
48
- import { resolveFontdueServerConfig } from '../relay/serverConfig.js';
48
+ import { resolveFontdueServerConfig, setFontdueServerConfig, resolveNodeAccessRecovery } from '../relay/serverConfig.js';
49
49
  import { PREVIEW_HEADER } from '../preview/constants.js';
50
50
  function readEnv(name) {
51
51
  if (typeof process !== 'undefined' && process.env) {
@@ -87,6 +87,30 @@ export class FontdueNotFoundError extends Error {
87
87
  this.name = 'FontdueNotFoundError';
88
88
  }
89
89
  }
90
+
91
+ // Thrown when a GraphQL response carries an `errors` entry. Subclasses Error so
92
+ // existing `catch`/rethrow keeps working, but also exposes the machine-readable
93
+ // `code` from the first error (e.g. "password_protected") so callers can branch
94
+ // on a specific failure instead of string-matching the message.
95
+ export class FontdueGraphqlError extends Error {
96
+ constructor(message, code, queryName) {
97
+ super(message);
98
+ this.code = code;
99
+ this.queryName = queryName;
100
+ this.name = 'FontdueGraphqlError';
101
+ }
102
+ }
103
+
104
+ // Thrown when the requested collection is password-protected and the visitor
105
+ // hasn't unlocked it. Catch it to render a <NodePasswordForm> instead of a 404:
106
+ // the collection exists, it's just gated. The matching server signal is the
107
+ // GraphQL error code "password_protected" (see the Fontdue slug resolver).
108
+ export class FontduePasswordProtectedError extends FontdueGraphqlError {
109
+ constructor(message, queryName) {
110
+ super(message, 'password_protected', queryName);
111
+ this.name = 'FontduePasswordProtectedError';
112
+ }
113
+ }
90
114
  /**
91
115
  * Build a server-side GraphQL fetcher bound to a base URL and a set of headers.
92
116
  * The returned function takes the operation name, the query text (load it from
@@ -94,8 +118,10 @@ export class FontdueNotFoundError extends Error {
94
118
  */
95
119
  export function createFontdueFetch() {
96
120
  let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
97
- return async function fetchGraphql(queryName, query, variables) {
98
- var _json$errors, _json$errors$;
121
+ async function runOnce(queryName, query, variables) {
122
+ let {
123
+ forceUncached = false
124
+ } = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : {};
99
125
  // Per-render config (a runWithPreview AsyncLocalStorage store, the Next
100
126
  // render-scoped slot, or the Next single-tenant ambient resolver). Awaited
101
127
  // per call so a single module-level fetcher picks up the current request's
@@ -111,7 +137,9 @@ export function createFontdueFetch() {
111
137
  // Cache tags: explicit option wins, else the per-render config's. A
112
138
  // non-empty list opts the fetch into Next's data cache (inert elsewhere);
113
139
  // an empty/absent list leaves it uncached (preview + CDN-cached frameworks).
114
- const cacheTags = options.cacheTags ?? (config === null || config === void 0 ? void 0 : config.cacheTags);
140
+ // A forced-uncached retry (an unlocked, per-visitor render) drops them
141
+ // entirely so nothing tags the response into the shared cache.
142
+ const cacheTags = forceUncached ? undefined : options.cacheTags ?? (config === null || config === void 0 ? void 0 : config.cacheTags);
115
143
 
116
144
  // Ambient headers (e.g. tenant + preview token), overridden by any explicit
117
145
  // per-fetcher headers.
@@ -151,10 +179,65 @@ export function createFontdueFetch() {
151
179
  throw new Error(`Fontdue request failed: ${response.status}`);
152
180
  }
153
181
  const json = await response.json();
154
- const errorMessage = (_json$errors = json.errors) === null || _json$errors === void 0 ? void 0 : (_json$errors$ = _json$errors[0]) === null || _json$errors$ === void 0 ? void 0 : _json$errors$.message;
155
- if (errorMessage) {
156
- throw new Error(`Fontdue GraphQL error: ${errorMessage}`);
182
+ const errors = json.errors ?? [];
183
+ if (errors.length > 0) {
184
+ // Absinthe puts a resolver's error `code` on the error itself; some errors
185
+ // carry it under `extensions` instead — check both.
186
+ const codeOf = e => {
187
+ var _e$extensions;
188
+ return e.code ?? ((_e$extensions = e.extensions) === null || _e$extensions === void 0 ? void 0 : _e$extensions.code);
189
+ };
190
+ // Look across every error, not just the first: a query that touches a
191
+ // locked collection alongside another failing field can return
192
+ // `password_protected` in any position, and the recovery + the app's
193
+ // `instanceof FontduePasswordProtectedError` catch both depend on it being
194
+ // recognized regardless of order.
195
+ const locked = errors.find(e => codeOf(e) === 'password_protected');
196
+ if (locked) {
197
+ throw new FontduePasswordProtectedError(`Fontdue GraphQL error: ${locked.message}`, queryName);
198
+ }
199
+ const first = errors[0];
200
+ throw new FontdueGraphqlError(`Fontdue GraphQL error: ${first.message}`, codeOf(first), queryName);
157
201
  }
158
202
  return json.data;
203
+ }
204
+ return async function fetchGraphql(queryName, query, variables) {
205
+ try {
206
+ return await runOnce(queryName, query, variables);
207
+ } catch (error) {
208
+ // A password-protected collection comes back as a `password_protected`
209
+ // error. In a server render, recover transparently: a registered recovery
210
+ // (the Next adapter) reads the visitor's node-access cookie. If they've
211
+ // unlocked it, fold the token into the per-render config — so this fetch
212
+ // AND the page's embedded server components' own (Suspense) preloads
213
+ // resolve, not just this one call — and retry once. Reading the cookie is
214
+ // deferred to here (only when a fetch is actually gated) so non-protected
215
+ // pages stay static/cacheable; the retry runs `forceUncached` so the
216
+ // unlocked, per-visitor render is never written to the shared cache.
217
+ // Frameworks that forward the token ambiently up-front (runWithNodeAccess)
218
+ // never reach this path. With no recovery registered, or no token, the
219
+ // error propagates so the page can render the password form.
220
+ if (error instanceof FontduePasswordProtectedError) {
221
+ const recover = resolveNodeAccessRecovery();
222
+ if (recover) {
223
+ const nodeAccess = await recover();
224
+ if (Object.keys(nodeAccess).length > 0) {
225
+ const config = (await resolveFontdueServerConfig()) ?? {};
226
+ setFontdueServerConfig({
227
+ ...config,
228
+ headers: {
229
+ ...config.headers,
230
+ ...nodeAccess
231
+ },
232
+ cacheTags: undefined
233
+ });
234
+ return await runOnce(queryName, query, variables, {
235
+ forceUncached: true
236
+ });
237
+ }
238
+ }
239
+ }
240
+ throw error;
241
+ }
159
242
  };
160
243
  }
@@ -0,0 +1,37 @@
1
+ /** Whether the current async context is an admin preview. */
2
+ export declare function isPreviewing(): boolean;
3
+ /**
4
+ * Request-scoped Fontdue headers for the current async context (admin preview
5
+ * and/or the visitor's node-access token), or {} when there are none. Spread
6
+ * into a hand-rolled fetch you make inside a runWith* middleware; the fontdue-js
7
+ * fetch/preload helpers pick these up automatically and don't need it.
8
+ */
9
+ export declare function ambientHeaders(): Record<string, string>;
10
+ /**
11
+ * Run `next` (your framework's render/loader chain) with the request's admin
12
+ * preview token in ambient scope, then return its Response. While active, every
13
+ * fontdue-js server fetch forwards the token (reveals unpublished fonts) with no
14
+ * per-call plumbing; when previewing, the response is forced out of any
15
+ * shared/CDN cache. Public requests (no preview cookie) pass through untouched
16
+ * and stay fully cacheable. For the visitor password-unlock token use
17
+ * runWithNodeAccess; for both, runWithFontdue.
18
+ */
19
+ export declare function runWithPreview(request: Request, next: () => Response | Promise<Response>): Promise<Response>;
20
+ /**
21
+ * Run `next` with the visitor's per-collection node-access token in ambient
22
+ * scope, then return its Response. The token is read from the first-party cookie
23
+ * the password form set; while active, every fontdue-js server fetch forwards it
24
+ * so a collection the visitor unlocked resolves for them. A request carrying an
25
+ * unlock is per-visitor, so its response is forced out of any shared/CDN cache —
26
+ * an unlocked render is never served to someone who hasn't unlocked. Requests
27
+ * with no node-access cookie pass through untouched and stay cacheable.
28
+ */
29
+ export declare function runWithNodeAccess(request: Request, next: () => Response | Promise<Response>): Promise<Response>;
30
+ /**
31
+ * The precomposed middleware most storefronts want: forwards the admin preview
32
+ * token AND the visitor's node-access token, each handled by its own
33
+ * single-responsibility middleware. Equivalent to nesting
34
+ * `runWithPreview(request, () => runWithNodeAccess(request, next))` — mount this
35
+ * in place of runWithPreview to get the password-unlock forwarding too.
36
+ */
37
+ export declare function runWithFontdue(request: Request, next: () => Response | Promise<Response>): Promise<Response>;