fontdue-js 3.1.0 → 3.2.1

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.
@@ -0,0 +1,60 @@
1
+ import { useEffect, useRef } from 'react';
2
+
3
+ // How long we wait for the invisible reCAPTCHA widget to hand back a token
4
+ // after calling `execute()` before we give up. reCAPTCHA normally resolves in
5
+ // a second or two; a genuine challenge is handled by the readiness probe below
6
+ // (we don't cut a buyer off mid-challenge), so this only needs to be long
7
+ // enough to rule out a transient slow load.
8
+ export const RECAPTCHA_TIMEOUT_MS = 15000;
9
+
10
+ // User-facing message shown when the reCAPTCHA widget never produced a token
11
+ // (typically because Google's api.js failed to load). Curly apostrophes per
12
+ // the Fontdue house style.
13
+ export const RECAPTCHA_UNAVAILABLE_MESSAGE = 'We couldn’t verify that you’re human. Please refresh the page and try again.';
14
+
15
+ // True once Google's reCAPTCHA script (`window.grecaptcha`) has loaded. We use
16
+ // this to tell the two failure modes apart when the timeout fires:
17
+ // - script never loaded → the widget is dead and would hang forever; reset.
18
+ // - script loaded, no token yet → the buyer may be mid-challenge; keep
19
+ // waiting rather than cancelling on them.
20
+ export function isRecaptchaScriptLoaded() {
21
+ // Google's api.js defines `grecaptcha` on the global object; in a browser
22
+ // `globalThis === window`, so this reads the same property while staying
23
+ // safe under SSR and unit tests where `window` may be undefined.
24
+ return typeof globalThis !== 'undefined' && globalThis.grecaptcha != null;
25
+ }
26
+ // Framework-free timer wrapper. Kept as a plain factory (not a hook) so the
27
+ // resilience contract can be unit-tested with fake timers without a DOM.
28
+ export function createRecaptchaTimeout() {
29
+ let timeoutMs = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : RECAPTCHA_TIMEOUT_MS;
30
+ let handle = null;
31
+ const clear = () => {
32
+ if (handle !== null) {
33
+ clearTimeout(handle);
34
+ handle = null;
35
+ }
36
+ };
37
+ const start = onTimeout => {
38
+ clear();
39
+ handle = setTimeout(() => {
40
+ handle = null;
41
+ onTimeout();
42
+ }, timeoutMs);
43
+ };
44
+ return {
45
+ start,
46
+ clear
47
+ };
48
+ }
49
+
50
+ // React hook wrapper: one stable timer per component that auto-clears on
51
+ // unmount (so a pending timeout never fires setState after teardown).
52
+ export function useRecaptchaTimeout(timeoutMs) {
53
+ const ref = useRef(null);
54
+ if (ref.current === null) {
55
+ ref.current = createRecaptchaTimeout(timeoutMs);
56
+ }
57
+ const timeout = ref.current;
58
+ useEffect(() => () => timeout.clear(), [timeout]);
59
+ return timeout;
60
+ }
@@ -1,6 +1,7 @@
1
1
  // Server-side entrypoint for Next.js apps (the App Router / RSC adapter).
2
- // The config-time wrapper lives in 'fontdue-js/next/config' and the deploy
3
- // hook route handler in 'fontdue-js/next/revalidate'.
2
+ // The config-time wrapper lives in 'fontdue-js/next/config', the deploy
3
+ // hook route handler in 'fontdue-js/next/revalidate', and the
4
+ // password-unlock route handler in 'fontdue-js/next/unlock'.
4
5
 
5
6
  // Single-tenant apps need NO per-render setup and nothing from this entrypoint:
6
7
  // mounting <FontdueProvider> registers the ambient resolver that configures
@@ -163,13 +163,41 @@ export async function __prepareFontdueRender(props) {
163
163
  //
164
164
  // Reading cookies() opts the route into dynamic rendering, so call this only
165
165
  // when it's actually needed — after a public fetch comes back
166
- // password-protected — to keep non-protected font pages static/cacheable. The
167
- // catch mirrors readPreviewHeaders: re-throw Next's control-flow bailout so a
168
- // protected page renders dynamically instead of being prerendered cookie-blind
169
- // (which would serve the locked form to a visitor who has unlocked it).
166
+ // password-protected — to keep non-protected font pages static/cacheable.
167
+ //
168
+ // The draft-mode gate is where the caching architecture for locked
169
+ // collections lives, so spelling it out: locked slugs are excluded from
170
+ // generateStaticParams, so a visitor's first request for one is an on-demand
171
+ // STATIC fill of an SSG route. Merely CALLING cookies() there flags the
172
+ // render as dynamic, and Next 15 cannot take a runtime fill dynamic the way a
173
+ // build-time pass can — it 500s ("Page changed from static to dynamic at
174
+ // runtime", or DYNAMIC_SERVER_USAGE; FD-773). Catching the throw doesn't
175
+ // help: the render is already poisoned by the call. So the cookie must not be
176
+ // read at all unless this render is a draft-mode one — draftMode().isEnabled
177
+ // is static-safe (readPreviewHeaders below leans on the same property), false
178
+ // during any static pass.
179
+ //
180
+ // Gating on draft mode is not just crash avoidance, it IS the design: with
181
+ // the gate, a locked slug's static fill renders (and caches) the password
182
+ // form — the correct public view. The full-route cache is visitor-blind, so
183
+ // no cookie could ever change what it serves anyway; the only way a visitor
184
+ // sees unlocked content is off the static cache entirely. That's what the
185
+ // unlock route handler (see ./unlock) provides: on a successful unlock the
186
+ // form POSTs there, draft mode's bypass cookie takes this visitor's requests
187
+ // to dynamic renders, the gate opens, and the token folds in here. (Exactly
188
+ // the admin-preview model, with the password standing in for the admin
189
+ // session.)
190
+ //
191
+ // At build time (NEXT_PHASE=phase-production-build) the gate is skipped and
192
+ // the cookies() read trips Next's dynamic bailout, which unstable_rethrow
193
+ // propagates: a build-time render whose own fetch touches a locked collection
194
+ // bails that route out to dynamic today, and baking a cookie-blind form into
195
+ // such a page is a behavior change we don't need for this fix. Non-bailout
196
+ // throws (no request scope at all) still fall through to "no token".
170
197
  export async function nodeAccessHeaders() {
171
198
  try {
172
199
  var _await$cookies$get;
200
+ if (!isBuildPhase() && !(await draftMode()).isEnabled) return {};
173
201
  const value = (_await$cookies$get = (await cookies()).get(NODE_ACCESS_COOKIE)) === null || _await$cookies$get === void 0 ? void 0 : _await$cookies$get.value;
174
202
  return value ? {
175
203
  [NODE_ACCESS_HEADER]: value
@@ -179,6 +207,9 @@ export async function nodeAccessHeaders() {
179
207
  return {};
180
208
  }
181
209
  }
210
+ function isBuildPhase() {
211
+ return typeof process !== 'undefined' && process.env.NEXT_PHASE === 'phase-production-build';
212
+ }
182
213
 
183
214
  // The admin preview token for this render, as Authorization headers, or
184
215
  // undefined when not previewing. Draft mode gates it: reading draftMode().
@@ -0,0 +1,3 @@
1
+ /** Default path the templates mount the handler at. */
2
+ export declare const UNLOCK_ENDPOINT = "/api/unlock";
3
+ export declare function POST(request: Request): Promise<Response>;
@@ -0,0 +1,112 @@
1
+ // Route handler that lets a visitor who unlocked a password-protected
2
+ // collection actually see it on a statically-served Next storefront.
3
+ // Re-export it from app/api/unlock/route.ts:
4
+ //
5
+ // export { POST } from 'fontdue-js/next/unlock';
6
+ //
7
+ // and pass the route's path to the form: <NodePasswordForm
8
+ // unlockEndpoint="/api/unlock" …/>. On a successful unlock the form POSTs the
9
+ // access token here before reloading.
10
+ //
11
+ // Why this exists (FD-773): font pages are SSG, and Next's full-route cache is
12
+ // visitor-blind — once a locked collection's page is (correctly) cached as the
13
+ // password form, no cookie the form sets can change what the cache serves.
14
+ // Enabling Next draft mode sets the __prerender_bypass cookie, which is the
15
+ // one sanctioned way to take a single visitor off the full-route cache: their
16
+ // subsequent requests render dynamically, nodeAccessHeaders() (fontdue-js/next)
17
+ // reads the node-access cookie, and the collection resolves. Everyone else
18
+ // keeps getting the static pages. This mirrors the admin-preview route
19
+ // (/api/preview in the templates), which layers the same draft-mode bypass on
20
+ // the preview cookie contract — including its trust model: the token is opaque
21
+ // here and validated by the Fontdue GraphQL server on every request, so a
22
+ // forged POST reveals nothing and buys the caller only their own dynamic
23
+ // renders, exactly like a forged preview POST.
24
+ //
25
+ // The bypass is scoped to the unlocked page's path, not the whole site.
26
+ // draftMode().enable() sets the cookie with Path=/, which would take EVERY
27
+ // page this visitor touches off the full-route cache — and because a render
28
+ // carrying a node-access token also opts all of its GraphQL fetches out of the
29
+ // shared data cache, one unlock made the visitor's whole browsing session
30
+ // dynamically rendered and measurably slow (seconds per page on heavy pages).
31
+ // Draft mode is decided per-request by whether the browser SENDS the bypass
32
+ // cookie, and the browser decides that by cookie Path — so the form posts the
33
+ // pathname it unlocked on and we narrow the cookie to it. Public pages stay on
34
+ // the static cache; only the unlocked collection's pages render dynamically
35
+ // for this visitor. Unlocking several collections yields several same-name
36
+ // cookies with different paths, which browsers store and send independently.
37
+ // (Admin preview keeps its site-wide Path=/ bypass — previewing the whole site
38
+ // dynamically is the point there.)
39
+ //
40
+ // The bypass cookie is a session cookie, while the node-access cookie lasts ~30
41
+ // days. A returning visitor whose bypass lapsed sees the static form again and
42
+ // re-enters the password; re-unlocking re-enables the bypass. There is no
43
+ // DELETE: draft mode is shared with admin preview, so an unlock exit must not
44
+ // tear down an active preview — the bypass simply expires with the session.
45
+
46
+ import { cookies, draftMode } from 'next/headers';
47
+
48
+ /** Default path the templates mount the handler at. */
49
+ export const UNLOCK_ENDPOINT = '/api/unlock';
50
+
51
+ // Next's draft-mode bypass cookie. The name is Next's stable, documented
52
+ // public contract (the same one draftMode().enable() sets), but it isn't
53
+ // exported from a public entry point, so it's spelled out here.
54
+ const PRERENDER_BYPASS_COOKIE = '__prerender_bypass';
55
+
56
+ // A client-sent pathname we are willing to put in a Set-Cookie Path attribute:
57
+ // absolute, bounded, visible ASCII with no ';' — i.e. what
58
+ // window.location.pathname (always percent-encoded) actually produces, and
59
+ // nothing that could terminate or escape the attribute (Next serializes the
60
+ // path verbatim). Anything else falls back to the site-wide default.
61
+ const SCOPE_PATH_MAX_LENGTH = 512;
62
+ const SCOPE_PATH_RE = /^\/[\x21-\x3a\x3c-\x7e]*$/;
63
+ export async function POST(request) {
64
+ const body = await request.json().catch(() => ({}));
65
+ if (typeof body.token !== 'string' || body.token === '') {
66
+ return Response.json({
67
+ ok: false,
68
+ error: 'Missing access token'
69
+ }, {
70
+ status: 400
71
+ });
72
+ }
73
+ (await draftMode()).enable();
74
+ await scopeBypassCookie(scopePath(body.path));
75
+ return Response.json({
76
+ ok: true
77
+ });
78
+ }
79
+
80
+ // The cookie Path to narrow the bypass to, or undefined to leave it site-wide
81
+ // (path missing — an older form — or unusable). Trailing slashes are stripped
82
+ // because cookie path-matching treats "/fonts/x" as covering both the page
83
+ // itself and everything under it, while "/fonts/x/" would miss the page.
84
+ function scopePath(path) {
85
+ if (typeof path !== 'string' || path.length > SCOPE_PATH_MAX_LENGTH || !SCOPE_PATH_RE.test(path)) {
86
+ return undefined;
87
+ }
88
+ const trimmed = path.replace(/\/+$/, '');
89
+ return trimmed === '' ? undefined : trimmed;
90
+ }
91
+
92
+ // Replace the pending site-wide bypass cookie enable() just queued with one
93
+ // scoped to `path`. cookies() in a route handler exposes the same mutable
94
+ // store enable() wrote to, and setting the same cookie name again replaces the
95
+ // queued Set-Cookie rather than adding a second one — so exactly one bypass
96
+ // cookie leaves this response, and no Path=/ variant that could clobber an
97
+ // admin's site-wide preview bypass. The explicit attributes mirror enable()'s
98
+ // as a fallback; the spread keeps whatever enable() actually set (bar the
99
+ // path) if Next ever changes them.
100
+ async function scopeBypassCookie(path) {
101
+ if (!path) return;
102
+ const jar = await cookies();
103
+ const bypass = jar.get(PRERENDER_BYPASS_COOKIE);
104
+ if (!(bypass !== null && bypass !== void 0 && bypass.value)) return;
105
+ jar.set({
106
+ httpOnly: true,
107
+ sameSite: process.env.NODE_ENV !== 'development' ? 'none' : 'lax',
108
+ secure: process.env.NODE_ENV !== 'development',
109
+ ...bypass,
110
+ path
111
+ });
112
+ }
@@ -8,7 +8,7 @@ import { NODE_ACCESS_HEADER } from '../nodeAccess.js';
8
8
  // (defineVersionPlugin in .babelrc.cjs) with the literal package.json#version.
9
9
  // Exported so UI (the admin toolbar) can surface it without re-reading the
10
10
  // build-time global in a 'use client' module.
11
- export const version = "3.1.0";
11
+ export const version = "3.2.1";
12
12
  const IS_SERVER = typeof window === typeof undefined;
13
13
 
14
14
  // Opt server fetches into Next's data cache only in production; dev stays
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fontdue-js",
3
- "version": "3.1.0",
3
+ "version": "3.2.1",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "build": "npm run relay && run-p build-js build-css build-ts",
@@ -90,6 +90,7 @@
90
90
  "./next": "./dist/next/index.js",
91
91
  "./next/config": "./dist/next/config.js",
92
92
  "./next/revalidate": "./dist/next/revalidate.js",
93
+ "./next/unlock": "./dist/next/unlock.js",
93
94
  "./next/image-loader": "./dist/next/image-loader.js",
94
95
  "./fontdue.css": "./dist/fontdue.css",
95
96
  "./BuyButton": {
@@ -1,9 +1,33 @@
1
1
  // Minimal declaration so src/next/tenant.ts type-checks without `next`
2
2
  // installed (it's the host app's dependency; this package only ever runs the
3
3
  // import inside a Next.js server).
4
+ //
5
+ // The cookie shape mirrors Next's route-handler cookie store: get() also
6
+ // carries the queued cookie's attributes (the store is ResponseCookies-backed,
7
+ // so a cookie draftMode().enable() just set comes back with its path/flags),
8
+ // and set() with an existing name replaces the queued Set-Cookie. The unlock
9
+ // route (src/next/unlock.ts) relies on both to narrow the bypass cookie's
10
+ // path.
4
11
  declare module 'next/headers' {
5
- export function draftMode(): Promise<{ isEnabled: boolean }>;
12
+ interface CookieAttributes {
13
+ path?: string;
14
+ httpOnly?: boolean;
15
+ secure?: boolean;
16
+ sameSite?: 'strict' | 'lax' | 'none';
17
+ expires?: Date | number;
18
+ maxAge?: number;
19
+ domain?: string;
20
+ }
21
+
22
+ export function draftMode(): Promise<{
23
+ isEnabled: boolean;
24
+ enable(): void;
25
+ disable(): void;
26
+ }>;
6
27
  export function cookies(): Promise<{
7
- get(name: string): { value: string } | undefined;
28
+ get(
29
+ name: string,
30
+ ): ({ name: string; value: string } & CookieAttributes) | undefined;
31
+ set(cookie: { name: string; value: string } & CookieAttributes): void;
8
32
  }>;
9
33
  }