@mapled/next 0.7.1 → 0.9.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
@@ -1,12 +1,12 @@
1
1
  # @mapled/next
2
2
 
3
- Read published [Mapled](https://mapled.io) content in a Next.js site — a delivery client with ISR cache tags, plus a revalidation webhook handler so publishes show up instantly.
3
+ Read published [Mapled](https://mapled.io) content in a Next.js site — a delivery client with ISR cache tags, a revalidation webhook handler so publishes show up instantly, and live updates for the tabs that are already open.
4
4
 
5
5
  Mapled is a hosted headless CMS built for sites created with AI. Your agent (or you) models collections and fills in content; this package is how the site reads what's published.
6
6
 
7
7
  - [Install](#install)
8
8
  - [Integrate by hand](#integrate-by-hand) — the whole integration step by step, no AI agent needed
9
- - Reference: [Read published content](#read-published-content) • [Rich text](#rich-text) • [Images](#images) • [Refresh on publish](#refresh-on-publish) • [Preview drafts](#preview-drafts) • [Forms](#forms) • [Live data](#live-data) • [Without Next.js: the HTTP API](#without-nextjs-the-http-api) • [API](#api)
9
+ - Reference: [Read published content](#read-published-content) • [Rich text](#rich-text) • [Images](#images) • [Refresh on publish](#refresh-on-publish) • [Live updates](#live-updates) • [Preview drafts](#preview-drafts) • [Forms](#forms) • [Live data](#live-data) • [Without Next.js: the HTTP API](#without-nextjs-the-http-api) • [API](#api)
10
10
 
11
11
  ## Install
12
12
 
@@ -153,6 +153,8 @@ export const POST = createRevalidateHandler({
153
153
 
154
154
  Deploy, then in Mapled → **Integrations** → **Your site** save `https://your-site.com/api/mapled/revalidate` as the **Revalidation webhook**, copy the **Signing secret** into `MAPLED_WEBHOOK_SECRET` (hosting env and `.env.local`), redeploy, and press **Send test ping**: the delivery shows up under **Recent deliveries** as Delivered. The URL has to be public — Mapled refuses addresses that resolve to a private network.
155
155
 
156
+ Optional: pages that are already open can follow a publish too, without a reload — a route and one component, see [Live updates](#live-updates).
157
+
156
158
  ### 7. Preview drafts
157
159
 
158
160
  ```ts
@@ -315,6 +317,96 @@ Then in Mapled (**Integrations** → **Your site**) set the revalidation webhook
315
317
 
316
318
  Each ping is tried up to three times (after 2 and 6 seconds, 5 seconds to answer); **Recent deliveries** keeps them for 30 days, and a failed one can be sent again from there. **Rotate secret** replaces the signing secret at once — deliveries fail until the site has the new one.
317
319
 
320
+ ## Live updates
321
+
322
+ The webhook refreshes the site's cache; a page someone already has open still shows what it rendered. Two files make open tabs follow Publish as well. A route that says which release the site serves:
323
+
324
+ ```ts
325
+ // app/api/mapled/release/route.ts
326
+ import { createReleaseHandler } from "@mapled/next/server";
327
+
328
+ export const GET = createReleaseHandler({ key: process.env.MAPLED_KEY! });
329
+ ```
330
+
331
+ and one component in the root layout:
332
+
333
+ ```tsx
334
+ // app/layout.tsx
335
+ import type { ReactNode } from "react";
336
+ import { MapledLive } from "@mapled/next/live";
337
+
338
+ export default function RootLayout({ children }: { children: ReactNode }) {
339
+ return (
340
+ <html lang="en">
341
+ <body>
342
+ {children}
343
+ <MapledLive />
344
+ </body>
345
+ </html>
346
+ );
347
+ }
348
+ ```
349
+
350
+ `<MapledLive />` renders nothing. While the tab is visible it asks the route every 10 seconds (`interval`, in seconds), and when a publish lands it calls `router.refresh()`: Server Components re-render with the new release in place — scroll position and client state stay. A hidden tab doesn't ask; it checks the moment it is shown again. To offer the update instead of applying it under the reader, pass `onPublish`:
351
+
352
+ ```tsx
353
+ "use client";
354
+
355
+ import { useRouter } from "next/navigation";
356
+ import { useState } from "react";
357
+ import { MapledLive } from "@mapled/next/live";
358
+
359
+ export function UpdateNotice() {
360
+ const router = useRouter();
361
+ const [fresh, setFresh] = useState(false);
362
+ return (
363
+ <>
364
+ <MapledLive onPublish={() => setFresh(true)} />
365
+ {fresh ? <button onClick={() => { router.refresh(); setFresh(false); }}>New content — refresh</button> : null}
366
+ </>
367
+ );
368
+ }
369
+ ```
370
+
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.
373
+ - 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
+ - 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
+
376
+ Outside React, `watchRelease({ onPublish })` from `@mapled/next` is the same watch as a plain function — it returns the function that stops it.
377
+
378
+ ### Pages Router
379
+
380
+ A site on `pages/` mounts the route as an API route and renders the component from `@mapled/next/live/pages` in its custom App:
381
+
382
+ ```ts
383
+ // Pages Router: pages/api/mapled/release.ts
384
+ import { createPagesReleaseHandler } from "@mapled/next/server";
385
+
386
+ export default createPagesReleaseHandler({ key: process.env.MAPLED_KEY! });
387
+ ```
388
+
389
+ ```tsx
390
+ // Pages Router: pages/_app.tsx
391
+ import type { AppProps } from "next/app";
392
+ import { MapledLive } from "@mapled/next/live/pages";
393
+
394
+ export default function App({ Component, pageProps }: AppProps) {
395
+ return (
396
+ <>
397
+ <Component {...pageProps} />
398
+ <MapledLive />
399
+ </>
400
+ );
401
+ }
402
+ ```
403
+
404
+ On a publish it replaces the page with itself through `next/router`, so the page's data runs again and it re-renders with the new props in place: scroll position and client state stay. A `#hash` in the address goes: while there is one, a replace to the same page runs no data, and every render the router makes scrolls back to the hash's anchor. For the router it is a navigation to the same URL, so `routeChangeStart` and `routeChangeComplete` fire — a pageview counter listening to them counts it too. A publish that lands while the reader is on the way to another page, or to an anchor on this one, doesn't cancel that navigation: it waits for the page they land on and refreshes that one. The first check counts too: when the release it finds went live after the page was requested — a publish between `getServerSideProps` and that check, or while a tab opened in the background waited to be shown — the page refreshes, and `onPublish` gets `previous` as `null` (which release the page read, the tab can't tell). A page requested within ten seconds after a publish may refresh once for nothing. `endpoint`, `interval` and `onPublish` work as above. (`<MapledLive />` from `@mapled/next/live` would reload the whole tab here.)
405
+
406
+ - **Pages that follow Publish read in `getServerSideProps`.** The Pages Router has no cache tag the route could share with a page, so `createPagesReleaseHandler` answers the live release as Mapled has it, and it moves the moment someone publishes. A page that reads in `getServerSideProps` reads that same release, so the refresh always renders the new content. Keep such a page out of shared caches — Next.js sends it uncached unless you set `Cache-Control` yourself.
407
+ - A `getStaticProps` page can't keep up: it is a copy Next.js regenerates on its own schedule — when its `revalidate` window runs out, or when a webhook of yours calls `res.revalidate` — and the route can't wait for that. A tab that refreshes before the copy does gets the old copy back and won't hear of this publish again. Read the pages that should follow Publish in `getServerSideProps` instead.
408
+ - Reads a page makes in the browser aren't part of its data: reload them in `onPublish`.
409
+
318
410
  ## Preview drafts
319
411
 
320
412
  Mount the preview routes:
@@ -432,7 +524,7 @@ curl -H "x-mapled-key: $MAPLED_KEY" \
432
524
 
433
525
  - Lists take `filter[field]=value` or `filter[field][op]=value` (`eq`, `ne`, `lt`, `lte`, `gt`, `gte`, `in` — comma-separated, `contains`), `sort=field` / `sort=-field`, `limit` (1–100, default 100) and `offset`. Every read takes `expand` (relation keys, dotted for a second level), `fields` and `release=N` to read a published release by its number. A query the schema can't answer is a 400 `INVALID_QUERY` that says why.
434
526
  - 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.
435
- - 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.
527
+ - 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)).
436
528
  - Errors share one envelope: `{ error: { code, message, requestId } }`. A key gets 1,200 requests a minute; past that, 429 `RATE_LIMITED` with `retry-after`.
437
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.
438
530
  - 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": { … } }`.
@@ -448,6 +540,8 @@ Preview (drafts on the site) is built on Next.js draft mode and ships with this
448
540
  - `assetUrl(id, { width?, height?, fit?, format?, quality?, apiUrl? })` — URL of an image or file value
449
541
  - `createRevalidateHandler({ secret, tags? })` — App Router `POST` handler
450
542
  - `createPreviewHandler({ apiUrl?, appUrl? })`, `createExitPreviewHandler()` — App Router `GET` handlers of the preview routes
543
+ - `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
+ - `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
451
545
  - `verifySignature(secret, body, signature)` — if you'd rather build your own handler
452
546
 
453
547
  The delivery key only reads published, public content — safe to use anywhere.
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /** Mapled delivery client for Next.js sites.
2
2
  Reads published content only — pair it with the revalidation webhook
3
3
  handler from "@mapled/next/server" for instant updates on publish. */
4
+ export { watchRelease, type Release, type WatchOptions } from "./watch.js";
4
5
  export type MapledRecord<T = Record<string, unknown>> = {
5
6
  id: string;
6
7
  data: T;
@@ -158,4 +159,3 @@ export declare function createAppClient<S extends ContentSchema = ContentSchema>
158
159
  serverKey: string;
159
160
  apiUrl?: string;
160
161
  }): MapledAppClient<S>;
161
- export {};
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  /** Mapled delivery client for Next.js sites.
2
2
  Reads published content only — pair it with the revalidation webhook
3
3
  handler from "@mapled/next/server" for instant updates on publish. */
4
+ export { watchRelease } from "./watch.js";
4
5
  /** Renamed fields (previous keys): Mapled keeps serving a renamed field
5
6
  under its old key — and accepting it in filters, sorts and writes —
6
7
  until the alias is removed in Field settings, and names such keys in
@@ -0,0 +1,4 @@
1
+ import type { MapledLiveProps } from "./live.js";
2
+ export type { MapledLiveProps } from "./live.js";
3
+ export type { Release } from "./watch.js";
4
+ export declare function MapledLive({ endpoint, interval, onPublish }: MapledLiveProps): null;
@@ -0,0 +1,142 @@
1
+ "use client";
2
+ /** Open tabs of a Pages Router site follow Publish. Render it once, in
3
+ the custom App:
4
+
5
+ pages/_app.tsx:
6
+ import { MapledLive } from "@mapled/next/live/pages";
7
+ <><Component {...pageProps} /><MapledLive /></>
8
+
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
12
+ the router, in place — scroll position and client state stay. A
13
+ module of its own, so an App Router bundle never pulls in
14
+ `next/router`, nor a Pages one `next/navigation`. */
15
+ import { useRouter } from "next/router";
16
+ import { useEffect, useRef } from "react";
17
+ import { watchFrom } from "./watch.js";
18
+ /** The Pages Router's way to re-run a page's data: a replace to where the
19
+ tab already is. Two details make it an actual refresh — the client
20
+ keeps `getStaticProps` data it fetched once, so its cache is skipped;
21
+ and while the address has a #hash, the router takes any replace to
22
+ the same path — with the hash or without it — for a hash change that
23
+ runs no data, and every render it makes scrolls back to the hash's
24
+ anchor. So the hash is left first, without scrolling, and stays gone:
25
+ putting it back would throw the reader back to the anchor. */
26
+ function refresh(router) {
27
+ const at = router.asPath;
28
+ const cut = at.indexOf("#");
29
+ const path = cut === -1 ? at : at.slice(0, cut);
30
+ const run = () => router.replace(path, undefined, { scroll: false, unstable_skipClientCache: true });
31
+ (cut === -1
32
+ ? run()
33
+ : // false: another navigation took over — the tab is somewhere else now
34
+ router.replace(path, undefined, { scroll: false, shallow: true }).then((ok) => ok && run()))
35
+ // the router has reported it (routeChangeError), and a page it can't load it loads in full
36
+ .catch(() => false);
37
+ }
38
+ const landed = (trip, now) => !trip.moving && now !== trip.from;
39
+ /** Mapled stamps a release when its publish starts, not when it commits
40
+ (a big one takes seconds), and its clock and the site's differ a
41
+ little: a release stamped up to this long before the page was asked
42
+ for may still be newer than what the page read. */
43
+ const MARGIN_MS = 10_000;
44
+ /** Whether `first` went live after this document was asked for — then
45
+ the page may have read the release before it: a publish between
46
+ getServerSideProps and the first check, or while a tab opened in the
47
+ background waited to be shown before asking. Compared on the servers'
48
+ clock, which the route's answer tells (`Date`, plus `Age` from a CDN),
49
+ so a browser whose clock is off changes nothing; an answer without
50
+ `Date` leaves the browser's own. Every error of the estimate — a
51
+ second's rounding in `Date`, the time the answer took to arrive —
52
+ makes a refresh likelier, never rarer; a page asked for within the
53
+ margin after a publish refreshes once for nothing. */
54
+ function newerThanPage(first, headers) {
55
+ const askedAt = globalThis.performance?.timeOrigin;
56
+ if (askedAt === undefined)
57
+ return false;
58
+ const date = Date.parse(headers.get("date") ?? "");
59
+ const offset = Number.isNaN(date) ? 0 : date + (Number(headers.get("age")) || 0) * 1000 - Date.now();
60
+ return Date.parse(first.publishedAt) > askedAt + offset - MARGIN_MS;
61
+ }
62
+ export function MapledLive({ endpoint, interval, onPublish }) {
63
+ // the Pages Router hands out a new router object on every render: the
64
+ // watch reads the latest one and restarts only for a new route or pace
65
+ const router = useRouter();
66
+ const latest = useRef({ router, onPublish });
67
+ const trip = useRef({ moving: false, from: null, owed: false });
68
+ /** the newest release the page has started from or heard of — a watch
69
+ restarted for a new route or pace starts from it, not from the time */
70
+ const followed = useRef(undefined);
71
+ /** runs a refresh owed by a publish mid-navigation, once the page it
72
+ led to has rendered — the router's end-of-navigation event and
73
+ React's effect with the new router object come in either order */
74
+ const settle = () => {
75
+ const now = latest.current.router;
76
+ if (trip.current.owed && landed(trip.current, now)) {
77
+ trip.current.owed = false;
78
+ refresh(now);
79
+ }
80
+ };
81
+ useEffect(() => {
82
+ latest.current = { router, onPublish };
83
+ settle();
84
+ }, [router, onPublish]);
85
+ useEffect(() => {
86
+ const { events } = latest.current.router;
87
+ const start = () => {
88
+ trip.current.moving = true;
89
+ trip.current.from = latest.current.router;
90
+ };
91
+ const end = () => {
92
+ trip.current.moving = false;
93
+ settle();
94
+ };
95
+ // a cancelled navigation is not an end: what cancelled it is itself a
96
+ // navigation, which has started, or a hash change, or leaves the page
97
+ const failed = (err) => {
98
+ if (!err?.cancelled)
99
+ end();
100
+ };
101
+ // a jump to an anchor has no routeChangeStart, but the router awaits
102
+ // its render all the same
103
+ events.on("routeChangeStart", start);
104
+ events.on("hashChangeStart", start);
105
+ events.on("routeChangeComplete", end);
106
+ events.on("routeChangeError", failed);
107
+ events.on("hashChangeComplete", end);
108
+ return () => {
109
+ events.off("routeChangeStart", start);
110
+ events.off("hashChangeStart", start);
111
+ events.off("routeChangeComplete", end);
112
+ events.off("routeChangeError", failed);
113
+ events.off("hashChangeComplete", end);
114
+ };
115
+ }, []);
116
+ useEffect(() => watchFrom({
117
+ endpoint,
118
+ interval,
119
+ onPublish: (next, previous) => {
120
+ followed.current = next.release;
121
+ const { router: now, onPublish: custom } = latest.current;
122
+ if (custom)
123
+ custom(next, previous);
124
+ else if (!landed(trip.current, now))
125
+ trip.current.owed = true;
126
+ else {
127
+ trip.current.owed = false;
128
+ refresh(now);
129
+ }
130
+ },
131
+ },
132
+ // the first answer names what is live, not what the page read:
133
+ // one that went live after the page was asked for counts as a
134
+ // publish, with no `previous` — which release the page read, the
135
+ // tab can't tell
136
+ (first, headers) => {
137
+ const from = followed.current ?? (newerThanPage(first, headers) ? null : first.release);
138
+ followed.current = Math.max(first.release, followed.current ?? 0);
139
+ return from;
140
+ }), [endpoint, interval]);
141
+ return null;
142
+ }
package/dist/live.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ import { type Release } from "./watch.js";
2
+ export type { Release } from "./watch.js";
3
+ export type MapledLiveProps = {
4
+ /** Where the release route is mounted (default "/api/mapled/release"). */
5
+ endpoint?: string;
6
+ /** Seconds between checks while the tab is visible (default 10, at least 2). */
7
+ interval?: number;
8
+ /** Runs instead of the refresh — to offer "New content — refresh"
9
+ rather than change the page under the reader. */
10
+ onPublish?: (next: Release, previous: number | null) => void;
11
+ };
12
+ export declare function MapledLive({ endpoint, interval, onPublish }: MapledLiveProps): null;
package/dist/live.js ADDED
@@ -0,0 +1,32 @@
1
+ "use client";
2
+ /** Open tabs follow Publish. Render it once, in the root layout:
3
+
4
+ app/layout.tsx:
5
+ import { MapledLive } from "@mapled/next/live";
6
+ <body>{children}<MapledLive /></body>
7
+
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
11
+ components in place — scroll position and client state stay. */
12
+ import { useRouter } from "next/navigation";
13
+ import { useEffect, useRef } from "react";
14
+ import { watchRelease } from "./watch.js";
15
+ export function MapledLive({ endpoint, interval, onPublish }) {
16
+ const router = useRouter();
17
+ const custom = useRef(onPublish);
18
+ useEffect(() => {
19
+ custom.current = onPublish;
20
+ }, [onPublish]);
21
+ useEffect(() => watchRelease({
22
+ endpoint,
23
+ interval,
24
+ onPublish: (next, previous) => {
25
+ if (custom.current)
26
+ custom.current(next, previous);
27
+ else
28
+ router.refresh();
29
+ },
30
+ }), [endpoint, interval, router]);
31
+ return null;
32
+ }
package/dist/server.d.ts CHANGED
@@ -25,3 +25,42 @@ export declare function createRevalidateHandler(options: {
25
25
  client fetch. */
26
26
  tags?: string[];
27
27
  }): (req: Request) => Promise<Response>;
28
+ type ReleaseOptions = {
29
+ key: string;
30
+ apiUrl?: string;
31
+ /** Seconds one answer is reused (default 5, at least 1). */
32
+ ttl?: number;
33
+ };
34
+ /** GET handler for the release route of an App Router site (mount at
35
+ app/api/mapled/release/route.ts): the release this site serves right
36
+ 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. */
41
+ export declare function createReleaseHandler(options: ReleaseOptions & {
42
+ /** ISR window of the pointer, the same as a read's (default 3600 — the webhook keeps it fresh). */
43
+ revalidate?: number | false;
44
+ }): (req: Request) => Promise<Response>;
45
+ /** What the Pages Router hands an API route: `NextApiRequest` and
46
+ `NextApiResponse` are these and more. */
47
+ type PagesApiRequest = {
48
+ method?: string;
49
+ headers: Record<string, string | string[] | undefined>;
50
+ };
51
+ type PagesApiResponse = {
52
+ statusCode: number;
53
+ setHeader(name: string, value: string): unknown;
54
+ end(body?: string): unknown;
55
+ };
56
+ /** The release route of a Pages Router site — the default export of
57
+ pages/api/mapled/release.ts, for `<MapledLive />` from
58
+ "@mapled/next/live/pages" to ask about. The Pages Router has no cache
59
+ tag a route could share with a page, so the pointer is Mapled's live
60
+ release, read every `ttl` seconds: it moves the moment someone
61
+ publishes. A page that reads in `getServerSideProps` reads that same
62
+ release, and a tab refreshed on it renders the new content; a
63
+ `getStaticProps` page is a copy Next regenerates on its own schedule,
64
+ which the pointer can't wait for (README, «Live updates»). */
65
+ export declare function createPagesReleaseHandler(options: ReleaseOptions): (req: PagesApiRequest, res: PagesApiResponse) => Promise<void>;
66
+ export {};
package/dist/server.js CHANGED
@@ -99,3 +99,117 @@ export function createRevalidateHandler(options) {
99
99
  return Response.json({ revalidated: true });
100
100
  };
101
101
  }
102
+ /** The release route, whichever router mounts it: one answer reused for
103
+ `ttl` seconds, in this instance and at the CDN in front of it —
104
+ however many tabs ask, Mapled hears from the site a few times a
105
+ minute. `next` is how the pointer is fetched: under the content's
106
+ cache tag in the App Router, as is in the Pages Router, which has no
107
+ such cache. */
108
+ function releaseAnswers(options, next) {
109
+ if (!options.key)
110
+ throw new Error("Mapled: a delivery key is required.");
111
+ const base = (options.apiUrl ?? "https://api.mapled.io").replace(/\/+$/, "");
112
+ const ttlMs = Math.max(1, options.ttl ?? 5) * 1000;
113
+ let held = null;
114
+ /** when Mapled was last asked — an answer or a failure, either waits a ttl */
115
+ let askedAt = 0;
116
+ let loading = null;
117
+ let complained = false;
118
+ async function load() {
119
+ 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;
135
+ }
136
+ catch (err) {
137
+ // 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
+ }
142
+ }
143
+ finally {
144
+ askedAt = Date.now();
145
+ }
146
+ }
147
+ /** `asked` — the tab's If-None-Match */
148
+ return async function answer(asked) {
149
+ if (Date.now() - askedAt >= ttlMs) {
150
+ loading ??= load().finally(() => {
151
+ loading = null;
152
+ });
153
+ await loading;
154
+ }
155
+ if (!held) {
156
+ return { status: 502, headers: { "cache-control": "no-store" }, body: { error: "The live release isn't available right now." } };
157
+ }
158
+ // the CDN's copy lives no longer than this instance's
159
+ const left = Math.max(1, Math.ceil((ttlMs - (Date.now() - askedAt)) / 1000));
160
+ const etag = `W/"r${held.release ?? 0}"`;
161
+ const headers = { "cache-control": `public, max-age=0, s-maxage=${left}`, etag };
162
+ if (asked && asked.split(",").some((t) => t.trim().replace(/^W\//, "") === etag.slice(2))) {
163
+ return { status: 304, headers };
164
+ }
165
+ return { status: 200, headers, body: held };
166
+ };
167
+ }
168
+ /** GET handler for the release route of an App Router site (mount at
169
+ app/api/mapled/release/route.ts): the release this site serves right
170
+ 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. */
175
+ export function createReleaseHandler(options) {
176
+ const answer = releaseAnswers(options, { revalidate: options.revalidate ?? 3600, tags: ["mapled"] });
177
+ return async function GET(req) {
178
+ // read first: a handler that looks at its request is never prerendered
179
+ const { status, headers, body } = await answer(req.headers.get("if-none-match"));
180
+ return status === 304 ? new Response(null, { status, headers }) : Response.json(body, { status, headers });
181
+ };
182
+ }
183
+ /** The release route of a Pages Router site — the default export of
184
+ pages/api/mapled/release.ts, for `<MapledLive />` from
185
+ "@mapled/next/live/pages" to ask about. The Pages Router has no cache
186
+ tag a route could share with a page, so the pointer is Mapled's live
187
+ release, read every `ttl` seconds: it moves the moment someone
188
+ publishes. A page that reads in `getServerSideProps` reads that same
189
+ release, and a tab refreshed on it renders the new content; a
190
+ `getStaticProps` page is a copy Next regenerates on its own schedule,
191
+ which the pointer can't wait for (README, «Live updates»). */
192
+ export function createPagesReleaseHandler(options) {
193
+ const answer = releaseAnswers(options);
194
+ return async function handler(req, res) {
195
+ const method = req.method ?? "GET";
196
+ if (method !== "GET" && method !== "HEAD") {
197
+ res.statusCode = 405;
198
+ res.setHeader("allow", "GET, HEAD");
199
+ res.end();
200
+ return;
201
+ }
202
+ const asked = req.headers["if-none-match"];
203
+ const { status, headers, body } = await answer(Array.isArray(asked) ? asked.join(",") : (asked ?? null));
204
+ res.statusCode = status;
205
+ for (const [name, value] of Object.entries(headers))
206
+ res.setHeader(name, value);
207
+ if (body === undefined) {
208
+ res.end();
209
+ return;
210
+ }
211
+ // written as is: res.json would swap the ETag for a hash of the body
212
+ res.setHeader("content-type", "application/json; charset=utf-8");
213
+ res.end(method === "HEAD" ? undefined : JSON.stringify(body));
214
+ };
215
+ }
@@ -0,0 +1,30 @@
1
+ /** Publish notifications for open tabs: the page asks its own server
2
+ which release is live (the route `createReleaseHandler` from
3
+ "@mapled/next/server" serves) and hears about it when that moves.
4
+ Framework-free on purpose — `<MapledLive />` from "@mapled/next/live"
5
+ is this plus `router.refresh()`. */
6
+ export type Release = {
7
+ release: number;
8
+ publishedAt: string;
9
+ };
10
+ export type WatchOptions = {
11
+ /** Where the release route is mounted (default "/api/mapled/release"). */
12
+ endpoint?: string;
13
+ /** Seconds between checks while the tab is visible (default 10, at least 2). */
14
+ interval?: number;
15
+ /** A publish landed: `next` is live now; `previous` is the release the
16
+ watch knew before — null when nothing was published yet. */
17
+ onPublish: (next: Release, previous: number | null) => void;
18
+ };
19
+ /** Starts watching; returns the function that stops it. The first answer
20
+ is where the watch starts from; `onPublish` runs once per release that
21
+ comes after it. A hidden tab doesn't ask — it checks the moment it is
22
+ shown again — and a route that fails is asked less and less often. */
23
+ export declare function watchRelease(options: WatchOptions): () => void;
24
+ /** The watch behind `watchRelease`, for a caller that can tell more about
25
+ where to start than the first answer can. `from` gets the first
26
+ release the route names, with the answer's headers, and returns the
27
+ release the page is on — the first answer's own number to start from
28
+ it, an older one (or null: not known) to hand the first answer to
29
+ `onPublish` as well. Not part of the package's API. */
30
+ export declare function watchFrom(options: WatchOptions, from?: (first: Release, headers: Headers) => number | null): () => void;
package/dist/watch.js ADDED
@@ -0,0 +1,104 @@
1
+ /** Publish notifications for open tabs: the page asks its own server
2
+ which release is live (the route `createReleaseHandler` from
3
+ "@mapled/next/server" serves) and hears about it when that moves.
4
+ Framework-free on purpose — `<MapledLive />` from "@mapled/next/live"
5
+ is this plus `router.refresh()`. */
6
+ const DEFAULT_ENDPOINT = "/api/mapled/release";
7
+ const MAX_BACKOFF_MS = 5 * 60_000;
8
+ /** The route's answer — null before the first publish — or undefined
9
+ when it isn't one. */
10
+ function pointer(body) {
11
+ if (!body || typeof body !== "object")
12
+ return undefined;
13
+ const { release, publishedAt } = body;
14
+ if (release === null)
15
+ return null;
16
+ if (!Number.isInteger(release) || release < 1 || typeof publishedAt !== "string")
17
+ return undefined;
18
+ return { release: release, publishedAt };
19
+ }
20
+ /** Starts watching; returns the function that stops it. The first answer
21
+ is where the watch starts from; `onPublish` runs once per release that
22
+ comes after it. A hidden tab doesn't ask — it checks the moment it is
23
+ shown again — and a route that fails is asked less and less often. */
24
+ export function watchRelease(options) {
25
+ return watchFrom(options);
26
+ }
27
+ /** The watch behind `watchRelease`, for a caller that can tell more about
28
+ where to start than the first answer can. `from` gets the first
29
+ release the route names, with the answer's headers, and returns the
30
+ release the page is on — the first answer's own number to start from
31
+ it, an older one (or null: not known) to hand the first answer to
32
+ `onPublish` as well. Not part of the package's API. */
33
+ export function watchFrom(options, from) {
34
+ const endpoint = options.endpoint ?? DEFAULT_ENDPOINT;
35
+ const everyMs = Math.max(2, options.interval ?? 10) * 1000;
36
+ const doc = typeof document === "undefined" ? null : document;
37
+ /** undefined until the first answer; null while nothing is published */
38
+ let seen;
39
+ let failures = 0;
40
+ let stopped = false;
41
+ let timer;
42
+ let inFlight = null;
43
+ const visible = () => !doc || doc.visibilityState !== "hidden";
44
+ const schedule = () => {
45
+ if (stopped || !visible())
46
+ return;
47
+ timer = setTimeout(check, Math.min(everyMs * 2 ** failures, MAX_BACKOFF_MS));
48
+ };
49
+ async function check() {
50
+ if (stopped || inFlight)
51
+ return;
52
+ const request = new AbortController();
53
+ inFlight = request;
54
+ let news = null;
55
+ try {
56
+ // no-cache: the browser revalidates by ETag, so a quiet check is a 304
57
+ const res = await fetch(endpoint, { cache: "no-cache", headers: { accept: "application/json" }, signal: request.signal });
58
+ const now = res.ok ? pointer(await res.json()) : undefined;
59
+ if (stopped)
60
+ return;
61
+ if (now === undefined) {
62
+ failures += 1;
63
+ }
64
+ else {
65
+ failures = 0;
66
+ if (seen === undefined)
67
+ seen = now ? (from ? from(now, res.headers) : now.release) : null;
68
+ if (now && now.release > (seen ?? 0)) {
69
+ // releases only grow (a rollback is a new one): an answer from a
70
+ // server that hasn't caught up yet never looks like news
71
+ news = { next: now, previous: seen };
72
+ seen = now.release;
73
+ }
74
+ }
75
+ }
76
+ catch {
77
+ if (stopped)
78
+ return;
79
+ failures += 1;
80
+ }
81
+ finally {
82
+ if (inFlight === request)
83
+ inFlight = null;
84
+ }
85
+ schedule();
86
+ // after the next check is set: a callback that throws doesn't end the watch
87
+ if (news)
88
+ options.onPublish(news.next, news.previous);
89
+ }
90
+ const onVisibility = () => {
91
+ clearTimeout(timer);
92
+ if (visible())
93
+ void check();
94
+ };
95
+ doc?.addEventListener("visibilitychange", onVisibility);
96
+ if (visible())
97
+ void check();
98
+ return () => {
99
+ stopped = true;
100
+ clearTimeout(timer);
101
+ inFlight?.abort();
102
+ doc?.removeEventListener("visibilitychange", onVisibility);
103
+ };
104
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mapled/next",
3
- "version": "0.7.1",
3
+ "version": "0.9.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",
@@ -27,6 +27,14 @@
27
27
  "./server": {
28
28
  "types": "./dist/server.d.ts",
29
29
  "default": "./dist/server.js"
30
+ },
31
+ "./live": {
32
+ "types": "./dist/live.d.ts",
33
+ "default": "./dist/live.js"
34
+ },
35
+ "./live/pages": {
36
+ "types": "./dist/live-pages.d.ts",
37
+ "default": "./dist/live-pages.js"
30
38
  }
31
39
  },
32
40
  "files": [