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 +14 -0
- package/README.md +165 -13
- package/dist/__tests__/createFontdueFetch.test.js +121 -3
- package/dist/__tests__/nextAdapter.test.js +64 -0
- package/dist/next/tenant.js +39 -3
- package/dist/relay/environment.js +1 -1
- package/dist/server/index.d.ts +15 -3
- package/dist/server/index.js +60 -26
- package/package.json +1 -1
- package/types/next-headers.d.ts +9 -0
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
|
|
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
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
//
|
|
432
|
-
|
|
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 (
|
|
435
|
+
return fetchFontdue<Q>(name, query, variables);
|
|
437
436
|
}
|
|
438
437
|
```
|
|
439
438
|
|
|
440
|
-
|
|
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.
|
|
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
|
-
|
|
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 () => {
|
package/dist/next/tenant.js
CHANGED
|
@@ -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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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-
|
|
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).
|
package/dist/server/index.d.ts
CHANGED
|
@@ -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.
|
|
9
|
-
*
|
|
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
|
|
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
|
/**
|
package/dist/server/index.js
CHANGED
|
@@ -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,
|
|
6
|
-
// Authorization header is forwarded in one place
|
|
7
|
-
//
|
|
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
|
-
//
|
|
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:
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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
|
|
20
|
-
// where ambient context can't propagate (
|
|
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
|
|
35
|
+
// Explicit options override the ambient context (headers merge, explicit
|
|
36
|
+
// winning), so the two compose.
|
|
27
37
|
//
|
|
28
|
-
// Caching
|
|
29
|
-
//
|
|
30
|
-
//
|
|
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
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
const
|
|
78
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
+
}
|