fontdue-js 3.0.0-alpha11 → 3.0.0-alpha12

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 CHANGED
@@ -1,3 +1,17 @@
1
+ ## 3.0.0
2
+
3
+ In alpha on the `alpha` dist-tag — install with `npm install fontdue-js@alpha`.
4
+
5
+ - **Framework-agnostic.** fontdue-js now works in any React SSR or client-only environment — Astro, React Router 7, TanStack Start, Vike, Remix, and the existing Next.js App Router — not just Next.js. Each supported framework has a worked example repo (see the README "Examples"). The package is now ESM-only and ships an `exports` map, so TypeScript consumers need `moduleResolution` set to `bundler`, `node16`, or `nodenext`.
6
+ - **Admin preview for every framework.** A preview toolbar — rendered by `<FontdueProvider>` and shown only to logged-in admins — reveals hidden (unpublished) fonts site-wide. New entry points implement a portable contract:
7
+ - `fontdue-js/preview` — `handlePreviewRequest` (a Web-standard enter/exit route handler), `readPreviewToken`, `previewAuthHeaders`, and the cookie/endpoint constants.
8
+ - `fontdue-js/preview/server` — `runWithPreview`, which holds the preview token in `AsyncLocalStorage` for the duration of a request so every server fetch and preload forwards it automatically, and forces preview responses out of shared/CDN caches so an admin's render is never served to the public.
9
+
10
+ On Next.js (which has no app-installable ambient context) the adapter's `prepareFontdueRender` folds the draft-mode token into the per-render config instead — the same effect, so the embedded components reveal hidden fonts server-side. See the README "Admin preview".
11
+ - **One server fetch for every framework.** `fontdue-js/server` exports `createFontdueFetch({ url?, headers?, cacheTags? })` — a ready-made server-side GraphQL fetcher with URL resolution, error handling, `FontdueNotFoundError`, and automatic preview-token forwarding. Each input resolves per call from the explicit option, the per-render config (`runWithPreview` or the Next adapter), then the environment, and passing `cacheTags` opts the fetch into Next's data cache + `/api/revalidate` (inert in other runtimes) — so Next and the other frameworks now share the same fetcher rather than Next hand-rolling its own. See the README "Server-side GraphQL fetches".
12
+ - **Server-rendered embeds.** Components now render their full HTML on the server where the framework supports it (v2 hydrated some empty and fetched on the client), via the `load{Component}Query()` preload helpers.
13
+ - **Migrating from v2 (Next.js)** is a small, mechanical upgrade — see the README "Migrating a Next.js site from v2". `useFontStyle` is renamed to `useFont` (the old name still works as an alias).
14
+
1
15
  ## 2.28.0
2
16
 
3
17
  - Checkout now captures the buyer's **analytics consent and ad attribution** on the order. When the cart or store-modal checkout opens, fontdue-js sends the consent-banner state, anonymous ID, Meta browser IDs (the `_fbp`/`_fbc` cookies), and the page URL to the server, where they're stored on the order. This lets Fontdue emit a server-side *purchase* conversion event (Facebook Conversions API, Google Ads) when the order completes — completion happens in a Stripe webhook, outside the browser — while respecting the buyer's cookie-consent choice: if consent was declined, no identifiers are sent and nothing is forwarded to ad platforms. The call is fire-and-forget and never affects checkout.
package/README.md CHANGED
@@ -417,27 +417,26 @@ fontdue-js's own server-side fetches opt into Next's data cache (and the `graphq
417
417
 
418
418
  ### Your own GraphQL fetches
419
419
 
420
- `currentFontdueEndpoint()` (from `fontdue-js/next`) describes the endpoint your own server-side [GraphQL](https://docs.fontdue.com/graphql-api) fetches should use — the base origin, required headers, and the cache tags that tie them into `/api/revalidate`:
420
+ Use the same [`createFontdueFetch`](#server-side-graphql-fetches) as every other framework, and give it the current render's endpoint so it ties into Next's data cache and `/api/revalidate`. `currentFontdueEndpoint()` (from `fontdue-js/next`) describes that endpoint — the base origin, required headers, and the per-site cache tags:
421
421
 
422
422
  ```ts
423
+ import { createFontdueFetch } from "fontdue-js/server";
423
424
  import { currentFontdueEndpoint } from "fontdue-js/next";
424
425
 
425
- export async function fetchGraphql(query: string, variables?: unknown) {
426
+ export async function fetchGraphql<Q>(name: string, query: string, variables?: unknown) {
426
427
  const endpoint = currentFontdueEndpoint();
427
- const response = await fetch(`${endpoint.origin}/graphql`, {
428
- method: "POST",
429
- body: JSON.stringify({ query, variables }),
430
- headers: { "content-type": "application/json", ...endpoint.headers },
431
- // Cache explicitly (Next 15 doesn't cache fetch by default), tagged so
432
- // the deploy hook purges this too.
433
- cache: "force-cache",
434
- next: { tags: endpoint.tags },
428
+ const fetchFontdue = createFontdueFetch({
429
+ url: endpoint.origin,
430
+ headers: endpoint.headers,
431
+ // Opts the fetch into Next's data cache (force-cache) and tags it so the
432
+ // deploy hook purges it. Drop cacheTags (pass `[]`) on a preview render.
433
+ cacheTags: endpoint.tags,
435
434
  });
436
- return (await response.json()).data;
435
+ return fetchFontdue<Q>(name, query, variables);
437
436
  }
438
437
  ```
439
438
 
440
- The shape is exported as `type FontdueEndpoint`.
439
+ `currentFontdueEndpoint()` resolves the per-render tenant in multi-tenant mode (after `prepareFontdueRender`) or the `NEXT_PUBLIC_FONTDUE_URL` site otherwise; the shape is exported as `type FontdueEndpoint`. Route handlers render outside React, so pass the endpoint they got from `prepareFontdueRender` explicitly. For preview, see [Admin preview → Next.js](#nextjs).
441
440
 
442
441
  ## Migrating a Next.js site from v2
443
442
 
@@ -469,7 +468,7 @@ For a site built on the [example repo](https://github.com/fontdue/example-next)
469
468
 
470
469
  Keep the Deploy hook URL in your Fontdue admin pointed at it.
471
470
 
472
- 4. **Delete caching workarounds you no longer need.** `export const fetchCache = "default-cache"` in the layout (if you added it) is obsolete — fontdue-js opts its own fetches into the data cache now. Your app's own GraphQL fetches should pass `cache: "force-cache"` and `next: { tags: endpoint.tags }` explicitly — see [Your own GraphQL fetches](#your-own-graphql-fetches).
471
+ 4. **Delete caching workarounds you no longer need.** `export const fetchCache = "default-cache"` in the layout (if you added it) is obsolete — fontdue-js opts its own fetches into the data cache now. For your app's own GraphQL fetches, move the transport to `createFontdueFetch` and pass it `cacheTags: endpoint.tags` so caching and `/api/revalidate` are handled for you — see [Your own GraphQL fetches](#your-own-graphql-fetches).
473
472
 
474
473
  5. **Remove the `url` prop from `<FontdueProvider>` if you passed one.** It never configured server-side fetches (server components have no context); v3 resolves everything from `NEXT_PUBLIC_FONTDUE_URL`. The prop still works as a client-side runtime override, but with the env var set you don't need it.
475
474
 
@@ -479,6 +478,159 @@ What you get for it: server components now render the embeds' full HTML on the s
479
478
 
480
479
  If you're starting fresh instead of migrating, fork the example repo — it ships in this shape already.
481
480
 
481
+ ## Server-side GraphQL fetches
482
+
483
+ Beyond the preload helpers, you'll often run your own [GraphQL](https://docs.fontdue.com/graphql-api) queries server-side — page chrome, metadata, custom sections. `fontdue-js/server` exports a ready-made fetcher so you don't hand-roll the transport:
484
+
485
+ ```ts
486
+ import { createFontdueFetch } from "fontdue-js/server";
487
+
488
+ // One fetcher for the whole app. Resolves the Fontdue URL from the environment
489
+ // (FONTDUE_URL / PUBLIC_FONTDUE_URL / VITE_FONTDUE_URL).
490
+ export const fetchGraphql = createFontdueFetch();
491
+
492
+ // In a loader / frontmatter / server component:
493
+ const data = await fetchGraphql<IndexQuery>("Index", indexQuery, { slug });
494
+ ```
495
+
496
+ `createFontdueFetch({ url?, headers?, cacheTags? })` returns `fetchGraphql(operationName, query, variables?)`. It POSTs to `/graphql`, unwraps `data`, throws on GraphQL errors, and throws `FontdueNotFoundError` when the host doesn't resolve to a site — catch it to render your framework's 404. It's the same fetcher in every framework; each input resolves per call from the explicit option, then the per-render config (set by `runWithPreview` or the Next adapter), then the environment:
497
+
498
+ - **url** — option → per-render tenant URL → `FONTDUE_URL` / `PUBLIC_FONTDUE_URL` / `VITE_FONTDUE_URL`.
499
+ - **headers** — merged ambient + explicit (explicit wins); this is how the [admin preview](#admin-preview) token is forwarded automatically.
500
+ - **cacheTags** — when present, the fetch opts into Next's data cache (`force-cache` + tags) so `/api/revalidate` can purge it; absent/empty leaves it uncached. The Next hints are inert in other runtimes, where HTML is cached at the response/CDN layer instead.
501
+
502
+ **Upgrading a hand-rolled fetch.** If you already have something like this:
503
+
504
+ ```ts
505
+ // Before — hand-rolled.
506
+ async function fetchGraphql(name, query, variables) {
507
+ const res = await fetch(`${import.meta.env.PUBLIC_FONTDUE_URL}/graphql`, {
508
+ method: "POST",
509
+ headers: { "content-type": "application/json" },
510
+ body: JSON.stringify({ query, variables }),
511
+ });
512
+ const json = await res.json();
513
+ if (json.errors) throw new Error(json.errors[0].message);
514
+ return json.data;
515
+ }
516
+ ```
517
+
518
+ replace it with:
519
+
520
+ ```ts
521
+ // After.
522
+ import { createFontdueFetch } from "fontdue-js/server";
523
+ export const fetchGraphql = createFontdueFetch();
524
+ ```
525
+
526
+ You get URL resolution, error handling, `FontdueNotFoundError`, and automatic preview-token forwarding for free.
527
+
528
+ > **Next.js:** the same `createFontdueFetch` — pass it the endpoint's `cacheTags` so it ties into Next's data cache and the `/api/revalidate` deploy hook. See [Your own GraphQL fetches](#your-own-graphql-fetches) under the Next adapter.
529
+
530
+ ## Admin preview
531
+
532
+ Logged-in Fontdue admins get a preview toolbar — rendered automatically by `<FontdueProvider>`, hidden for everyone else — that reveals **hidden (unpublished) fonts** across the whole site. The toolbar brokers a short-lived admin token and POSTs it to a small preview route on your own origin; from then on, server renders forward the token so GraphQL returns the unpublished content. The public never has the cookie, so their renders stay sessionless and cacheable.
533
+
534
+ Two entry points cover this: `fontdue-js/preview` (the portable cookie contract) and `fontdue-js/preview/server` (the ambient wiring).
535
+
536
+ **1. Add the preview route** at `/api/preview` — the toolbar POSTs to enter preview and DELETEs to exit. `handlePreviewRequest` is a Web-standard `Request → Response` handler, so it drops into any Fetch-API framework:
537
+
538
+ ```ts
539
+ // Astro — src/pages/api/preview.ts
540
+ import { handlePreviewRequest } from "fontdue-js/preview";
541
+ export const ALL = ({ request }) => handlePreviewRequest(request);
542
+ export const prerender = false;
543
+ ```
544
+
545
+ ```ts
546
+ // React Router 7 — app/routes/api.preview.ts
547
+ import { handlePreviewRequest } from "fontdue-js/preview";
548
+ export const action = ({ request }) => handlePreviewRequest(request);
549
+ ```
550
+
551
+ The path is configurable via `config.preview.endpoint` on `<FontdueProvider>` (default `/api/preview`) — mount the route to match.
552
+
553
+ **2. Forward the token on server renders.** The recommended way is ambient: wrap each request in `runWithPreview` (from `fontdue-js/preview/server`) in your framework's middleware. While it's active, every `createFontdueFetch` call and every `load*Query()` preload forwards the token automatically — no per-call plumbing — and preview responses are forced out of any shared/CDN cache so an admin's render is never served to the public.
554
+
555
+ ```ts
556
+ // Astro — src/middleware.ts
557
+ import { runWithPreview } from "fontdue-js/preview/server";
558
+ export const onRequest = (ctx, next) => runWithPreview(ctx.request, next);
559
+ ```
560
+
561
+ ```ts
562
+ // React Router 7 — root route (with future.v8_middleware enabled)
563
+ import { runWithPreview } from "fontdue-js/preview/server";
564
+ export const middleware = [({ request }, next) => runWithPreview(request, next)];
565
+ ```
566
+
567
+ `runWithPreview` uses `AsyncLocalStorage`, so it works wherever middleware shares a runtime with the render — Node (the default SSR target on Netlify/Vercel), Deno, Bun.
568
+
569
+ **Explicit alternative.** Where the ambient context can't propagate (e.g. middleware running in a separate runtime from the render, such as Astro's `edgeMiddleware: true`), read the token yourself and pass it as the `{ headers }` option, which always overrides the ambient context:
570
+
571
+ ```ts
572
+ import { readPreviewToken, previewAuthHeaders } from "fontdue-js/preview";
573
+
574
+ const headers = previewAuthHeaders(readPreviewToken(request.headers.get("cookie")));
575
+ const fetchGraphql = createFontdueFetch({ headers });
576
+ const preload = await loadTypeTesterQuery(vars, { headers });
577
+ ```
578
+
579
+ `previewAuthHeaders` returns `{}` when there's no token, so it's always safe to pass.
580
+
581
+ ### Next.js
582
+
583
+ Next uses [draft mode](https://nextjs.org/docs/app/building-your-application/configuring/draft-mode) rather than ambient context. The route layers `draftMode()` on top of `handlePreviewRequest`:
584
+
585
+ ```ts
586
+ // app/api/preview/route.ts
587
+ import { draftMode } from "next/headers";
588
+ import { handlePreviewRequest } from "fontdue-js/preview";
589
+
590
+ export async function POST(request: Request) {
591
+ const response = await handlePreviewRequest(request);
592
+ if (response.ok) (await draftMode()).enable();
593
+ return response;
594
+ }
595
+
596
+ export async function DELETE(request: Request) {
597
+ const response = await handlePreviewRequest(request);
598
+ (await draftMode()).disable();
599
+ return response;
600
+ }
601
+ ```
602
+
603
+ Two things then forward the token on a Next render:
604
+
605
+ - **`prepareFontdueRender`** — the call you already make at the top of every page folds the draft-mode token into the per-render config, so the embedded components (type testers, store) reveal hidden fonts server-side automatically. This is the Next counterpart to `runWithPreview`.
606
+ - **Your own fetch** reads draft mode and hands the token to `createFontdueFetch`, dropping the cache tags so the render stays live:
607
+
608
+ ```ts
609
+ import { draftMode, cookies } from "next/headers";
610
+ import { createFontdueFetch } from "fontdue-js/server";
611
+ import { currentFontdueEndpoint } from "fontdue-js/next";
612
+ import { PREVIEW_TOKEN_COOKIE, previewAuthHeaders } from "fontdue-js/preview";
613
+
614
+ export async function fetchGraphql<Q>(name: string, query: string, variables?: unknown) {
615
+ const endpoint = currentFontdueEndpoint();
616
+ // draftMode()/cookies() are request-scoped — guard build-time static
617
+ // generation, where they throw, as "not preview".
618
+ const isPreview = (await draftMode()).isEnabled;
619
+ const token = isPreview ? (await cookies()).get(PREVIEW_TOKEN_COOKIE)?.value : undefined;
620
+
621
+ const fetchFontdue = createFontdueFetch({
622
+ url: endpoint.origin,
623
+ headers: { ...endpoint.headers, ...previewAuthHeaders(token) },
624
+ // Public renders are tagged + cached; preview renders pass no tags so they
625
+ // stay live and reveal hidden fonts.
626
+ cacheTags: isPreview ? [] : endpoint.tags,
627
+ });
628
+ return fetchFontdue<Q>(name, query, variables);
629
+ }
630
+ ```
631
+
632
+ The example repos wire this up end to end for each framework.
633
+
482
634
  ## UI config
483
635
 
484
636
  Most components accept a `config` object that controls UI behavior — type-tester options (`selectable`, `priceBar`, size ranges, OpenType-feature UI…), store-modal layout, form styling, analytics tracking, and more. See the [full config reference](https://docs.fontdue.com/fontduejs#b3dec49aa08240bba2b4c71a67c08333).
@@ -1,9 +1,19 @@
1
- import { describe, it, expect, beforeEach, vi } from 'vitest';
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
2
  import { createFontdueFetch, FontdueNotFoundError } from '../server/index.js';
3
+ import { registerAmbientConfigResolver } from '../relay/serverConfig.js';
3
4
  beforeEach(() => {
4
5
  vi.unstubAllEnvs();
5
6
  vi.unstubAllGlobals();
6
7
  });
8
+
9
+ // Several tests inject a per-render config through the ambient resolver seam
10
+ // (the same seam runWithPreview and the Next slot use). Always clear it.
11
+ afterEach(() => {
12
+ registerAmbientConfigResolver(() => undefined);
13
+ });
14
+ function withConfig(config) {
15
+ registerAmbientConfigResolver(() => config);
16
+ }
7
17
  function mockFetch(impl) {
8
18
  const fetchMock = vi.fn(async (url, init) => impl(url, init));
9
19
  vi.stubGlobal('fetch', fetchMock);
@@ -98,7 +108,115 @@ describe('createFontdueFetch', () => {
98
108
  await fetchGraphql('Q', 'query Q { __typename }');
99
109
  expect(fetchMock.mock.calls[0][0]).toBe('https://env.fontdue.com/graphql?query=Q');
100
110
  });
101
- it('throws a helpful error when no URL is configured', () => {
102
- expect(() => createFontdueFetch()).toThrow(/no Fontdue URL configured/);
111
+ it('throws a helpful error when no URL is configured (resolved per call)', async () => {
112
+ mockFetch(() => ({
113
+ status: 200,
114
+ json: async () => ({
115
+ data: {}
116
+ })
117
+ }));
118
+ const fetchGraphql = createFontdueFetch();
119
+ await expect(fetchGraphql('Q', 'query Q { __typename }')).rejects.toThrow(/no Fontdue URL configured/);
120
+ });
121
+ describe('Next data cache tags', () => {
122
+ it('opts into force-cache + tags when cacheTags are given', async () => {
123
+ var _init$next;
124
+ const fetchMock = mockFetch(() => ({
125
+ status: 200,
126
+ json: async () => ({
127
+ data: {}
128
+ })
129
+ }));
130
+ const fetchGraphql = createFontdueFetch({
131
+ url: 'https://acme.fontdue.com',
132
+ cacheTags: ['graphql:acme.fontdue.com']
133
+ });
134
+ await fetchGraphql('Q', 'query Q { __typename }');
135
+ const init = fetchMock.mock.calls[0][1];
136
+ expect(init.cache).toBe('force-cache');
137
+ // The global `graphql` tag is prepended automatically.
138
+ expect((_init$next = init.next) === null || _init$next === void 0 ? void 0 : _init$next.tags).toEqual(['graphql', 'graphql:acme.fontdue.com']);
139
+ });
140
+ it('leaves the fetch uncached when no cacheTags are given', async () => {
141
+ const fetchMock = mockFetch(() => ({
142
+ status: 200,
143
+ json: async () => ({
144
+ data: {}
145
+ })
146
+ }));
147
+ const fetchGraphql = createFontdueFetch({
148
+ url: 'https://acme.fontdue.com'
149
+ });
150
+ await fetchGraphql('Q', 'query Q { __typename }');
151
+ const init = fetchMock.mock.calls[0][1];
152
+ expect(init.cache).toBeUndefined();
153
+ expect(init.next).toBeUndefined();
154
+ });
155
+ it('treats an empty cacheTags list as uncached (preview renders)', async () => {
156
+ const fetchMock = mockFetch(() => ({
157
+ status: 200,
158
+ json: async () => ({
159
+ data: {}
160
+ })
161
+ }));
162
+ const fetchGraphql = createFontdueFetch({
163
+ url: 'https://acme.fontdue.com',
164
+ cacheTags: []
165
+ });
166
+ await fetchGraphql('Q', 'query Q { __typename }');
167
+ const init = fetchMock.mock.calls[0][1];
168
+ expect(init.cache).toBeUndefined();
169
+ expect(init.next).toBeUndefined();
170
+ });
171
+ });
172
+ describe('per-render config (the Next slot / ambient resolver)', () => {
173
+ it('resolves url, headers and cacheTags from the config', async () => {
174
+ var _init$next2;
175
+ const fetchMock = mockFetch(() => ({
176
+ status: 200,
177
+ json: async () => ({
178
+ data: {}
179
+ })
180
+ }));
181
+ withConfig({
182
+ url: 'https://tenant.fontdue.com',
183
+ headers: {
184
+ 'x-forwarded-host': 'tenant.fontdue.com'
185
+ },
186
+ cacheTags: ['graphql:tenant.fontdue.com']
187
+ });
188
+
189
+ // No options: a module-level fetcher picks up the current render's config.
190
+ const fetchGraphql = createFontdueFetch();
191
+ await fetchGraphql('Q', 'query Q { __typename }');
192
+ const [url, init] = fetchMock.mock.calls[0];
193
+ expect(url).toBe('https://tenant.fontdue.com/graphql?query=Q');
194
+ expect(init.headers['x-forwarded-host']).toBe('tenant.fontdue.com');
195
+ expect(init.cache).toBe('force-cache');
196
+ expect((_init$next2 = init.next) === null || _init$next2 === void 0 ? void 0 : _init$next2.tags).toEqual(['graphql', 'graphql:tenant.fontdue.com']);
197
+ });
198
+ it('explicit options override the config (url and cacheTags)', async () => {
199
+ const fetchMock = mockFetch(() => ({
200
+ status: 200,
201
+ json: async () => ({
202
+ data: {}
203
+ })
204
+ }));
205
+ withConfig({
206
+ url: 'https://tenant.fontdue.com',
207
+ cacheTags: ['graphql:tenant.fontdue.com']
208
+ });
209
+
210
+ // Explicit url + empty cacheTags (e.g. a preview render) win.
211
+ const fetchGraphql = createFontdueFetch({
212
+ url: 'https://explicit.fontdue.com',
213
+ cacheTags: []
214
+ });
215
+ await fetchGraphql('Q', 'query Q { __typename }');
216
+ const [url, init] = fetchMock.mock.calls[0];
217
+ expect(url).toBe('https://explicit.fontdue.com/graphql?query=Q');
218
+ expect(init.cache).toBeUndefined();
219
+ expect(init.next).toBeUndefined();
220
+ });
103
221
  });
104
222
  });
@@ -26,11 +26,30 @@ vi.mock('next/navigation', () => ({
26
26
  throw new Error('NEXT_NOT_FOUND');
27
27
  }
28
28
  }));
29
+
30
+ // Draft mode + the preview token cookie, controllable per test. Default: not
31
+ // previewing, so prepareFontdueRender takes the public (cached) path.
32
+ const draft = vi.hoisted(() => ({
33
+ enabled: false,
34
+ token: undefined
35
+ }));
36
+ vi.mock('next/headers', () => ({
37
+ draftMode: async () => ({
38
+ isEnabled: draft.enabled
39
+ }),
40
+ cookies: async () => ({
41
+ get: name => name === 'fontdue_preview_token' && draft.token ? {
42
+ value: draft.token
43
+ } : undefined
44
+ })
45
+ }));
29
46
  beforeEach(() => {
30
47
  vi.resetModules();
31
48
  revalidateTag.mockClear();
32
49
  vi.unstubAllEnvs();
33
50
  vi.restoreAllMocks();
51
+ draft.enabled = false;
52
+ draft.token = undefined;
34
53
  });
35
54
  function stubSingleTenant() {
36
55
  let url = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 'https://acme.fontdue.com';
@@ -196,6 +215,51 @@ describe('prepareFontdueRender', () => {
196
215
  slug: 'sans'
197
216
  }))).rejects.toThrow('NEXT_NOT_FOUND');
198
217
  });
218
+
219
+ // React.cache doesn't memoize outside an RSC render (see configureFontdueRender
220
+ // above), so the slot write is a no-op here — capture what prepareFontdueRender
221
+ // passes to setFontdueServerConfig instead.
222
+ async function captureRenderConfig(params) {
223
+ let captured;
224
+ vi.doMock('../relay/serverConfig', async importActual => ({
225
+ ...(await importActual()),
226
+ setFontdueServerConfig: c => {
227
+ captured = c;
228
+ }
229
+ }));
230
+ const {
231
+ prepareFontdueRender
232
+ } = await importTenant();
233
+ await prepareFontdueRender(props(params));
234
+ vi.doUnmock('../relay/serverConfig');
235
+ return captured;
236
+ }
237
+ it('public render: sets the tenant config with cache tags and no token', async () => {
238
+ var _config$headers;
239
+ stubMultiTenant({
240
+ origin: 'http://app:4000'
241
+ });
242
+ const config = await captureRenderConfig({
243
+ domain: 'acme.fontdue.com'
244
+ });
245
+ expect(config.cacheTags).toEqual(['graphql:acme.fontdue.com']);
246
+ expect((_config$headers = config.headers) === null || _config$headers === void 0 ? void 0 : _config$headers.authorization).toBeUndefined();
247
+ });
248
+ it('preview render: folds the token into headers and drops cache tags', async () => {
249
+ var _config$headers2;
250
+ stubMultiTenant({
251
+ origin: 'http://app:4000'
252
+ });
253
+ draft.enabled = true;
254
+ draft.token = 'admin-tok';
255
+ const config = await captureRenderConfig({
256
+ domain: 'acme.fontdue.com'
257
+ });
258
+ // Embeds (createNetworkFetch) and the app's fetch (createFontdueFetch) both
259
+ // read this, so both reveal hidden fonts; no tags keeps the render live.
260
+ expect((_config$headers2 = config.headers) === null || _config$headers2 === void 0 ? void 0 : _config$headers2.authorization).toBe('Bearer admin-tok');
261
+ expect(config.cacheTags).toBeUndefined();
262
+ });
199
263
  });
200
264
  describe('currentFontdueEndpoint', () => {
201
265
  it('multi-tenant: throws when no render config was set', async () => {
@@ -25,7 +25,9 @@
25
25
  // NEXT_PUBLIC_FONTDUE_URL at all.
26
26
 
27
27
  import { notFound } from 'next/navigation';
28
+ import { cookies, draftMode } from 'next/headers';
28
29
  import { setFontdueServerConfig, getFontdueServerConfig } from '../relay/serverConfig.js';
30
+ import { PREVIEW_TOKEN_COOKIE, previewAuthHeaders } from '../preview/index.js';
29
31
  export const isMultiTenant = process.env.FONTDUE_MULTI_TENANT === '1';
30
32
  const singleTenantUrl = process.env.NEXT_PUBLIC_FONTDUE_URL;
31
33
  const internalOrigin = process.env.FONTDUE_ORIGIN;
@@ -127,6 +129,12 @@ export function configureFontdueRender(domain) {
127
129
  // on every pass. Forgetting it is loud, not subtle: currentFontdueEndpoint
128
130
  // throws in multi-tenant mode when no render config was set.
129
131
  //
132
+ // When the admin has entered preview (Next draft mode is on), it also folds
133
+ // the preview token into the render config and drops the cache tags, so the
134
+ // whole render — the app's own fetches AND the embedded fontdue-js components'
135
+ // server preloads — reveals hidden fonts and stays live (uncached). This is
136
+ // the Next counterpart to runWithPreview for the SSR frameworks.
137
+ //
130
138
  // Route handlers are not React renders — the render-scoped store doesn't
131
139
  // exist there, so use the returned endpoint explicitly instead of relying
132
140
  // on currentFontdueEndpoint.
@@ -134,9 +142,37 @@ export async function prepareFontdueRender(props) {
134
142
  const {
135
143
  domain
136
144
  } = await props.params;
137
- const endpoint = typeof domain === 'string' ? configureFontdueRender(domain) : null;
138
- if (!endpoint) notFound();
139
- return endpoint;
145
+ if (typeof domain !== 'string' || !isValidDomain(domain)) notFound();
146
+ const config = fontdueServerConfig(domain);
147
+ const previewHeaders = await readPreviewHeaders();
148
+ setFontdueServerConfig(previewHeaders ? {
149
+ ...config,
150
+ headers: {
151
+ ...config.headers,
152
+ ...previewHeaders
153
+ },
154
+ // Live render: no tags means createNetworkFetch/createFontdueFetch
155
+ // leave the fetch uncached so preview never lands in a shared cache.
156
+ cacheTags: undefined
157
+ } : config);
158
+ return fontdueEndpoint(domain);
159
+ }
160
+
161
+ // The admin preview token for this render, as Authorization headers, or
162
+ // undefined when not previewing. Draft mode gates it: reading draftMode().
163
+ // isEnabled is static-safe (it only forces dynamic rendering when actually
164
+ // enabled), and the token cookie is only read in the preview branch. Outside
165
+ // a request scope (build-time static generation) draftMode()/cookies() throw,
166
+ // which we treat as "not preview".
167
+ async function readPreviewHeaders() {
168
+ try {
169
+ var _await$cookies$get;
170
+ if (!(await draftMode()).isEnabled) return undefined;
171
+ const token = (_await$cookies$get = (await cookies()).get(PREVIEW_TOKEN_COOKIE)) === null || _await$cookies$get === void 0 ? void 0 : _await$cookies$get.value;
172
+ return token ? previewAuthHeaders(token) : undefined;
173
+ } catch {
174
+ return undefined;
175
+ }
140
176
  }
141
177
 
142
178
  // Endpoint for the app's own GraphQL fetches in this render pass: whatever
@@ -4,7 +4,7 @@ import { getFontdueServerConfig } from './serverConfig.js';
4
4
 
5
5
  // `__FONTDUE_JS_VERSION__` is replaced by an inline babel plugin
6
6
  // (defineVersionPlugin in .babelrc.cjs) with the literal package.json#version.
7
- const version = "3.0.0-alpha11";
7
+ const version = "3.0.0-alpha12";
8
8
  const IS_SERVER = typeof window === typeof undefined;
9
9
 
10
10
  // Read env from either process.env (Node/Next.js) or import.meta.env (Vite/Astro).
@@ -5,16 +5,28 @@ export declare class FontdueNotFoundError extends Error {
5
5
  export interface CreateFontdueFetchOptions {
6
6
  /**
7
7
  * GraphQL base URL (without the trailing /graphql), e.g.
8
- * https://acme.fontdue.com. Defaults to FONTDUE_URL / PUBLIC_FONTDUE_URL /
9
- * VITE_FONTDUE_URL from the environment.
8
+ * https://acme.fontdue.com. Falls back to the per-render config
9
+ * (getFontdueServerConfig().url, set by the Next adapter for the current
10
+ * tenant) and then to FONTDUE_URL / PUBLIC_FONTDUE_URL / VITE_FONTDUE_URL
11
+ * from the environment.
10
12
  */
11
13
  url?: string;
12
14
  /**
13
- * Extra headers sent with every fetch from this fetcher. Pass
15
+ * Extra headers sent with every fetch from this fetcher, merged over the
16
+ * ambient per-render headers (explicit winning). Pass
14
17
  * `previewAuthHeaders(token)` (from fontdue-js/preview) to reveal hidden
15
18
  * fonts while an admin is in preview.
16
19
  */
17
20
  headers?: Record<string, string>;
21
+ /**
22
+ * Next.js data-cache tags. When present — passed here or via the per-render
23
+ * config — the fetch is opted into Next's data cache so the revalidate
24
+ * handler can purge it. The global `graphql` tag is always included (and
25
+ * deduped), so pass the per-site tags alone or the full `endpoint.tags`.
26
+ * Omit (or pass `[]`) to leave the fetch uncached, which is what preview
27
+ * renders want. Inert outside Next.
28
+ */
29
+ cacheTags?: string[];
18
30
  }
19
31
  export type FontdueFetch = <Q, V = void>(queryName: string, query: string, variables?: V) => Promise<Q>;
20
32
  /**
@@ -2,32 +2,46 @@
2
2
  //
3
3
  // Every framework example ships a near-identical `fetchGraphql` — resolve the
4
4
  // Fontdue URL, POST the query, unwrap `data`, throw on errors. This exports
5
- // that transport once so apps don't re-implement it, and so the preview
6
- // Authorization header is forwarded in one place instead of being wired into
7
- // each app's data layer.
5
+ // that transport once so apps don't re-implement it, so the preview
6
+ // Authorization header is forwarded in one place, and so the same fetcher
7
+ // works in every server runtime. It is the sibling of the Relay network layer
8
+ // (relay/environment.ts) that powers the embedded components: both read the
9
+ // per-render config from getFontdueServerConfig() and apply it the same way.
8
10
  //
9
- // The per-request input — the admin preview token — can come from two places,
10
- // resolved per call:
11
+ // All three per-request inputs — the base URL, the admin preview token, and
12
+ // the Next cache tags — are resolved per call from two sources, so a single
13
+ // module-level fetcher serves every render:
11
14
  //
12
- // 1. Ambient context: wrap requests in runWithPreview (fontdue-js/preview/
13
- // server) and a single module-level fetcher forwards the token
14
- // automatically no binding, no per-call plumbing:
15
+ // 1. Ambient context (preferred): whatever set the per-render config.
16
+ // - Astro / React Router etc.: runWithPreview (fontdue-js/preview/server)
17
+ // rides the token through AsyncLocalStorage.
18
+ // - Next: prepareFontdueRender (fontdue-js/next) writes the tenant URL,
19
+ // cache tags, and preview token into the render-scoped slot.
20
+ // Either way:
15
21
  //
16
22
  // const fetchGraphql = createFontdueFetch(); // once, at module scope
17
23
  // const data = await fetchGraphql('Index', indexQuery, vars);
18
24
  //
19
- // 2. Explicit headers: bind a fetcher per request, for frameworks/runtimes
20
- // where ambient context can't propagate (see runWithPreview's notes):
25
+ // 2. Explicit options: bind a fetcher with a url/headers/cacheTags, for
26
+ // runtimes where the ambient context can't propagate (route handlers,
27
+ // Astro edgeMiddleware — see runWithPreview's notes):
21
28
  //
22
29
  // const fetchGraphql = createFontdueFetch({
30
+ // url: endpoint.origin,
23
31
  // headers: previewAuthHeaders(token), // from fontdue-js/preview
32
+ // cacheTags: [`graphql:${endpoint.domain}`],
24
33
  // });
25
34
  //
26
- // Explicit `headers` override the ambient context, so the two compose.
35
+ // Explicit options override the ambient context (headers merge, explicit
36
+ // winning), so the two compose.
27
37
  //
28
- // Caching is intentionally left to the host: these frameworks cache HTML at
29
- // the CDN/response-header layer, not on the fetch. (The Next adapter layers
30
- // its own force-cache + tag handling on top of the same idea.)
38
+ // Caching: when the resolved config carries cacheTags (the Next adapter sets
39
+ // them per render; supply them explicitly elsewhere) the fetch is opted into
40
+ // Next's data cache (`force-cache` + tags) so /api/revalidate can purge it.
41
+ // With no tags the fetch is left uncached, which is what preview renders and
42
+ // the CDN-cached frameworks (Astro/RR7 cache HTML at the response layer) want.
43
+ // The Next fetch hints are inert in other runtimes: Node's fetch accepts and
44
+ // ignores the cache mode, and `next` is just an unknown init property.
31
45
 
32
46
  import { getFontdueServerConfig } from '../relay/serverConfig.js';
33
47
  function readEnv(name) {
@@ -65,17 +79,26 @@ export class FontdueNotFoundError extends Error {
65
79
  */
66
80
  export function createFontdueFetch() {
67
81
  let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
68
- const base = options.url ?? resolveFontdueUrl();
69
- if (!base) {
70
- throw new Error('fontdue-js: no Fontdue URL configured. Set FONTDUE_URL / ' + 'PUBLIC_FONTDUE_URL / VITE_FONTDUE_URL, or pass { url } to ' + 'createFontdueFetch.');
71
- }
72
82
  return async function fetchGraphql(queryName, query, variables) {
73
- var _getFontdueServerConf, _json$errors, _json$errors$;
74
- // Ambient preview headers (from a runWithPreview context), overridden by any
75
- // explicit per-fetcher headers. Resolved per call so a module-level fetcher
76
- // still picks up the current request's preview token.
77
- const ambientHeaders = (_getFontdueServerConf = getFontdueServerConfig()) === null || _getFontdueServerConf === void 0 ? void 0 : _getFontdueServerConf.headers;
78
- const response = await fetch(`${base}/graphql?query=${queryName}`, {
83
+ var _json$errors, _json$errors$;
84
+ // Per-render config (a runWithPreview AsyncLocalStorage store, or the Next
85
+ // render-scoped slot). Resolved per call so a single module-level fetcher
86
+ // picks up the current request's tenant URL, preview token, and cache tags.
87
+ const config = getFontdueServerConfig();
88
+
89
+ // URL: explicit option, then the per-render tenant URL, then the env.
90
+ const base = options.url ?? (config === null || config === void 0 ? void 0 : config.url) ?? resolveFontdueUrl();
91
+ if (!base) {
92
+ throw new Error('fontdue-js: no Fontdue URL configured. Set FONTDUE_URL / ' + 'PUBLIC_FONTDUE_URL / VITE_FONTDUE_URL, pass { url } to ' + 'createFontdueFetch, or set it on the per-render config.');
93
+ }
94
+
95
+ // Cache tags: explicit option wins, else the per-render config's. A
96
+ // non-empty list opts the fetch into Next's data cache (inert elsewhere);
97
+ // an empty/absent list leaves it uncached (preview + CDN-cached frameworks).
98
+ const cacheTags = options.cacheTags ?? (config === null || config === void 0 ? void 0 : config.cacheTags);
99
+
100
+ // `next` is a Next.js-only fetch extension, ignored by other runtimes.
101
+ const init = {
79
102
  method: 'POST',
80
103
  body: JSON.stringify({
81
104
  query,
@@ -83,10 +106,21 @@ export function createFontdueFetch() {
83
106
  }),
84
107
  headers: {
85
108
  'content-type': 'application/json',
86
- ...ambientHeaders,
109
+ // Ambient headers (e.g. tenant + preview token), overridden by any
110
+ // explicit per-fetcher headers.
111
+ ...(config === null || config === void 0 ? void 0 : config.headers),
87
112
  ...options.headers
88
113
  }
89
- });
114
+ };
115
+ if (Array.isArray(cacheTags) && cacheTags.length > 0) {
116
+ init.cache = 'force-cache';
117
+ // The global `graphql` tag is always present; dedupe so callers can pass
118
+ // the per-site tags alone or the full `endpoint.tags` (which include it).
119
+ init.next = {
120
+ tags: Array.from(new Set(['graphql', ...cacheTags]))
121
+ };
122
+ }
123
+ const response = await fetch(`${base}/graphql?query=${queryName}`, init);
90
124
  if (response.status === 404) throw new FontdueNotFoundError(queryName);
91
125
  if (response.status !== 200) {
92
126
  throw new Error(`Fontdue request failed: ${response.status}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fontdue-js",
3
- "version": "3.0.0-alpha11",
3
+ "version": "3.0.0-alpha12",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "build": "npm run relay && run-p build-js build-css build-ts",
@@ -0,0 +1,9 @@
1
+ // Minimal declaration so src/next/tenant.ts type-checks without `next`
2
+ // installed (it's the host app's dependency; this package only ever runs the
3
+ // import inside a Next.js server).
4
+ declare module 'next/headers' {
5
+ export function draftMode(): Promise<{ isEnabled: boolean }>;
6
+ export function cookies(): Promise<{
7
+ get(name: string): { value: string } | undefined;
8
+ }>;
9
+ }