@mapled/next 0.9.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 CHANGED
@@ -369,9 +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, 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.
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.
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" />`.
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.
375
396
 
376
397
  Outside React, `watchRelease({ onPublish })` from `@mapled/next` is the same watch as a plain function — it returns the function that stops it.
377
398
 
@@ -504,6 +525,8 @@ await db.remove("orders", id);
504
525
 
505
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.
506
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
+
507
530
  ## Without Next.js: the HTTP API
508
531
 
509
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`.
@@ -514,7 +537,7 @@ The client is a thin layer over a plain HTTP API, so any site — or a build scr
514
537
  | `GET /v1/delivery/collections/{collection}/records/{id}` | `{ record, release }` — 404 when it isn't in the release |
515
538
  | `GET /v1/delivery/collections/{collection}/records/by-slug/{slug}` | `{ record, release }` |
516
539
  | `GET /v1/delivery/singles/{single}` | `{ record, release }` — `record` is `null` while the single is empty |
517
- | `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 |
518
541
  | `POST /v1/delivery/forms/{form}` | `201 { ok: true }` — see [Forms](#forms) |
519
542
 
520
543
  ```bash
@@ -526,7 +549,7 @@ curl -H "x-mapled-key: $MAPLED_KEY" \
526
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.
527
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)).
528
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`.
529
- - 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.
530
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": { … } }`.
531
554
 
532
555
  Preview (drafts on the site) is built on Next.js draft mode and ships with this package only.
@@ -543,5 +566,6 @@ Preview (drafts on the site) is built on Next.js draft mode and ships with this
543
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
544
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
545
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
546
570
 
547
571
  The delivery key only reads published, public content — safe to use anywhere.
@@ -7,8 +7,8 @@
7
7
  <><Component {...pageProps} /><MapledLive /></>
8
8
 
9
9
  It asks the release route (`createPagesReleaseHandler` from
10
- "@mapled/next/server", mounted at pages/api/mapled/release) every few
11
- seconds and, when a publish lands, runs the page's data again through
10
+ "@mapled/next/server", mounted at pages/api/mapled/release — under
11
+ the site's basePath, when next.config sets one) every few seconds and, when a publish lands, runs the page's data again through
12
12
  the router, in place — scroll position and client state stay. A
13
13
  module of its own, so an App Router bundle never pulls in
14
14
  `next/router`, nor a Pages one `next/navigation`. */
package/dist/live.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { type Release } from "./watch.js";
2
2
  export type { Release } from "./watch.js";
3
3
  export type MapledLiveProps = {
4
- /** Where the release route is mounted (default "/api/mapled/release"). */
4
+ /** Where the release route is mounted, asked as written (default
5
+ "/api/mapled/release" under the site's `basePath`). */
5
6
  endpoint?: string;
6
7
  /** Seconds between checks while the tab is visible (default 10, at least 2). */
7
8
  interval?: number;
package/dist/live.js CHANGED
@@ -6,8 +6,8 @@
6
6
  <body>{children}<MapledLive /></body>
7
7
 
8
8
  It asks the release route (`createReleaseHandler` from
9
- "@mapled/next/server", mounted at /api/mapled/release) every few
10
- seconds and, when a publish lands, refreshes the page's server
9
+ "@mapled/next/server", mounted at /api/mapled/release — under the
10
+ site's basePath, when next.config sets one) every few seconds and, when a publish lands, refreshes the page's server
11
11
  components in place — scroll position and client state stay. */
12
12
  import { useRouter } from "next/navigation";
13
13
  import { useEffect, useRef } from "react";
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/dist/watch.d.ts CHANGED
@@ -8,7 +8,8 @@ export type Release = {
8
8
  publishedAt: string;
9
9
  };
10
10
  export type WatchOptions = {
11
- /** Where the release route is mounted (default "/api/mapled/release"). */
11
+ /** Where the release route is mounted, asked as written (default
12
+ "/api/mapled/release" under the site's `basePath`). */
12
13
  endpoint?: string;
13
14
  /** Seconds between checks while the tab is visible (default 10, at least 2). */
14
15
  interval?: number;
@@ -16,6 +17,13 @@ export type WatchOptions = {
16
17
  watch knew before — null when nothing was published yet. */
17
18
  onPublish: (next: Release, previous: number | null) => void;
18
19
  };
20
+ /** The site's `basePath` from next.config, "" without one. Next writes it
21
+ into the code it bundles — its own and every package's — in place of
22
+ this very expression, the way its router adds it to every link, so a
23
+ route under the basePath is where the default endpoint lives. Outside
24
+ Next a browser has no `process`, and Node no such variable: "". Not
25
+ part of the package's API. */
26
+ export declare function nextBasePath(): string;
19
27
  /** Starts watching; returns the function that stops it. The first answer
20
28
  is where the watch starts from; `onPublish` runs once per release that
21
29
  comes after it. A hidden tab doesn't ask — it checks the moment it is
package/dist/watch.js CHANGED
@@ -5,6 +5,21 @@
5
5
  is this plus `router.refresh()`. */
6
6
  const DEFAULT_ENDPOINT = "/api/mapled/release";
7
7
  const MAX_BACKOFF_MS = 5 * 60_000;
8
+ /** The site's `basePath` from next.config, "" without one. Next writes it
9
+ into the code it bundles — its own and every package's — in place of
10
+ this very expression, the way its router adds it to every link, so a
11
+ route under the basePath is where the default endpoint lives. Outside
12
+ Next a browser has no `process`, and Node no such variable: "". Not
13
+ part of the package's API. */
14
+ export function nextBasePath() {
15
+ try {
16
+ const base = process.env.__NEXT_ROUTER_BASEPATH;
17
+ return typeof base === "string" && base.startsWith("/") ? base : "";
18
+ }
19
+ catch {
20
+ return "";
21
+ }
22
+ }
8
23
  /** The route's answer — null before the first publish — or undefined
9
24
  when it isn't one. */
10
25
  function pointer(body) {
@@ -31,7 +46,7 @@ export function watchRelease(options) {
31
46
  it, an older one (or null: not known) to hand the first answer to
32
47
  `onPublish` as well. Not part of the package's API. */
33
48
  export function watchFrom(options, from) {
34
- const endpoint = options.endpoint ?? DEFAULT_ENDPOINT;
49
+ const endpoint = options.endpoint ?? nextBasePath() + DEFAULT_ENDPOINT;
35
50
  const everyMs = Math.max(2, options.interval ?? 10) * 1000;
36
51
  const doc = typeof document === "undefined" ? null : document;
37
52
  /** undefined until the first answer; null while nothing is published */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mapled/next",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
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",