fontdue-js 3.0.0-alpha12 → 3.0.0-alpha13

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 (34) hide show
  1. package/.playwright-mcp/console-2026-06-15T09-14-00-118Z.log +84 -0
  2. package/.playwright-mcp/console-2026-06-15T09-25-42-726Z.log +2 -0
  3. package/.playwright-mcp/console-2026-06-15T09-25-47-707Z.log +1 -0
  4. package/.playwright-mcp/page-2026-06-15T09-14-01-054Z.yml +13 -0
  5. package/CHANGELOG.md +2 -2
  6. package/README.md +25 -50
  7. package/dist/__tests__/createFontdueFetch.test.js +33 -0
  8. package/dist/__tests__/networkFetch.test.js +81 -2
  9. package/dist/__tests__/nextAdapter.test.js +195 -50
  10. package/dist/__tests__/serverConfig.test.js +62 -0
  11. package/dist/components/ConfigContext.d.ts +3 -0
  12. package/dist/components/ConfigContext.js +5 -2
  13. package/dist/components/FontdueAdminToolbar/index.js +72 -16
  14. package/dist/components/FontdueProvider/index.server.d.ts +1 -0
  15. package/dist/components/FontdueProvider/index.server.js +10 -0
  16. package/dist/fontdue.css +59 -0
  17. package/dist/next/index.d.ts +1 -2
  18. package/dist/next/index.js +16 -6
  19. package/dist/next/registerSingleTenantResolver.d.ts +1 -0
  20. package/dist/next/registerSingleTenantResolver.js +36 -0
  21. package/dist/next/revalidate.js +1 -1
  22. package/dist/next/tenant.d.ts +6 -4
  23. package/dist/next/tenant.js +106 -69
  24. package/dist/preview/constants.d.ts +2 -0
  25. package/dist/preview/constants.js +20 -1
  26. package/dist/relay/environment.d.ts +2 -0
  27. package/dist/relay/environment.js +67 -38
  28. package/dist/relay/serverConfig.d.ts +6 -4
  29. package/dist/relay/serverConfig.js +81 -19
  30. package/dist/server/index.d.ts +1 -1
  31. package/dist/server/index.js +27 -15
  32. package/package.json +1 -1
  33. package/types/next-navigation.d.ts +4 -0
  34. package/vitest.config.ts +5 -0
@@ -25,33 +25,70 @@
25
25
  // and the environment variables remain the configuration source.
26
26
 
27
27
  import * as React from 'react';
28
- const getSlot = typeof React.cache === 'function' ? React.cache(() => ({
29
- current: undefined
30
- })) : () => ({
31
- current: undefined
32
- });
28
+
29
+ // This module owns two pieces of cross-cutting state — the render-scoped slot
30
+ // and the ambient resolver — and both must be shared by every server-side
31
+ // fetch in a render. The catch: a Next.js server build can bundle this same
32
+ // file into more than one server chunk (e.g. the app/provider chunk and a
33
+ // separate per-embed chunk), and those copies do NOT share module-level state.
34
+ // That split is silent until it bites: the ambient resolver is registered once
35
+ // (when <FontdueProvider> mounts) but an embedded component's own preload runs
36
+ // from the OTHER chunk's copy — so it never sees the resolver, fetches without
37
+ // the preview token, and a hidden-font reveal fails (a <BuyButton> node(id)
38
+ // @required(THROW) preload then crashes the server render — FD-712).
39
+ //
40
+ // Anchor both on globalThis so every duplicate instance shares one slot factory
41
+ // and one resolver. Same reasoning as the global Relay-environment singleton —
42
+ // see components/FontdueContextProvider.
43
+
44
+ const STORE_KEY = '__fontdueServerConfigStore__';
45
+ function store() {
46
+ const g = globalThis;
47
+ let s = g[STORE_KEY];
48
+ if (!s) {
49
+ // React.cache() scopes the slot to one server render pass (and so isolates
50
+ // concurrent requests from each other). Creating it once and sharing the
51
+ // single instance keeps that per-render isolation while defeating the
52
+ // chunk duplication. Outside an RSC render React.cache is unavailable, so
53
+ // the slot degrades to a throwaway object: writes no-op and the
54
+ // environment variables remain the configuration source.
55
+ s = {
56
+ getSlot: typeof React.cache === 'function' ? React.cache(() => ({
57
+ current: undefined
58
+ })) : () => ({
59
+ current: undefined
60
+ }),
61
+ ambientResolver: undefined
62
+ };
63
+ g[STORE_KEY] = s;
64
+ }
65
+ return s;
66
+ }
33
67
  export function setFontdueServerConfig(config) {
34
- getSlot().current = config;
68
+ store().getSlot().current = config;
35
69
  }
36
70
 
37
71
  // Ambient config resolver — the seam that lets a request-scoped store outside
38
- // RSC (e.g. fontdue-js/preview/server's AsyncLocalStorage) feed config into
39
- // server fetches without this browser-safe module importing a Node builtin.
40
- // The server-only module registers a resolver on import; if nothing registers,
41
- // resolution is unchanged. The RSC render slot still wins, so Next is
42
- // unaffected (it never registers a resolver).
43
-
44
- let ambientResolver;
72
+ // the RSC slot feed config into server fetches without this browser-safe module
73
+ // importing a Node builtin. Two registrants today:
74
+ // - fontdue-js/preview/server (Astro/RR7): a synchronous resolver reading an
75
+ // AsyncLocalStorage store set by runWithPreview.
76
+ // - fontdue-js/next (single-tenant): an asynchronous resolver that reads the
77
+ // env URL plus Next's draftMode()/cookies() per fetch — so a single-tenant
78
+ // foundry never calls a per-render setup function.
79
+ // A server-only module registers a resolver on import; if nothing registers,
80
+ // resolution is unchanged. The RSC slot still wins on merge, so multi-tenant
81
+ // Next (which drives config through the slot via __prepareFontdueRender) is
82
+ // unaffected.
45
83
  export function registerAmbientConfigResolver(resolver) {
46
- ambientResolver = resolver;
84
+ store().ambientResolver = resolver;
47
85
  }
48
- export function getFontdueServerConfig() {
49
- var _ambientResolver;
50
- const slot = getSlot().current;
51
- const ambient = (_ambientResolver = ambientResolver) === null || _ambientResolver === void 0 ? void 0 : _ambientResolver();
86
+
87
+ // Merge an ambient config under the RSC slot: the slot wins for scalar fields;
88
+ // headers merge with the slot overriding.
89
+ function mergeServerConfig(ambient, slot) {
52
90
  if (!ambient) return slot;
53
91
  if (!slot) return ambient;
54
- // RSC slot wins for scalar fields; headers merge with the slot overriding.
55
92
  return {
56
93
  ...ambient,
57
94
  ...slot,
@@ -60,4 +97,29 @@ export function getFontdueServerConfig() {
60
97
  ...slot.headers
61
98
  }
62
99
  };
100
+ }
101
+
102
+ // Slot-only read of the render-scoped config. It deliberately does NOT run the
103
+ // ambient resolver: the single-tenant Next resolver is async (it awaits
104
+ // draftMode()/cookies()), so a synchronous read could never reflect it without
105
+ // silently dropping the preview token. Anything that needs the resolved
106
+ // url/headers — every server-side fetch — must use resolveFontdueServerConfig()
107
+ // instead. This exists only to recover the multi-tenant `domain` the slot
108
+ // carries (set by __prepareFontdueRender), which fontdueEndpoint() reads from a
109
+ // synchronous path.
110
+ export function getFontdueSlotConfig() {
111
+ return store().getSlot().current;
112
+ }
113
+
114
+ // Asynchronous read used by the server-side fetch paths (the Relay network
115
+ // layer and createFontdueFetch). Awaits the ambient resolver so a resolver that
116
+ // reads request-scoped async APIs (Next's draftMode()/cookies()) can feed
117
+ // config into every server fetch with no per-render call, then merges it under
118
+ // the slot.
119
+ export async function resolveFontdueServerConfig() {
120
+ var _s$ambientResolver;
121
+ const s = store();
122
+ const slot = s.getSlot().current;
123
+ const ambient = await ((_s$ambientResolver = s.ambientResolver) === null || _s$ambientResolver === void 0 ? void 0 : _s$ambientResolver.call(s));
124
+ return mergeServerConfig(ambient, slot);
63
125
  }
@@ -6,7 +6,7 @@ export interface CreateFontdueFetchOptions {
6
6
  /**
7
7
  * GraphQL base URL (without the trailing /graphql), e.g.
8
8
  * https://acme.fontdue.com. Falls back to the per-render config
9
- * (getFontdueServerConfig().url, set by the Next adapter for the current
9
+ * (resolveFontdueServerConfig().url, set by the Next adapter for the current
10
10
  * tenant) and then to FONTDUE_URL / PUBLIC_FONTDUE_URL / VITE_FONTDUE_URL
11
11
  * from the environment.
12
12
  */
@@ -6,7 +6,7 @@
6
6
  // Authorization header is forwarded in one place, and so the same fetcher
7
7
  // works in every server runtime. It is the sibling of the Relay network layer
8
8
  // (relay/environment.ts) that powers the embedded components: both read the
9
- // per-render config from getFontdueServerConfig() and apply it the same way.
9
+ // per-render config from resolveFontdueServerConfig() and apply it the same way.
10
10
  //
11
11
  // All three per-request inputs — the base URL, the admin preview token, and
12
12
  // the Next cache tags — are resolved per call from two sources, so a single
@@ -15,8 +15,9 @@
15
15
  // 1. Ambient context (preferred): whatever set the per-render config.
16
16
  // - Astro / React Router etc.: runWithPreview (fontdue-js/preview/server)
17
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.
18
+ // - Next: configureFontduePreview / __prepareFontdueRender
19
+ // (fontdue-js/next) write the site URL, cache tags, and preview token
20
+ // into the render-scoped slot.
20
21
  // Either way:
21
22
  //
22
23
  // const fetchGraphql = createFontdueFetch(); // once, at module scope
@@ -43,7 +44,8 @@
43
44
  // The Next fetch hints are inert in other runtimes: Node's fetch accepts and
44
45
  // ignores the cache mode, and `next` is just an unknown init property.
45
46
 
46
- import { getFontdueServerConfig } from '../relay/serverConfig.js';
47
+ import { resolveFontdueServerConfig } from '../relay/serverConfig.js';
48
+ import { PREVIEW_HEADER } from '../preview/constants.js';
47
49
  function readEnv(name) {
48
50
  if (typeof process !== 'undefined' && process.env) {
49
51
  const v = process.env[name];
@@ -81,10 +83,11 @@ export function createFontdueFetch() {
81
83
  let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
82
84
  return async function fetchGraphql(queryName, query, variables) {
83
85
  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();
86
+ // Per-render config (a runWithPreview AsyncLocalStorage store, the Next
87
+ // render-scoped slot, or the Next single-tenant ambient resolver). Awaited
88
+ // per call so a single module-level fetcher picks up the current request's
89
+ // tenant URL, preview token, and cache tags.
90
+ const config = await resolveFontdueServerConfig();
88
91
 
89
92
  // URL: explicit option, then the per-render tenant URL, then the env.
90
93
  const base = options.url ?? (config === null || config === void 0 ? void 0 : config.url) ?? resolveFontdueUrl();
@@ -97,6 +100,21 @@ export function createFontdueFetch() {
97
100
  // an empty/absent list leaves it uncached (preview + CDN-cached frameworks).
98
101
  const cacheTags = options.cacheTags ?? (config === null || config === void 0 ? void 0 : config.cacheTags);
99
102
 
103
+ // Ambient headers (e.g. tenant + preview token), overridden by any explicit
104
+ // per-fetcher headers.
105
+ const headers = {
106
+ 'content-type': 'application/json',
107
+ ...(config === null || config === void 0 ? void 0 : config.headers),
108
+ ...options.headers
109
+ };
110
+
111
+ // Declare preview intent explicitly: a forwarded admin token means this is a
112
+ // preview render → reveal hidden fonts; otherwise "false" so a public (or
113
+ // merely session-authenticated) request never gets the hidden-fonts view.
114
+ // Older clients omit the header and keep the legacy behavior. Mirrors the
115
+ // Relay network layer (relay/environment.ts) and FontageWeb.Schema.Context.
116
+ headers[PREVIEW_HEADER] = headers.authorization != null || headers.Authorization != null ? 'true' : 'false';
117
+
100
118
  // `next` is a Next.js-only fetch extension, ignored by other runtimes.
101
119
  const init = {
102
120
  method: 'POST',
@@ -104,13 +122,7 @@ export function createFontdueFetch() {
104
122
  query,
105
123
  variables
106
124
  }),
107
- headers: {
108
- 'content-type': 'application/json',
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),
112
- ...options.headers
113
- }
125
+ headers
114
126
  };
115
127
  if (Array.isArray(cacheTags) && cacheTags.length > 0) {
116
128
  init.cache = 'force-cache';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fontdue-js",
3
- "version": "3.0.0-alpha12",
3
+ "version": "3.0.0-alpha13",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "build": "npm run relay && run-p build-js build-css build-ts",
@@ -3,4 +3,8 @@
3
3
  // import inside a Next.js server).
4
4
  declare module 'next/navigation' {
5
5
  export function notFound(): never;
6
+ // Re-throws Next's internal control-flow errors (dynamic-rendering bailout,
7
+ // notFound, redirect) and is a no-op for everything else. Used so the preview
8
+ // resolver doesn't swallow the bailout that forces a dynamic render.
9
+ export function unstable_rethrow(error: unknown): void;
6
10
  }
package/vitest.config.ts CHANGED
@@ -7,6 +7,11 @@ export default defineConfig({
7
7
  __FONTDUE_JS_VERSION__: JSON.stringify('0.0.0-test'),
8
8
  },
9
9
  test: {
10
+ // Only the TypeScript sources are the tests of record. Without this, a
11
+ // prior `npm run build` leaves compiled copies in dist/__tests__ that
12
+ // vitest also picks up — running each suite twice and racing on
13
+ // module-level singletons (e.g. the ambient config resolver).
14
+ include: ['src/**/*.test.{ts,tsx}'],
10
15
  alias: {
11
16
  '__generated__': path.resolve(__dirname, 'src/__generated__'),
12
17
  },