@mapled/next 0.10.0 → 0.11.0
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 +26 -4
- package/dist/server.d.ts +16 -4
- package/dist/server.js +161 -26
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -369,11 +369,30 @@ 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,
|
|
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.
|
|
374
393
|
- 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
394
|
- 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
|
|
395
|
+
- 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
396
|
|
|
378
397
|
Outside React, `watchRelease({ onPublish })` from `@mapled/next` is the same watch as a plain function — it returns the function that stops it.
|
|
379
398
|
|
|
@@ -506,6 +525,8 @@ await db.remove("orders", id);
|
|
|
506
525
|
|
|
507
526
|
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
527
|
|
|
528
|
+
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.
|
|
529
|
+
|
|
509
530
|
## Without Next.js: the HTTP API
|
|
510
531
|
|
|
511
532
|
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 +537,7 @@ The client is a thin layer over a plain HTTP API, so any site — or a build scr
|
|
|
516
537
|
| `GET /v1/delivery/collections/{collection}/records/{id}` | `{ record, release }` — 404 when it isn't in the release |
|
|
517
538
|
| `GET /v1/delivery/collections/{collection}/records/by-slug/{slug}` | `{ record, release }` |
|
|
518
539
|
| `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 |
|
|
540
|
+
| `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
541
|
| `POST /v1/delivery/forms/{form}` | `201 { ok: true }` — see [Forms](#forms) |
|
|
521
542
|
|
|
522
543
|
```bash
|
|
@@ -528,7 +549,7 @@ curl -H "x-mapled-key: $MAPLED_KEY" \
|
|
|
528
549
|
- 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
550
|
- 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
551
|
- 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.
|
|
552
|
+
- 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
553
|
- 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
554
|
|
|
534
555
|
Preview (drafts on the site) is built on Next.js draft mode and ships with this package only.
|
|
@@ -545,5 +566,6 @@ Preview (drafts on the site) is built on Next.js draft mode and ships with this
|
|
|
545
566
|
- `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
567
|
- `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
568
|
- `verifySignature(secret, body, signature)` — if you'd rather build your own handler
|
|
569
|
+
- `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
570
|
|
|
549
571
|
The delivery key only reads published, public content — safe to use anywhere.
|
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,
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
134
|
+
const { revalidateTag } = await cacheModule();
|
|
97
135
|
for (const tag of options.tags ?? ["mapled"])
|
|
98
136
|
revalidateTag(tag);
|
|
99
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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,
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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