@mapled/next 0.11.1 → 0.11.3

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
@@ -284,6 +284,17 @@ const about = await mapled.getRecordBySlug("pages", "about", { release: live!.re
284
284
 
285
285
  `createClient({ key, release: 12 })` pins every read — a way to hold a site on a known-good release without publishing anything. A release never changes, so pinned reads are cached until the next publish (`revalidate: false` unless you pass your own); a release that doesn't exist fails with a 404 `RELEASE_NOT_FOUND` rather than reading as a missing record. In preview (draft mode) the pin is dropped: drafts are unversioned.
286
286
 
287
+ ### Read in a language
288
+
289
+ A project with several languages (Mapled: Settings → Languages) translates the fields an editor marked as translated. Every read takes `locale`:
290
+
291
+ ```ts
292
+ const about = await mapled.getRecordBySlug("pages", "about", { locale: "ru" });
293
+ const { records, locale } = await mapled.getRecords("articles", { locale: "pt-BR", sort: "-date" }); // locale: "pt-BR"
294
+ ```
295
+
296
+ A translated field answers in that language, or in the first language of the project's fallback chain that has a value — the default language at the end — so a value is never missing where the default language has one; shared fields come as they are. Filters, sorts and slug lookups see that language too, and the answer names the language it is in (`locale`). `createClient({ key, locale: "ru" })` reads everything in that language (a read's own `locale` wins). Without `locale` a read answers in the project's default language, as before. A language the project doesn't have — or that the release you read was published without — fails with a 400 `INVALID_QUERY`.
297
+
287
298
  ## Rich text
288
299
 
289
300
  `rich_text` values are Markdown (headings, lists, links, bold/italic, images as `![alt](url)`). Render them with a Markdown component such as `react-markdown` — never inject them as raw HTML.
@@ -347,7 +358,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
347
358
  }
348
359
  ```
349
360
 
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`:
361
+ `<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. The first check counts too: when the release it finds was acknowledged by your site's cache after the page was requested — a publish between the render 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 rendered, the tab can't tell). A page requested within ten seconds after the site answered a publish's webhook may refresh once for nothing. To offer the update instead of applying it under the reader, pass `onPublish`:
351
362
 
352
363
  ```tsx
353
364
  "use client";
@@ -389,7 +400,7 @@ export async function POST(req: Request) {
389
400
  }
390
401
  ```
391
402
 
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.
403
+ - The route answers `{ release, publishedAt, acknowledgedAt }` — the number of publishes, the time of the last one and when your site's cache answered its webhook (0.11.3 and later; the Pages Router's route answers the first two), nothing else — and may be cached by anyone. While Mapled can't be reached it keeps answering the last release it knew.
393
404
  - Refreshes ask your site, not the browser's cache. `router.refresh()` asks for the page's Server Components at the same URL every time, and a static page answers with `s-maxage` and `stale-while-revalidate` but no `max-age`: stale for the browser at once, which Chrome and Edge serve anyway while they fetch a fresh copy behind it. So from the first publish a tab hears about, every refresh of the page goes with `cache: "no-cache"`, and your site may still answer 304. That covers the component's own refresh, the one your `onPublish` makes (like the button above), and any your code makes. The layer changes only the cache mode, and only of requests your server reads as a refresh of the page the tab is on. Navigations, server actions, your own requests and any request that sets its own `cache` go as they did. A layer over `fetch`, put in place with the first publish, does it; a `fetch` wrapper of your own keeps working either way. (Before 0.11.1 an open tab showed the release before the one it was told about, from its second publish on.)
394
405
  - 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.
395
406
  - 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" />`.
@@ -546,7 +557,7 @@ curl -H "x-mapled-key: $MAPLED_KEY" \
546
557
  "https://api.mapled.io/v1/delivery/collections/articles/records?filter[featured]=true&filter[date][gte]=2026-01-01&sort=-date&limit=10&expand=author&fields=title,slug"
547
558
  ```
548
559
 
549
- - 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.
560
+ - 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`, `release=N` to read a published release by its number and `locale=ru` to read in one of the project's languages: a translated field answers in it or in the first language of the project's fallback chain that has a value, filters and slug lookups see that language, and the answer carries `locale`. A query the schema can't answer is a 400 `INVALID_QUERY` that says why.
550
561
  - 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.
551
562
  - 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)).
552
563
  - Errors share one envelope: `{ error: { code, message, requestId } }`. A key gets 1,200 requests a minute; past that, 429 `RATE_LIMITED` with `retry-after`.
@@ -559,8 +570,8 @@ Preview (drafts on the site) is built on Next.js draft mode and ships with this
559
570
 
560
571
  - `createClient({ key, apiUrl?, release? })` → `getRecords(collection, opts?)`, `getRecord(collection, id, opts?)`, `getRecordBySlug(collection, slug, opts?)`, `getSingle(key, opts?)`, `getRelease()`; `createClient<MapledSchema>(…)` with the generated `MapledSchema` types every read by its key (`MapledClient<S>`, `MapledAppClient<S>` name the typed clients)
561
572
  - list `opts`: `filter` (a value for equality, or `{ eq, ne, lt, lte, gt, gte, in, contains }` — text takes eq/ne/in/contains, numbers and dates comparisons, booleans eq/ne, image and relation ids eq/ne/in; a one-to-many relation matches when it includes the id), `sort` (`"field"` / `"-field"`, not on relations or images), `limit` (≤100), `offset`
562
- - every read: `expand` (relation keys, dotted for a second level), `fields` (data keys to keep), `release` (pin to a published release), `revalidate`, `tags`; a query the schema can't answer fails with a 400 `INVALID_QUERY` message, and errors carry the API's `code` on `MapledError`
563
- - `createAppClient({ serverKey, apiUrl? })` → `list(collection, opts?)`, `get(collection, id)`, `create(collection, data)`, `update(collection, id, data)`, `remove(collection, id)`
573
+ - every read: `expand` (relation keys, dotted for a second level), `fields` (data keys to keep), `release` (pin to a published release), `locale` (read in one of the project's languages, through its fallback chain), `revalidate`, `tags`; a query the schema can't answer fails with a 400 `INVALID_QUERY` message, and errors carry the API's `code` on `MapledError`
574
+ - `createAppClient({ serverKey, apiUrl? })` → `list(collection, opts?)`, `get(collection, id, opts?)`, `create(collection, data)`, `update(collection, id, data)`, `remove(collection, id)` — `list` and `get` take `locale` too
564
575
  - `assetUrl(id, { width?, height?, fit?, format?, quality?, apiUrl? })` — URL of an image or file value
565
576
  - `createRevalidateHandler({ secret, tags? })` — App Router `POST` handler
566
577
  - `createPreviewHandler({ apiUrl?, appUrl? })`, `createExitPreviewHandler()` — App Router `GET` handlers of the preview routes
package/dist/index.d.ts CHANGED
@@ -38,6 +38,11 @@ export type ReadOptions = {
38
38
  never changes, so pinned reads stay cached until the next publish
39
39
  (`revalidate: false` unless you say otherwise). */
40
40
  release?: number;
41
+ /** Read in this language: a translated field answers in it, or in the
42
+ first language of the project's fallback chain that has a value —
43
+ the default language at the end. One of the project's languages, in
44
+ any case (`pt_br` → `pt-BR`); another is a 400 INVALID_QUERY. */
45
+ locale?: string;
41
46
  };
42
47
  export type ListOptions = ReadOptions & {
43
48
  /** Filters on top-level fields, ANDed: { slug: "about", price: { gte: 10 } }. */
@@ -52,6 +57,8 @@ export type ListResult<T = Record<string, unknown>> = {
52
57
  records: MapledRecord<T>[];
53
58
  total: number;
54
59
  release: number;
60
+ /** The language the answer is in — when the read asked for one. */
61
+ locale?: string;
55
62
  /** Renamed fields whose previous keys this answer still serves, by
56
63
  collection: `{ products: { price: "amount" } }`. Absent when nothing
57
64
  was renamed. Outside production the SDK warns about each once. */
@@ -127,8 +134,8 @@ export interface MapledAppClient<S extends ContentSchema = ContentSchema> {
127
134
  records: AppRecord<T>[];
128
135
  total: number;
129
136
  }>;
130
- get<K extends keyof CollectionsOf<S> & string>(collection: K, id: string): Promise<AppRecord<CollectionsOf<S>[K]> | null>;
131
- get<T = Data>(collection: LooseKey<S, CollectionsOf<S>>, id: string): Promise<AppRecord<T> | null>;
137
+ get<K extends keyof CollectionsOf<S> & string>(collection: K, id: string, opts?: AppReadOptions): Promise<AppRecord<CollectionsOf<S>[K]> | null>;
138
+ get<T = Data>(collection: LooseKey<S, CollectionsOf<S>>, id: string, opts?: AppReadOptions): Promise<AppRecord<T> | null>;
132
139
  create<K extends keyof CollectionsOf<S> & string>(collection: K, data: CollectionsOf<S>[K]): Promise<AppRecord<CollectionsOf<S>[K]>>;
133
140
  create<T = Data>(collection: LooseKey<S, CollectionsOf<S>>, data: LooseData<S, T>): Promise<AppRecord<T>>;
134
141
  update<K extends keyof CollectionsOf<S> & string>(collection: K, id: string, data: CollectionsOf<S>[K]): Promise<AppRecord<CollectionsOf<S>[K]>>;
@@ -146,11 +153,17 @@ export type AppListOptions = {
146
153
  sort?: string;
147
154
  limit?: number;
148
155
  offset?: number;
156
+ /** Read in this language, through the project's fallback chain (as `ReadOptions.locale`). */
157
+ locale?: string;
149
158
  };
159
+ export type AppReadOptions = Pick<AppListOptions, "locale">;
150
160
  export declare function createClient<S extends ContentSchema = ContentSchema>(config: {
151
161
  key: string;
152
162
  apiUrl?: string;
163
+ /** Every read of this client is pinned to this release. */
153
164
  release?: number;
165
+ /** Every read of this client is in this language (`ReadOptions.locale`); a read's own `locale` wins. */
166
+ locale?: string;
154
167
  }): MapledClient<S>;
155
168
  /** Live data (operational collections): the site's backend reads and
156
169
  writes records that take effect immediately — no publish involved.
package/dist/index.js CHANGED
@@ -106,6 +106,10 @@ export function createClient(config) {
106
106
  // drafts are unversioned: a pin only shapes published reads
107
107
  if (release !== undefined && !draft)
108
108
  search.set("release", String(release));
109
+ // a language shapes drafts too; an empty one means the client's
110
+ const locale = opts.locale || config.locale;
111
+ if (locale)
112
+ search.set("locale", locale);
109
113
  const qs = search.toString();
110
114
  const init = draft
111
115
  ? {
@@ -206,6 +210,11 @@ export function createAppClient(config) {
206
210
  const base = (config.apiUrl ?? "https://api.mapled.io").replace(/\/+$/, "");
207
211
  if (!config.serverKey)
208
212
  throw new Error("Mapled: a server key is required.");
213
+ // the same refusal @mapled/client makes: the server key must never
214
+ // reach the browser, whatever bundling put this module there
215
+ if (typeof window !== "undefined" && typeof document !== "undefined") {
216
+ throw new Error("Mapled: the server key must never reach a browser — call createAppClient from your server.");
217
+ }
209
218
  async function call(method, path, search, data) {
210
219
  const qs = search?.toString();
211
220
  const res = await fetch(`${base}${path}${qs ? `?${qs}` : ""}`, {
@@ -246,11 +255,16 @@ export function createAppClient(config) {
246
255
  search.set("limit", String(opts.limit));
247
256
  if (opts.offset !== undefined)
248
257
  search.set("offset", String(opts.offset));
258
+ if (opts.locale)
259
+ search.set("locale", opts.locale);
249
260
  return call("GET", collectionPath(collection), search);
250
261
  },
251
- async get(collection, id) {
262
+ async get(collection, id, opts = {}) {
252
263
  try {
253
- const body = await call("GET", `${collectionPath(collection)}/${encodeURIComponent(id)}`);
264
+ const search = new URLSearchParams();
265
+ if (opts.locale)
266
+ search.set("locale", opts.locale);
267
+ const body = await call("GET", `${collectionPath(collection)}/${encodeURIComponent(id)}`, search);
254
268
  return body.record;
255
269
  }
256
270
  catch (err) {
@@ -14,7 +14,7 @@
14
14
  `next/router`, nor a Pages one `next/navigation`. */
15
15
  import { useRouter } from "next/router";
16
16
  import { useEffect, useRef } from "react";
17
- import { watchFrom } from "./watch.js";
17
+ import { startFrom, watchFrom } from "./watch.js";
18
18
  /** The Pages Router's way to re-run a page's data: a replace to where the
19
19
  tab already is. Two details make it an actual refresh — the client
20
20
  keeps `getStaticProps` data it fetched once, so its cache is skipped;
@@ -36,29 +36,6 @@ function refresh(router) {
36
36
  .catch(() => false);
37
37
  }
38
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
39
  export function MapledLive({ endpoint, interval, onPublish }) {
63
40
  // the Pages Router hands out a new router object on every render: the
64
41
  // watch reads the latest one and restarts only for a new route or pace
@@ -129,14 +106,12 @@ export function MapledLive({ endpoint, interval, onPublish }) {
129
106
  }
130
107
  },
131
108
  },
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]);
109
+ // the first answer names what is live, not what the page read: one
110
+ // that went live after the page was asked for — a publish between
111
+ // getServerSideProps and the first check, or while a tab opened in
112
+ // the background waited to be shown — counts as a publish, with no
113
+ // `previous`: which release the page read, the tab can't tell
114
+ // (watch.ts, «where the page is»)
115
+ startFrom(followed)), [endpoint, interval]);
141
116
  return null;
142
117
  }
package/dist/live.js CHANGED
@@ -8,27 +8,40 @@
8
8
  It asks the release route (`createReleaseHandler` from
9
9
  "@mapled/next/server", mounted at /api/mapled/release — under the
10
10
  site's basePath, when next.config sets one) every few seconds and, when a publish lands, refreshes the page's server
11
- components in place — scroll position and client state stay. */
11
+ components in place — scroll position and client state stay. Its
12
+ first answer counts too, when the release it names went live for the
13
+ pages after this page was asked for: a publish between the render and
14
+ the first check, or while a tab opened in the background waited to be
15
+ shown. */
12
16
  import { useRouter } from "next/navigation";
13
17
  import { useEffect, useRef } from "react";
14
- import { watchRelease } from "./watch.js";
18
+ import { startFrom, watchFrom } from "./watch.js";
15
19
  export function MapledLive({ endpoint, interval, onPublish }) {
16
20
  const router = useRouter();
17
21
  const custom = useRef(onPublish);
22
+ /** the newest release the page has started from or heard of — a watch
23
+ restarted for a new route or pace starts from it, not from the time */
24
+ const followed = useRef(undefined);
18
25
  useEffect(() => {
19
26
  custom.current = onPublish;
20
27
  }, [onPublish]);
21
- useEffect(() => watchRelease({
28
+ useEffect(() => watchFrom({
22
29
  endpoint,
23
30
  interval,
24
31
  onPublish: (next, previous) => {
32
+ followed.current = next.release;
25
33
  refreshPastBrowserCache();
26
34
  if (custom.current)
27
35
  custom.current(next, previous);
28
36
  else
29
37
  router.refresh();
30
38
  },
31
- }), [endpoint, interval, router]);
39
+ },
40
+ // the first answer names what is live, not what the page read: one
41
+ // the site's cache acknowledged after the page was asked for counts
42
+ // as a publish, with no `previous` — which release the page read,
43
+ // the tab can't tell (watch.ts, «where the page is»)
44
+ startFrom(followed)), [endpoint, interval, router]);
32
45
  return null;
33
46
  }
34
47
  /** The layers this module has put over `fetch`. */
package/dist/server.d.ts CHANGED
@@ -49,7 +49,10 @@ type ReleaseOptions = {
49
49
  publish webhook — however the cache entry was filled, the route moves
50
50
  when the webhook has refreshed this cache and not before, so a tab
51
51
  that refreshes on it renders the new release, never the old one
52
- again. A deployment the webhook doesn't reach names none. */
52
+ again. A deployment the webhook doesn't reach names none. The answer
53
+ says when Mapled recorded the acknowledgement (`acknowledgedAt`): a
54
+ page asked for after that renders the release, so a tab whose page is
55
+ older knows to refresh on its first check. */
53
56
  export declare function createReleaseHandler(options: ReleaseOptions & {
54
57
  /** ISR window of the pointer, the same as a read's (default 3600 — the webhook keeps it fresh). */
55
58
  revalidate?: number | false;
package/dist/server.js CHANGED
@@ -29,8 +29,15 @@ export async function verifySignature(secret, body, signature) {
29
29
  }
30
30
  const DRAFT_COOKIE = "mapled_draft";
31
31
  /** Same-app relative path only — preview links can't bounce elsewhere. */
32
+ /** The app's rule for a same-site path (`SAME_APP_PATH` of `@mapled/shared`,
33
+ ported literally — this package has no runtime dependencies): one
34
+ leading slash and not a second, no backslash, no control character or
35
+ space. A browser reads `/\host` and `/<tab>/host` as another host, so
36
+ anything else answers `/` — the exit route takes no token and would be
37
+ an open redirect at the customer's site otherwise. */
38
+ const SAME_SITE_PATH = /^\/(?![\/\\])[^\\\x00-\x20\x7f]*$/;
32
39
  function safeNext(raw) {
33
- return raw && /^\/(?!\/)/.test(raw) ? raw : "/";
40
+ return raw && SAME_SITE_PATH.test(raw) ? raw : "/";
34
41
  }
35
42
  /** GET handler for the preview route (mount at /api/mapled/preview).
36
43
  Trades the one-time link token from Mapled for a draft session,
@@ -151,10 +158,10 @@ function asPointer(body) {
151
158
  }
152
159
  /** The pointer's `webhook` as Mapled writes it; anything else throws. */
153
160
  function asAcknowledged(body) {
154
- const { cache = null } = (body ?? {});
161
+ const { cache = null, deliveredAt } = (body ?? {});
155
162
  if (cache !== null && typeof cache !== "string")
156
163
  throw new Error("Mapled's answer wasn't a release");
157
- return { ...asPointer(body), cache };
164
+ return { ...asPointer(body), cache, deliveredAt: typeof deliveredAt === "string" ? deliveredAt : null };
158
165
  }
159
166
  /** The pointer and, when asked with `?webhook=1`, the release the site
160
167
  has acknowledged through its publish webhook: null — the project has
@@ -170,16 +177,23 @@ async function readPointer(url, init) {
170
177
  return { live: asPointer(body), acknowledged: webhook === undefined ? undefined : webhook === null ? null : asAcknowledged(webhook), copy };
171
178
  }
172
179
  const NONE = { release: null, publishedAt: null };
173
- const UNACKNOWLEDGED = { ...NONE, cache: null };
180
+ const UNACKNOWLEDGED = { ...NONE, cache: null, deliveredAt: null };
181
+ /** the App Router route naming none */
182
+ const UNKNOWN = { ...NONE, acknowledgedAt: null };
174
183
  /** What the route names for a copy naming `live`: the release this
175
184
  cache acknowledged, never past `live` — none when the acknowledgement
176
- is another cache's, or names none. */
185
+ is another cache's, or names none — and when Mapled recorded the
186
+ acknowledgement: the cache had let go of the release before by then,
187
+ so a page asked for later renders it. Of an acknowledgement past
188
+ `live` (a copy behind Mapled) the time is that later release's — a tab
189
+ whose page is older than it refreshes, as it should, one asked for in
190
+ between refreshes once for nothing. */
177
191
  function follow(acknowledged, live, mark) {
178
192
  if (acknowledged.cache !== mark || acknowledged.release === null)
179
- return NONE;
193
+ return UNKNOWN;
180
194
  if (acknowledged.release >= (live.release ?? 0))
181
- return live;
182
- return { release: acknowledged.release, publishedAt: acknowledged.publishedAt };
195
+ return { ...live, acknowledgedAt: acknowledged.deliveredAt };
196
+ return { release: acknowledged.release, publishedAt: acknowledged.publishedAt, acknowledgedAt: acknowledged.deliveredAt };
183
197
  }
184
198
  /** The release route, whichever router mounts it: one answer reused for
185
199
  `ttl` seconds, in this instance and at the CDN in front of it —
@@ -227,7 +241,7 @@ function releaseAnswers(options, next) {
227
241
  const cached = await readPointer(url, { headers, next });
228
242
  const live = cached.live;
229
243
  if (live.release === null || !cached.acknowledged)
230
- return { answer: NONE };
244
+ return { answer: UNKNOWN };
231
245
  const mark = await cacheMark();
232
246
  let acknowledged = cached.acknowledged;
233
247
  let trouble;
@@ -306,7 +320,10 @@ function releaseAnswers(options, next) {
306
320
  publish webhook — however the cache entry was filled, the route moves
307
321
  when the webhook has refreshed this cache and not before, so a tab
308
322
  that refreshes on it renders the new release, never the old one
309
- again. A deployment the webhook doesn't reach names none. */
323
+ again. A deployment the webhook doesn't reach names none. The answer
324
+ says when Mapled recorded the acknowledgement (`acknowledgedAt`): a
325
+ page asked for after that renders the release, so a tab whose page is
326
+ older knows to refresh on its first check. */
310
327
  export function createReleaseHandler(options) {
311
328
  const answer = releaseAnswers(options, { revalidate: options.revalidate ?? 3600, tags: ["mapled"] });
312
329
  return async function GET(req) {
package/dist/watch.d.ts CHANGED
@@ -7,6 +7,13 @@ export type Release = {
7
7
  release: number;
8
8
  publishedAt: string;
9
9
  };
10
+ /** The route's answer as the watch reads it: the release and, when the
11
+ route says (the App Router's does), when this site's cache
12
+ acknowledged it through the publish webhook — from then on the pages
13
+ render it. Not part of the package's API. */
14
+ export type Answer = Release & {
15
+ acknowledgedAt?: string;
16
+ };
10
17
  export type WatchOptions = {
11
18
  /** Where the release route is mounted, asked as written (default
12
19
  "/api/mapled/release" under the site's `basePath`). */
@@ -24,6 +31,35 @@ export type WatchOptions = {
24
31
  Next a browser has no `process`, and Node no such variable: "". Not
25
32
  part of the package's API. */
26
33
  export declare function nextBasePath(): string;
34
+ /** Whether a moment `at` (as the route's answer states it) came after
35
+ this document was asked for. Compared on the servers' clock, which the
36
+ route's answer tells (`Date`, plus `Age` from a CDN), so a browser
37
+ whose clock is off changes nothing; an answer without `Date` leaves the
38
+ browser's own. Every error of the estimate — a second's rounding in
39
+ `Date`, the time the answer took to arrive — makes a refresh likelier,
40
+ never rarer; a page asked for within the margin after `at` refreshes
41
+ once for nothing. Not part of the package's API. */
42
+ export declare function wentLiveAfterPage(at: string, headers: Headers): boolean;
43
+ /** Whether the first answer's release went live for this site's pages
44
+ after the page was asked for — then the page may have read the release
45
+ before it. The App Router's route says when this site's cache
46
+ acknowledged the release through the publish webhook
47
+ (`acknowledgedAt`): the cache let go of the old release before the
48
+ site answered, and Mapled recorded the answer after, so a page asked
49
+ for later than that renders the release, whatever the cache held
50
+ before — and the route names no release its cache hasn't acknowledged
51
+ (§18.8). A route that doesn't say — the Pages Router's, whose pages
52
+ read the live release as Mapled has it, or one of the site's own —
53
+ leaves the publish's time. Not part of the package's API. */
54
+ export declare function newerThanPage(first: Answer, headers: Headers): boolean;
55
+ /** The `from` of a `<MapledLive />`: the first answer counts as a publish
56
+ when it went live after the page was asked for; a watch restarted for
57
+ a new route or pace starts from the newest release the page has
58
+ started from or heard of (`followed`, which the component's
59
+ `onPublish` raises), not from the time. Not part of the package's API. */
60
+ export declare function startFrom(followed: {
61
+ current: number | undefined;
62
+ }): (first: Answer, headers: Headers) => number | null;
27
63
  /** Starts watching; returns the function that stops it. The first answer
28
64
  is where the watch starts from; `onPublish` runs once per release that
29
65
  comes after it. A hidden tab doesn't ask — it checks the moment it is
@@ -35,4 +71,4 @@ export declare function watchRelease(options: WatchOptions): () => void;
35
71
  release the page is on — the first answer's own number to start from
36
72
  it, an older one (or null: not known) to hand the first answer to
37
73
  `onPublish` as well. Not part of the package's API. */
38
- export declare function watchFrom(options: WatchOptions, from?: (first: Release, headers: Headers) => number | null): () => void;
74
+ export declare function watchFrom(options: WatchOptions, from?: (first: Answer, headers: Headers) => number | null): () => void;
package/dist/watch.js CHANGED
@@ -25,12 +25,67 @@ export function nextBasePath() {
25
25
  function pointer(body) {
26
26
  if (!body || typeof body !== "object")
27
27
  return undefined;
28
- const { release, publishedAt } = body;
28
+ const { release, publishedAt, acknowledgedAt } = body;
29
29
  if (release === null)
30
30
  return null;
31
31
  if (!Number.isInteger(release) || release < 1 || typeof publishedAt !== "string")
32
32
  return undefined;
33
- return { release: release, publishedAt };
33
+ return typeof acknowledgedAt === "string" ? { release: release, publishedAt, acknowledgedAt } : { release: release, publishedAt };
34
+ }
35
+ /* ---- where the page is ----
36
+
37
+ The first answer names what is live, not what the page read. A publish
38
+ between the page's render and the first check — or while a tab opened
39
+ in the background waited to be shown before asking, for hours maybe —
40
+ leaves the tab on the old release until the next publish, unless that
41
+ first answer counts as one. Which release the page read, the tab can't
42
+ tell; when the release went live for the pages, it can. */
43
+ /** Mapled stamps a release when its publish starts, not when it commits
44
+ (a big one takes seconds), and its clock and the site's differ a
45
+ little: a release stamped up to this long before the page was asked
46
+ for may still be newer than what the page read. */
47
+ const MARGIN_MS = 10_000;
48
+ /** Whether a moment `at` (as the route's answer states it) came after
49
+ this document was asked for. Compared on the servers' clock, which the
50
+ route's answer tells (`Date`, plus `Age` from a CDN), so a browser
51
+ whose clock is off changes nothing; an answer without `Date` leaves the
52
+ browser's own. Every error of the estimate — a second's rounding in
53
+ `Date`, the time the answer took to arrive — makes a refresh likelier,
54
+ never rarer; a page asked for within the margin after `at` refreshes
55
+ once for nothing. Not part of the package's API. */
56
+ export function wentLiveAfterPage(at, headers) {
57
+ const askedAt = globalThis.performance?.timeOrigin;
58
+ if (askedAt === undefined)
59
+ return false;
60
+ const date = Date.parse(headers.get("date") ?? "");
61
+ const offset = Number.isNaN(date) ? 0 : date + (Number(headers.get("age")) || 0) * 1000 - Date.now();
62
+ return Date.parse(at) > askedAt + offset - MARGIN_MS;
63
+ }
64
+ /** Whether the first answer's release went live for this site's pages
65
+ after the page was asked for — then the page may have read the release
66
+ before it. The App Router's route says when this site's cache
67
+ acknowledged the release through the publish webhook
68
+ (`acknowledgedAt`): the cache let go of the old release before the
69
+ site answered, and Mapled recorded the answer after, so a page asked
70
+ for later than that renders the release, whatever the cache held
71
+ before — and the route names no release its cache hasn't acknowledged
72
+ (§18.8). A route that doesn't say — the Pages Router's, whose pages
73
+ read the live release as Mapled has it, or one of the site's own —
74
+ leaves the publish's time. Not part of the package's API. */
75
+ export function newerThanPage(first, headers) {
76
+ return wentLiveAfterPage(first.acknowledgedAt ?? first.publishedAt, headers);
77
+ }
78
+ /** The `from` of a `<MapledLive />`: the first answer counts as a publish
79
+ when it went live after the page was asked for; a watch restarted for
80
+ a new route or pace starts from the newest release the page has
81
+ started from or heard of (`followed`, which the component's
82
+ `onPublish` raises), not from the time. Not part of the package's API. */
83
+ export function startFrom(followed) {
84
+ return (first, headers) => {
85
+ const from = followed.current ?? (newerThanPage(first, headers) ? null : first.release);
86
+ followed.current = Math.max(first.release, followed.current ?? 0);
87
+ return from;
88
+ };
34
89
  }
35
90
  /** Starts watching; returns the function that stops it. The first answer
36
91
  is where the watch starts from; `onPublish` runs once per release that
@@ -83,7 +138,7 @@ export function watchFrom(options, from) {
83
138
  if (now && now.release > (seen ?? 0)) {
84
139
  // releases only grow (a rollback is a new one): an answer from a
85
140
  // server that hasn't caught up yet never looks like news
86
- news = { next: now, previous: seen };
141
+ news = { next: { release: now.release, publishedAt: now.publishedAt }, previous: seen };
87
142
  seen = now.release;
88
143
  }
89
144
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mapled/next",
3
- "version": "0.11.1",
3
+ "version": "0.11.3",
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",