@mapled/next 0.10.0 → 0.11.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.
package/README.md CHANGED
@@ -369,11 +369,31 @@ export function UpdateNotice() {
369
369
  ```
370
370
 
371
371
  - It is polling, and it is cheap: tabs ask **your** route, never Mapled. The route reuses one answer for `ttl` seconds (default 5) — in the instance and, through `s-maxage`, at the CDN in front of it — and a check that finds nothing new is a 304. However many tabs are open, Mapled hears from the site a few times a minute, and the key's rate limit is left for reads.
372
- - The route reads the release under the same `mapled` cache tag as the content, so it moves when the webhook refreshes the cache and not before — a tab that refreshes on it never renders the old release again. Live updates therefore need the webhook from [Refresh on publish](#refresh-on-publish); without it both change only when their ISR window (`revalidate`, 3600 by default) runs out.
372
+ - The route moves when the webhook has refreshed your site's cache, and not before — a tab that refreshes on it never renders the old release again. It reads the release under the same `mapled` cache tag as the content, and never names one newer than the release your site's cache last answered the publish webhook for: the webhook handler revalidates the cache before it answers, its answer carries the cache's mark (`x-mapled-cache`), and Mapled records the answer with the mark. So a cache entry filled early — after a deploy, an eviction, or when its `revalidate` window runs out between a publish and its webhook — doesn't move the route ahead of the pages. Only while a publish's webhook is on its way — or failing — does the route ask Mapled past its cache, once per `ttl`. Live updates therefore need the webhook from [Refresh on publish](#refresh-on-publish): until your site has answered a publish's webhook, the route names the release before it (`null` before the first one it answered), and without a webhook it names nothing new — nothing tells it when your pages have a release, so open tabs don't follow Publish. A new webhook address starts over: the site there hasn't answered anything yet.
373
+ - Live updates follow the deployment the webhook reaches. Another deployment on the same key — a preview, a second site — renders from a cache of its own, which no publish revalidates, so its route names no release and its open tabs stay as they are. The route tells the answers apart by the mark: each Next.js cache keeps its own (`cacheMark()`), so deployments that share a cache share the mark, and one with a cache of its own has another. After a deploy onto a fresh cache — or once the cache has lost the mark — the route names no release until the next publish's webhook has been answered from that cache. (Before 0.11.0 such a deployment followed the webhook's one and could refresh its tabs into the release they already showed.)
374
+ - A webhook handler of your own — one that refreshes more than the `mapled` tag, say — answers with the cache's mark, or the route names no release (it logs once why):
375
+
376
+ ```ts
377
+ import { revalidateTag } from "next/cache";
378
+ import { cacheMark, verifySignature } from "@mapled/next/server";
379
+
380
+ export async function POST(req: Request) {
381
+ const body = await req.text();
382
+ const signature = req.headers.get("x-mapled-signature") ?? "";
383
+ if (!(await verifySignature(process.env.MAPLED_WEBHOOK_SECRET!, body, signature))) {
384
+ return Response.json({ error: "Invalid signature." }, { status: 401 });
385
+ }
386
+ revalidateTag("mapled");
387
+ // after revalidating: the mark says which cache has let go of the old release
388
+ return Response.json({ revalidated: true }, { headers: { "x-mapled-cache": await cacheMark() } });
389
+ }
390
+ ```
391
+
373
392
  - The route answers `{ release, publishedAt }` — the number of publishes and the time of the last one, nothing else — and may be cached by anyone. While Mapled can't be reached it keeps answering the last release it knew.
393
+ - Refreshes ask your site, not the browser's cache. `router.refresh()` asks for the page's Server Components at the same URL every time, and a static page answers with `s-maxage` and `stale-while-revalidate` but no `max-age`: stale for the browser at once, which Chrome and Edge serve anyway while they fetch a fresh copy behind it. So from the first publish a tab hears about, every refresh of the page goes with `cache: "no-cache"`, and your site may still answer 304. That covers the component's own refresh, the one your `onPublish` makes (like the button above), and any your code makes. The layer changes only the cache mode, and only of requests your server reads as a refresh of the page the tab is on. Navigations, server actions, your own requests and any request that sets its own `cache` go as they did. A layer over `fetch`, put in place with the first publish, does it; a `fetch` wrapper of your own keeps working either way. (Before 0.11.1 an open tab showed the release before the one it was told about, from its second publish on.)
374
394
  - Expect a publish to reach an open tab in about ten seconds with the defaults. `<MapledLive interval={3} />` and `createReleaseHandler({ key, ttl: 1 })` make it quicker at the price of more requests to your site.
375
395
  - With a `basePath` in `next.config`, the route answers under it, and so does the default: `<MapledLive />` asks `/docs/api/mapled/release` on a site with `basePath: "/docs"` — Next writes the basePath into the bundle, and the component reads it from there (0.10.0 and later; earlier versions ask `/api/mapled/release`). An `endpoint` you pass is asked as written, basePath included: `<MapledLive endpoint="/docs/live" />`.
376
- - Leave route segment config off the release route: `createReleaseHandler` sets how it is cached. `dynamic = "force-static"` turns it into a copy Next builds without the tab's request, and `fetchCache = "force-no-store"` reads the release past the cache tag — the route would move before the webhook refreshes the pages. `dynamic = "force-dynamic"` does the same when the handler's `revalidate` is `false` or `0`; at its default it is harmless. Middleware that runs on the route has to let the tab's `GET` through: no redirect, rewrite or sign-in wall. `output: "export"` has no server to run the route at all.
396
+ - Leave route segment config off the release route: `createReleaseHandler` sets how it is cached. `dynamic = "force-static"` turns it into a copy Next builds without the tab's request, and `fetchCache = "force-no-store"` reads the release past the cache tag, so Mapled hears from the route every `ttl`. `dynamic = "force-dynamic"` does the same when the handler's `revalidate` is `false` or `0`; at its default it is harmless. On the webhook route, leave `fetchCache` off too: with `"force-no-store"` every answer names a new cache mark, and the release route may name no release. Middleware that runs on the route has to let the tab's `GET` through: no redirect, rewrite or sign-in wall. `output: "export"` has no server to run the route at all.
377
397
 
378
398
  Outside React, `watchRelease({ onPublish })` from `@mapled/next` is the same watch as a plain function — it returns the function that stops it.
379
399
 
@@ -506,6 +526,8 @@ await db.remove("orders", id);
506
526
 
507
527
  The server key reaches only operational collections with access class `public`/`server` — editorial content, internal and sensitive collections stay out of reach, and it grants nothing on the Management API.
508
528
 
529
+ A write is checked against the collection's fields: data that doesn't fit them is a 400 `VALIDATION_FAILED`, a slug another record uses a 409 `SLUG_TAKEN`. A write that ran while a field it sends was renamed, removed or changed is a 409 `FIELDS_CHANGED` and writes nothing — send it again against the fields as they are now.
530
+
509
531
  ## Without Next.js: the HTTP API
510
532
 
511
533
  The client is a thin layer over a plain HTTP API, so any site — or a build script — can read Mapled. The base URL is `https://api.mapled.io`; every request carries the delivery key in `x-mapled-key`.
@@ -516,7 +538,7 @@ The client is a thin layer over a plain HTTP API, so any site — or a build scr
516
538
  | `GET /v1/delivery/collections/{collection}/records/{id}` | `{ record, release }` — 404 when it isn't in the release |
517
539
  | `GET /v1/delivery/collections/{collection}/records/by-slug/{slug}` | `{ record, release }` |
518
540
  | `GET /v1/delivery/singles/{single}` | `{ record, release }` — `record` is `null` while the single is empty |
519
- | `GET /v1/delivery/release` | `{ release, publishedAt }` — the release being served now; `null`s before the first publish |
541
+ | `GET /v1/delivery/release` | `{ release, publishedAt }` — the release being served now; `null`s before the first publish. With `?webhook=1` also `webhook`: `{ release, publishedAt, deliveredAt, cache }` of the newest release your site answered the publish webhook for, with the `x-mapled-cache` mark the answer carried (`null`s before the first, and again after the webhook's address changes), or `null` when the project has no webhook |
520
542
  | `POST /v1/delivery/forms/{form}` | `201 { ok: true }` — see [Forms](#forms) |
521
543
 
522
544
  ```bash
@@ -528,7 +550,7 @@ curl -H "x-mapled-key: $MAPLED_KEY" \
528
550
  - Only published content of collections with access **Public** is served; sensitive fields are never in an answer. Image and file values are asset ids — `https://api.mapled.io/files/{id}` is the file, with `?w=`, `h=`, `fit=`, `fmt=`, `q=` for a resized variant.
529
551
  - Answers are per key — `Cache-Control: private` — with an `ETag`: send it back as `If-None-Match` and an unchanged answer is a 304. A read pinned with `release=N` never changes and may be cached for good. `GET /v1/delivery/release` is the cheap way to learn that something was published: relay it from a route of your own server, and `watchRelease({ endpoint, onPublish })` from `@mapled/next` — no framework in it — tells open tabs ([Live updates](#live-updates)).
530
552
  - Errors share one envelope: `{ error: { code, message, requestId } }`. A key gets 1,200 requests a minute; past that, 429 `RATE_LIMITED` with `retry-after`.
531
- - The publish webhook is a `POST` with a JSON body `{ event, project, release, at, id }` and the headers `x-mapled-event` (`release.published`, `project.restored` or `test`), `x-mapled-event-id` (the same on every retry — use it to deduplicate), `x-mapled-attempt` and `x-mapled-signature`: `sha256=` followed by the hex HMAC-SHA256 of the raw body with the signing secret. Compare in constant time, answer 2xx within 5 seconds. `verifySignature` from `@mapled/next/server` does the check anywhere Web Crypto exists.
553
+ - The publish webhook is a `POST` with a JSON body `{ event, project, release, at, id }` and the headers `x-mapled-event` (`release.published`, `project.restored` or `test`), `x-mapled-event-id` (the same on every retry — use it to deduplicate), `x-mapled-attempt` and `x-mapled-signature`: `sha256=` followed by the hex HMAC-SHA256 of the raw body with the signing secret. Compare in constant time, answer 2xx within 5 seconds. `verifySignature` from `@mapled/next/server` does the check anywhere Web Crypto exists. An answer may carry `x-mapled-cache` — up to 64 letters, digits, `-` or `_` naming the cache the handler revalidated; Mapled records it with the release, and the `?webhook=1` pointer gives it back.
532
554
  - Live data: `GET` / `POST /v1/app/collections/{collection}/records` and `GET` / `PATCH` / `DELETE /v1/app/collections/{collection}/records/{id}` with the server key in `x-mapled-server-key`; writes send `{ "data": { … } }`.
533
555
 
534
556
  Preview (drafts on the site) is built on Next.js draft mode and ships with this package only.
@@ -545,5 +567,6 @@ Preview (drafts on the site) is built on Next.js draft mode and ships with this
545
567
  - `createReleaseHandler({ key, apiUrl?, ttl?, revalidate? })` — App Router `GET` handler of the release route; `<MapledLive endpoint? interval? onPublish? />` from `@mapled/next/live` and `watchRelease({ endpoint?, interval?, onPublish })` ask it
546
568
  - `createPagesReleaseHandler({ key, apiUrl?, ttl? })` — Pages Router API route of the release route (the default export of `pages/api/mapled/release.ts`); `<MapledLive endpoint? interval? onPublish? />` from `@mapled/next/live/pages` asks it
547
569
  - `verifySignature(secret, body, signature)` — if you'd rather build your own handler
570
+ - `cacheMark()` — the mark of this deployment's Next.js cache: a webhook handler of your own answers with it in `x-mapled-cache` (`createRevalidateHandler` does), and `createReleaseHandler` follows only an answer that carries its own
548
571
 
549
572
  The delivery key only reads published, public content — safe to use anywhere.
package/dist/live.js CHANGED
@@ -22,6 +22,7 @@ export function MapledLive({ endpoint, interval, onPublish }) {
22
22
  endpoint,
23
23
  interval,
24
24
  onPublish: (next, previous) => {
25
+ refreshPastBrowserCache();
25
26
  if (custom.current)
26
27
  custom.current(next, previous);
27
28
  else
@@ -30,3 +31,102 @@ export function MapledLive({ endpoint, interval, onPublish }) {
30
31
  }), [endpoint, interval, router]);
31
32
  return null;
32
33
  }
34
+ /** The layers this module has put over `fetch`. */
35
+ const layers = new WeakSet();
36
+ /** Sends every refresh of the page from now on past the browser's cache:
37
+ ours, the one `onPublish` makes later, and one the site makes for its
38
+ own reasons alike — a request doesn't say whose it is, and a refresh
39
+ asks for fresh content anyway. A layer over `fetch` does it, put in
40
+ place when the first publish lands: Next asks `fetch` when it sends,
41
+ not when it loads. Code that wraps `fetch` later wraps the layer too;
42
+ code that puts back a `fetch` it kept from before gets a new layer
43
+ over it on the next publish. A layer, once there, stays: removing it
44
+ could take someone else's wrapper out with it.
45
+ The layer keeps nothing from one request to the next. A bypass armed
46
+ by each publish and spent by the first request that looked like the
47
+ refresh was spent, again and again, by prefetches it couldn't be told
48
+ from once a `fetch` wrapper had dropped their priority (PR 442). */
49
+ function refreshPastBrowserCache() {
50
+ let below;
51
+ try {
52
+ below = globalThis.fetch;
53
+ }
54
+ catch {
55
+ return;
56
+ }
57
+ if (typeof below !== "function" || layers.has(below))
58
+ return;
59
+ const layer = function (input, init) {
60
+ return below.call(this, input, needsBypass(input, init) ? bypassing(init) : init);
61
+ };
62
+ layers.add(layer);
63
+ try {
64
+ globalThis.fetch = layer;
65
+ }
66
+ catch {
67
+ // a fetch that may not be replaced: the refresh goes as before
68
+ }
69
+ }
70
+ /** Whether a request is one the site's server reads as the router's
71
+ refresh of the page the tab is on, left to the browser's default
72
+ cache mode:
73
+ - a GET for the page's server components (the `rsc` header) that is
74
+ not a prefetch (no prefetch header);
75
+ - asked from the root of the router's tree: the `refetch` marker at
76
+ the root of `next-router-state-tree`, which `router.refresh()` sets
77
+ in Next 14, 15 and 16;
78
+ - to the tab's own address, the query compared less the `_rsc` Next
79
+ adds. Next 16 asks a route it doesn't know yet from the root too,
80
+ and `?page=2` of this page is another page here.
81
+ Only what the server reads decides, which a `fetch` wrapper can't
82
+ change without breaking the page — not the `priority` the browser
83
+ keeps, which a wrapper drops when it makes a `Request` of Next's
84
+ `(url, init)` or passes one on as fields. So Next 16's full prefetch
85
+ of this page, asked from the root while its segment cache is empty,
86
+ goes past the cache too: the server can't tell it from the refresh.
87
+ A cache mode someone chose (`no-store`, `reload`, `force-cache`) is
88
+ theirs, and it stays. */
89
+ function needsBypass(input, init) {
90
+ try {
91
+ const request = typeof Request === "function" && input instanceof Request ? input : null;
92
+ if ((init?.cache ?? request?.cache ?? "default") !== "default")
93
+ return false;
94
+ const headers = new Headers(init?.headers ?? request?.headers);
95
+ const method = (init?.method ?? request?.method ?? "GET").toUpperCase();
96
+ if (method !== "GET" || headers.get("rsc") !== "1")
97
+ return false;
98
+ if (headers.has("next-router-prefetch") || headers.has("next-router-segment-prefetch"))
99
+ return false;
100
+ const tree = JSON.parse(decodeURIComponent(headers.get("next-router-state-tree") ?? ""));
101
+ if (!Array.isArray(tree) || tree[3] !== "refetch")
102
+ return false;
103
+ const url = new URL(request ? request.url : String(input), location.href);
104
+ return url.origin === location.origin && url.pathname === location.pathname && query(url.search) === query(location.search);
105
+ }
106
+ catch {
107
+ return false;
108
+ }
109
+ }
110
+ /** The members of `RequestInit`, which `fetch` reads with getters and
111
+ all: a spread copies only an object's own fields, and a `Request`
112
+ handed on as the init keeps its members on its prototype. */
113
+ const MEMBERS = ["body", "cache", "credentials", "duplex", "headers", "integrity", "keepalive", "method", "mode", "priority", "redirect", "referrer", "referrerPolicy", "signal", "window"];
114
+ /** `init` as `fetch` would read it, with `cache: "no-cache"` — the one
115
+ change the layer makes. */
116
+ function bypassing(init) {
117
+ const read = { ...init };
118
+ for (const key of MEMBERS) {
119
+ const value = init?.[key];
120
+ if (value !== undefined)
121
+ read[key] = value;
122
+ }
123
+ read.cache = "no-cache";
124
+ return read;
125
+ }
126
+ /** A query as the page reads it: Next 14 writes the request's anew, 15
127
+ and 16 append `_rsc` to it as it was. */
128
+ function query(search) {
129
+ const params = new URLSearchParams(search);
130
+ params.delete("_rsc");
131
+ return params.toString();
132
+ }
package/dist/server.d.ts CHANGED
@@ -19,6 +19,16 @@ export declare function createPreviewHandler(options?: {
19
19
  }): (req: Request) => Promise<Response>;
20
20
  /** GET handler to leave preview (mount at /api/mapled/preview/exit). */
21
21
  export declare function createExitPreviewHandler(): (req: Request) => Promise<Response>;
22
+ /** The mark of this deployment's Next.js cache: a random value made once
23
+ and kept in that cache, never expiring (`unstable_cache`). Handlers
24
+ that read through the same cache read the same mark; a deployment
25
+ with a cache of its own — a preview, another site on the same key —
26
+ reads another. `createRevalidateHandler` answers the publish webhook
27
+ with it (`x-mapled-cache`), Mapled records it with the release the
28
+ answer acknowledges, and the release route follows only an
29
+ acknowledgement its own cache gave (§18.8). Outside Next.js, or while
30
+ its cache doesn't answer, the mark is this process's. */
31
+ export declare function cacheMark(): Promise<string>;
22
32
  export declare function createRevalidateHandler(options: {
23
33
  secret: string;
24
34
  /** Cache tags to refresh — defaults to ["mapled"], which covers every
@@ -34,10 +44,12 @@ type ReleaseOptions = {
34
44
  /** GET handler for the release route of an App Router site (mount at
35
45
  app/api/mapled/release/route.ts): the release this site serves right
36
46
  now, for `<MapledLive />` from "@mapled/next/live" to ask about. The
37
- pointer is read under the "mapled" tag like the content is, so it
38
- moves when the publish webhook refreshes the cache and not before — a
39
- tab that refreshes on it renders the new release, never the old one
40
- again. */
47
+ pointer is read under the "mapled" tag like the content is, and never
48
+ past the release this deployment's cache has acknowledged through the
49
+ publish webhook — however the cache entry was filled, the route moves
50
+ when the webhook has refreshed this cache and not before, so a tab
51
+ that refreshes on it renders the new release, never the old one
52
+ again. A deployment the webhook doesn't reach names none. */
41
53
  export declare function createReleaseHandler(options: ReleaseOptions & {
42
54
  /** ISR window of the pointer, the same as a read's (default 3600 — the webhook keeps it fresh). */
43
55
  revalidate?: number | false;
package/dist/server.js CHANGED
@@ -83,6 +83,44 @@ export function createExitPreviewHandler() {
83
83
  });
84
84
  };
85
85
  }
86
+ /** A mark as `cacheMark` makes them: 32 hex digits. */
87
+ const MARK = /^[0-9a-f]{32}$/;
88
+ function mint() {
89
+ const bytes = new Uint8Array(16);
90
+ crypto.getRandomValues(bytes);
91
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
92
+ }
93
+ /** what `unstable_cache` runs when the cache has no mark yet */
94
+ async function minted() {
95
+ return mint();
96
+ }
97
+ /** the mark where no Next.js cache answers: this process's */
98
+ let processMark;
99
+ let nextCache;
100
+ /** "next/cache", loaded once — by the first handler that needs it */
101
+ const cacheModule = () => (nextCache ??= import("next/cache"));
102
+ /** The mark of this deployment's Next.js cache: a random value made once
103
+ and kept in that cache, never expiring (`unstable_cache`). Handlers
104
+ that read through the same cache read the same mark; a deployment
105
+ with a cache of its own — a preview, another site on the same key —
106
+ reads another. `createRevalidateHandler` answers the publish webhook
107
+ with it (`x-mapled-cache`), Mapled records it with the release the
108
+ answer acknowledges, and the release route follows only an
109
+ acknowledgement its own cache gave (§18.8). Outside Next.js, or while
110
+ its cache doesn't answer, the mark is this process's. */
111
+ export async function cacheMark() {
112
+ try {
113
+ const { unstable_cache } = await cacheModule();
114
+ // bound: Next keys the entry by the function's source, and a bound function's reads the same in every bundle
115
+ const mark = await unstable_cache(minted.bind(null), ["@mapled/next", "cache-mark"], { revalidate: false })();
116
+ if (typeof mark === "string" && MARK.test(mark))
117
+ return mark;
118
+ }
119
+ catch {
120
+ // outside Next.js, or its cache didn't answer
121
+ }
122
+ return (processMark ??= mint());
123
+ }
86
124
  export function createRevalidateHandler(options) {
87
125
  if (!options.secret) {
88
126
  throw new Error("Mapled: the webhook secret is required.");
@@ -93,56 +131,151 @@ export function createRevalidateHandler(options) {
93
131
  if (!signature || !(await verifySignature(options.secret, body, signature))) {
94
132
  return Response.json({ error: "Invalid signature." }, { status: 401 });
95
133
  }
96
- const { revalidateTag } = await import("next/cache");
134
+ const { revalidateTag } = await cacheModule();
97
135
  for (const tag of options.tags ?? ["mapled"])
98
136
  revalidateTag(tag);
99
- return Response.json({ revalidated: true });
137
+ // which cache this answer speaks for: the release route follows only its own (§18.8)
138
+ return Response.json({ revalidated: true }, { headers: { "x-mapled-cache": await cacheMark() } });
100
139
  };
101
140
  }
141
+ /** `{ release, publishedAt }` as Mapled writes it — a release, or nulls
142
+ before the first; anything else throws. */
143
+ function asPointer(body) {
144
+ const { release = null, publishedAt = null } = (body ?? {});
145
+ if (release === null)
146
+ return { release: null, publishedAt: null };
147
+ if (!Number.isInteger(release) || release < 1 || typeof publishedAt !== "string") {
148
+ throw new Error("Mapled's answer wasn't a release");
149
+ }
150
+ return { release, publishedAt };
151
+ }
152
+ /** The pointer's `webhook` as Mapled writes it; anything else throws. */
153
+ function asAcknowledged(body) {
154
+ const { cache = null } = (body ?? {});
155
+ if (cache !== null && typeof cache !== "string")
156
+ throw new Error("Mapled's answer wasn't a release");
157
+ return { ...asPointer(body), cache };
158
+ }
159
+ /** The pointer and, when asked with `?webhook=1`, the release the site
160
+ has acknowledged through its publish webhook: null — the project has
161
+ no webhook; undefined — this Mapled doesn't say. `copy` is the answer
162
+ as it came, to tell one cached copy from another. */
163
+ async function readPointer(url, init) {
164
+ const res = await fetch(url, init);
165
+ if (!res.ok)
166
+ throw new Error(`Mapled answered ${res.status}`);
167
+ const copy = await res.text();
168
+ const body = JSON.parse(copy);
169
+ const webhook = body?.webhook;
170
+ return { live: asPointer(body), acknowledged: webhook === undefined ? undefined : webhook === null ? null : asAcknowledged(webhook), copy };
171
+ }
172
+ const NONE = { release: null, publishedAt: null };
173
+ const UNACKNOWLEDGED = { ...NONE, cache: null };
174
+ /** What the route names for a copy naming `live`: the release this
175
+ cache acknowledged, never past `live` — none when the acknowledgement
176
+ is another cache's, or names none. */
177
+ function follow(acknowledged, live, mark) {
178
+ if (acknowledged.cache !== mark || acknowledged.release === null)
179
+ return NONE;
180
+ if (acknowledged.release >= (live.release ?? 0))
181
+ return live;
182
+ return { release: acknowledged.release, publishedAt: acknowledged.publishedAt };
183
+ }
102
184
  /** The release route, whichever router mounts it: one answer reused for
103
185
  `ttl` seconds, in this instance and at the CDN in front of it —
104
186
  however many tabs ask, Mapled hears from the site a few times a
105
187
  minute. `next` is how the pointer is fetched: under the content's
106
188
  cache tag in the App Router, as is in the Pages Router, which has no
107
- such cache. */
189
+ such cache.
190
+
191
+ Under the tag the answer is the one the site's cache holds, and an
192
+ entry can be filled at any moment — cold after a deploy, evicted,
193
+ expired — also between a publish's commit and its webhook, while the
194
+ pages still render the release before. So the App Router asks with
195
+ `?webhook=1` and never names a release newer than the one this
196
+ site's cache has acknowledged through the webhook: the site
197
+ revalidates before it answers, and Mapled records the answer with the
198
+ cache's mark. An answer from another cache — the deployment the
199
+ webhook reaches, when this one is a preview or another site on the
200
+ same key — says nothing of this one's pages. Nothing else proves a
201
+ release rendered — without a webhook, from a Mapled that doesn't say,
202
+ or while the acknowledgement is another cache's, the route names
203
+ none. Only while the cached copy names a release it doesn't say is
204
+ acknowledged does the route ask Mapled as it is now — the site may
205
+ have answered since the copy was made — once a ttl, until Mapled
206
+ says a webhook for it was answered. */
108
207
  function releaseAnswers(options, next) {
109
208
  if (!options.key)
110
209
  throw new Error("Mapled: a delivery key is required.");
111
210
  const base = (options.apiUrl ?? "https://api.mapled.io").replace(/\/+$/, "");
112
211
  const ttlMs = Math.max(1, options.ttl ?? 5) * 1000;
212
+ const headers = { "x-mapled-key": options.key };
113
213
  let held = null;
214
+ /** the cached copy Mapled has since said a webhook for was answered, and what that means here: not asked about again */
215
+ let settled = null;
114
216
  /** when Mapled was last asked — an answer or a failure, either waits a ttl */
115
217
  let askedAt = 0;
116
218
  let loading = null;
117
219
  let complained = false;
220
+ let unmarkedSaid = false;
221
+ /** What this site's pages render: the cached pointer, capped at the
222
+ release this site's cache has acknowledged. `trouble` — the read of
223
+ Mapled as it is now failed, and the answer is what the cached copy
224
+ says; `unmarked` — the acknowledgement names no cache. */
225
+ async function siteRelease() {
226
+ const url = `${base}/v1/delivery/release?webhook=1`;
227
+ const cached = await readPointer(url, { headers, next });
228
+ const live = cached.live;
229
+ if (live.release === null || !cached.acknowledged)
230
+ return { answer: NONE };
231
+ const mark = await cacheMark();
232
+ let acknowledged = cached.acknowledged;
233
+ let trouble;
234
+ // the copy may have been made between the site's answer and Mapled's record of it
235
+ if ((acknowledged.release ?? 0) < live.release) {
236
+ if (settled?.copy === cached.copy && settled.mark === mark)
237
+ return { answer: settled.answer };
238
+ try {
239
+ const now = await readPointer(url, { headers, cache: "no-store" });
240
+ // as Mapled has it now: a webhook removed or moved since the copy was made has acknowledged nothing
241
+ acknowledged = now.acknowledged ?? UNACKNOWLEDGED;
242
+ // answered for this copy's release — by this cache or another, asking again changes nothing
243
+ if ((acknowledged.release ?? 0) >= live.release)
244
+ settled = { copy: cached.copy, mark, answer: follow(acknowledged, live, mark) };
245
+ }
246
+ catch (err) {
247
+ trouble = err ?? new Error("request failed");
248
+ }
249
+ }
250
+ return { answer: follow(acknowledged, live, mark), trouble, unmarked: acknowledged.release !== null && acknowledged.cache === null };
251
+ }
118
252
  async function load() {
253
+ let trouble;
254
+ let unmarked;
119
255
  try {
120
- const init = {
121
- headers: { "x-mapled-key": options.key },
122
- ...(next ? { next } : {}),
123
- };
124
- const res = await fetch(`${base}/v1/delivery/release`, init);
125
- if (!res.ok)
126
- throw new Error(`Mapled answered ${res.status}`);
127
- const body = (await res.json());
128
- const release = body?.release ?? null;
129
- const publishedAt = body?.publishedAt ?? null;
130
- const published = Number.isInteger(release) && release >= 1 && typeof publishedAt === "string";
131
- if (!published && release !== null)
132
- throw new Error("Mapled's answer wasn't a release");
133
- held = published ? { release, publishedAt } : { release: null, publishedAt: null };
134
- complained = false;
256
+ if (next)
257
+ ({ answer: held, trouble, unmarked } = await siteRelease());
258
+ else
259
+ held = (await readPointer(`${base}/v1/delivery/release`, { headers })).live;
135
260
  }
136
261
  catch (err) {
137
262
  // the last answer stays good while Mapled can't be reached
138
- if (!complained) {
139
- complained = true;
140
- console.error(`[mapled] The release route can't read the live release: ${err instanceof Error ? err.message : "request failed"}.`);
141
- }
263
+ trouble = err ?? new Error("request failed");
142
264
  }
143
265
  finally {
144
266
  askedAt = Date.now();
145
267
  }
268
+ if (trouble === undefined)
269
+ complained = false;
270
+ else if (!complained) {
271
+ // one line for the developer, not one per poll
272
+ complained = true;
273
+ console.error(`[mapled] The release route can't read the live release: ${trouble instanceof Error ? trouble.message : "request failed"}.`);
274
+ }
275
+ if (unmarked && !unmarkedSaid) {
276
+ unmarkedSaid = true;
277
+ console.warn("[mapled] The release route names no release until your site answers a publish webhook with its cache's mark: createRevalidateHandler does (0.11.0 and later); a webhook handler of your own answers with `x-mapled-cache: await cacheMark()` (README, «Live updates»).");
278
+ }
146
279
  }
147
280
  /** `asked` — the tab's If-None-Match */
148
281
  return async function answer(asked) {
@@ -168,10 +301,12 @@ function releaseAnswers(options, next) {
168
301
  /** GET handler for the release route of an App Router site (mount at
169
302
  app/api/mapled/release/route.ts): the release this site serves right
170
303
  now, for `<MapledLive />` from "@mapled/next/live" to ask about. The
171
- pointer is read under the "mapled" tag like the content is, so it
172
- moves when the publish webhook refreshes the cache and not before — a
173
- tab that refreshes on it renders the new release, never the old one
174
- again. */
304
+ pointer is read under the "mapled" tag like the content is, and never
305
+ past the release this deployment's cache has acknowledged through the
306
+ publish webhook — however the cache entry was filled, the route moves
307
+ when the webhook has refreshed this cache and not before, so a tab
308
+ that refreshes on it renders the new release, never the old one
309
+ again. A deployment the webhook doesn't reach names none. */
175
310
  export function createReleaseHandler(options) {
176
311
  const answer = releaseAnswers(options, { revalidate: options.revalidate ?? 3600, tags: ["mapled"] });
177
312
  return async function GET(req) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mapled/next",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "description": "Read published Mapled content in a Next.js site \u2014 delivery client, ISR tags, and a revalidation webhook handler.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://mapled.io",