create-shapio 0.4.0 → 0.5.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/package.json +1 -1
- package/templates/astro/package.json +2 -2
- package/templates/astro/scripts/smokeHttp.ts +29 -1
- package/templates/next/.env.example +4 -0
- package/templates/next/README.md +28 -11
- package/templates/next/next.config.ts +12 -11
- package/templates/next/package.json +3 -2
- package/templates/next/scripts/smokeHttp.ts +29 -1
- package/templates/next/src/app/api/revalidate/route.ts +3 -1
- package/templates/next/src/lib/config.ts +22 -0
- package/templates/next/src/lib/revalidation.ts +27 -12
- package/templates/next/src/lib/revalidationTags.test.ts +43 -0
- package/templates/next/src/lib/revalidationTags.ts +30 -0
- package/templates/next/src/lib/shapio.ts +19 -10
- package/templates/next/src/lib/shapioClient.ts +17 -0
- package/templates/sveltekit/package.json +2 -2
- package/templates/sveltekit/scripts/smokeHttp.ts +29 -1
package/package.json
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { createHmac } from 'node:crypto';
|
|
2
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
2
4
|
import { parseArgs } from 'node:util';
|
|
3
5
|
|
|
4
6
|
/**
|
|
@@ -13,6 +15,10 @@ import { parseArgs } from 'node:util';
|
|
|
13
15
|
* `--url` is the running site's origin (default: SMOKE_URL, then http://localhost:4321). `--revalidate` (the
|
|
14
16
|
* Next.js starter) also checks its on-demand revalidation route: an unsigned POST to /api/revalidate is refused,
|
|
15
17
|
* and an event signed with SHAPIO_WEBHOOK_SECRET is answered 200 with the paths it revalidated.
|
|
18
|
+
*
|
|
19
|
+
* `--save <dir>` also writes each checked page's rendered markup to `<dir>` (scripts and the framework's own
|
|
20
|
+
* asset links removed: they name build-specific chunks), so two builds of a site can be compared with
|
|
21
|
+
* `diff -r`, e.g. the Next.js starter reading over HTTP and in process.
|
|
16
22
|
*/
|
|
17
23
|
const DRAFT_TITLE = 'Coming soon: our winter projects';
|
|
18
24
|
const ARTICLE = 'modelling-without-a-deploy';
|
|
@@ -85,6 +91,18 @@ const fetchPage = async (origin: string, path: string) => {
|
|
|
85
91
|
return { status: response.status, html: await response.text() };
|
|
86
92
|
};
|
|
87
93
|
|
|
94
|
+
/** The page as rendered: without scripts and framework asset links, which differ from build to build. */
|
|
95
|
+
const renderedMarkup = (html: string) =>
|
|
96
|
+
html
|
|
97
|
+
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/g, '')
|
|
98
|
+
.replace(/<link\b[^>]*\/_next\/[^>]*>/g, '')
|
|
99
|
+
.replace(/<meta\b[^>]*name="next-size-adjust"[^>]*>/g, '');
|
|
100
|
+
|
|
101
|
+
const savePage = async (directory: string, path: string, html: string) => {
|
|
102
|
+
const name = path.replace(/^\/|\/$/g, '').replace(/\//g, '__') || 'index';
|
|
103
|
+
await writeFile(join(directory, `${name}.html`), `${renderedMarkup(html)}\n`);
|
|
104
|
+
};
|
|
105
|
+
|
|
88
106
|
const REVALIDATE_PATH = '/api/revalidate/';
|
|
89
107
|
|
|
90
108
|
const postEvent = (origin: string, body: string, headers: Record<string, string>) =>
|
|
@@ -137,12 +155,22 @@ const checkRevalidation = async (origin: string) => {
|
|
|
137
155
|
|
|
138
156
|
const main = async () => {
|
|
139
157
|
const { values } = parseArgs({
|
|
140
|
-
options: {
|
|
158
|
+
options: {
|
|
159
|
+
url: { type: 'string' },
|
|
160
|
+
revalidate: { type: 'boolean', default: false },
|
|
161
|
+
save: { type: 'string' },
|
|
162
|
+
},
|
|
141
163
|
});
|
|
142
164
|
const origin = values.url ?? process.env.SMOKE_URL ?? 'http://localhost:4321';
|
|
165
|
+
if (values.save) {
|
|
166
|
+
await mkdir(values.save, { recursive: true });
|
|
167
|
+
}
|
|
143
168
|
for (const page of PAGES) {
|
|
144
169
|
const { status, html } = await fetchPage(origin, page.path);
|
|
145
170
|
check(status === 200, `${page.path}: HTTP ${status}`);
|
|
171
|
+
if (values.save) {
|
|
172
|
+
await savePage(values.save, page.path, html);
|
|
173
|
+
}
|
|
146
174
|
for (const [needle, label] of page.expect) {
|
|
147
175
|
check(html.includes(needle), `${page.path}: ${label}`);
|
|
148
176
|
}
|
|
@@ -15,3 +15,7 @@ NEXT_TELEMETRY_DISABLED=1
|
|
|
15
15
|
# NEXT_PUBLIC_SHAPIO_URL=https://cms.example.com
|
|
16
16
|
# Optional: this site's public origin; pages then carry a canonical URL and og:url.
|
|
17
17
|
# SITE_URL=https://www.example.com
|
|
18
|
+
# Optional: read Shapio's delivery API in this server process instead of over HTTP (@shapio/local), from
|
|
19
|
+
# Shapio's own database. A read-only database role is enough. Previews still use SHAPIO_URL.
|
|
20
|
+
# SHAPIO_MODE=in-process
|
|
21
|
+
# DATABASE_URL=postgres://shapio_site:password@localhost:5432/shapio
|
package/templates/next/README.md
CHANGED
|
@@ -22,16 +22,17 @@ npm test # unit tests (path mapping, signature check)
|
|
|
22
22
|
`next.config.ts` pins the latest one when the build starts); `SHAPIO_SITE` picks the site on a multi-site
|
|
23
23
|
instance. Next.js telemetry is off: `NEXT_TELEMETRY_DISABLED=1` in the npm scripts and `.env.example`.
|
|
24
24
|
|
|
25
|
-
| File | Role
|
|
26
|
-
| ---------------------------- |
|
|
27
|
-
| `next.config.ts` | pins the snapshot; `/` redirects to `/en/`; `frame-ancestors`
|
|
28
|
-
| `src/lib/shapio.ts` | reads the delivery API
|
|
29
|
-
| `src/
|
|
30
|
-
| `src/
|
|
31
|
-
| `src/
|
|
32
|
-
| `src/app/
|
|
33
|
-
| `src/
|
|
34
|
-
| `
|
|
25
|
+
| File | Role |
|
|
26
|
+
| ---------------------------- | ------------------------------------------------------------------ |
|
|
27
|
+
| `next.config.ts` | pins the snapshot; `/` redirects to `/en/`; `frame-ancestors` |
|
|
28
|
+
| `src/lib/shapio.ts` | reads the delivery API at the pinned snapshot |
|
|
29
|
+
| `src/lib/shapioClient.ts` | over HTTP (`@shapio/client`) or in process (`@shapio/local`) |
|
|
30
|
+
| `src/app/[locale]/…` | the layout (site settings), pages, articles and the colophon |
|
|
31
|
+
| `src/components/…` | sections, articles and responsive images |
|
|
32
|
+
| `src/app/preview/` | draft preview with visual editing (`@shapio/visual`) |
|
|
33
|
+
| `src/app/api/revalidate/` | the webhook target: on-demand revalidation of changed pages |
|
|
34
|
+
| `src/lib/revalidation*.ts` | the snapshot diff, and which pages and cache tags a change touches |
|
|
35
|
+
| `shapio/`, `scripts/seed.ts` | the models and the seed |
|
|
35
36
|
|
|
36
37
|
Preview: the seed creates a deployment connection named **Preview** that opens drafts at
|
|
37
38
|
`http://localhost:3000/preview/` (set `SITE_URL` when seeding for another address). Add that origin to Shapio's
|
|
@@ -39,6 +40,18 @@ Preview: the seed creates a deployment connection named **Preview** that opens d
|
|
|
39
40
|
re-render the draft ([visual editing](https://github.com/mybrokengnome/shapio/blob/main/documentation/visual-editing.md)). `NEXT_PUBLIC_SHAPIO_URL` sets the Shapio URL the browser
|
|
40
41
|
calls, when it differs from `SHAPIO_URL`.
|
|
41
42
|
|
|
43
|
+
## Reading in process
|
|
44
|
+
|
|
45
|
+
By default the site reads Shapio's delivery API over HTTP. With `SHAPIO_MODE=in-process` and `DATABASE_URL` set
|
|
46
|
+
to Shapio's own database (PostgreSQL or MySQL), it reads the same API as function calls in the Next.js server
|
|
47
|
+
process (`@shapio/local`), with no HTTP hop: the same delivery token, the same answers, the same pages.
|
|
48
|
+
`next.config.ts` lists `@shapio/local` in `serverExternalPackages`, so each server process opens one database
|
|
49
|
+
pool. Previews and the browser still use `SHAPIO_URL`, and the Shapio server keeps running (it applies schema
|
|
50
|
+
changes and sends the revalidation webhook). Install the same `@shapio/local` version as the server's release;
|
|
51
|
+
the scaffolded `package.json` pins it. Reads in process do not go through `fetch`, so the cache tags below do
|
|
52
|
+
not apply to them; `revalidatePath` still refreshes the pages. Guide:
|
|
53
|
+
[in-process delivery](https://github.com/mybrokengnome/shapio/blob/main/documentation/in-process.md).
|
|
54
|
+
|
|
42
55
|
## Incremental rebuilds (on-demand revalidation)
|
|
43
56
|
|
|
44
57
|
With `npm run start`, a publish refreshes only the pages it changed, without a rebuild:
|
|
@@ -55,7 +68,11 @@ With `npm run start`, a publish refreshes only the pages it changed, without a r
|
|
|
55
68
|
changed between the snapshot the site shows and the current one (`/api/snapshots/changes`), and calls
|
|
56
69
|
`revalidatePath` for those pages: an article's page and its locale's list, a page by its slug (`home` is
|
|
57
70
|
`/en/`), the list and every article page for an author, every page for the `siteSettings` singleton or a
|
|
58
|
-
schema change. A changed slug refreshes the old and the new address.
|
|
71
|
+
schema change. A changed slug refreshes the old and the new address. Reads are tagged by `@shapio/client` with
|
|
72
|
+
the site, the model and the entry, so the route also calls `revalidateTag` for each changed model and entry;
|
|
73
|
+
`site.updated` and a schema change expire the site tag, which every read carries. Content reads are pinned to
|
|
74
|
+
a snapshot, so they sit in Next's data cache (`force-cache`) regardless; the client sets no cache mode, so
|
|
75
|
+
`site.get()` stays fresh on every build (the data cache outlives `next build`).
|
|
59
76
|
3. The site moves to the new snapshot: revalidated pages re-render at it, and the snapshot is kept in
|
|
60
77
|
`.next/shapio-revalidate.json` so a restart carries on from it. `next build` empties `.next/`, so a new build
|
|
61
78
|
starts again from its own snapshot.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { createClient } from '@shapio/client';
|
|
2
1
|
import type { NextConfig } from 'next';
|
|
3
2
|
import { PHASE_PRODUCTION_BUILD } from 'next/constants.js';
|
|
3
|
+
import { createShapioClient } from './src/lib/shapioClient';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Pins one publication snapshot for the whole `next build`: Next renders pages in several worker processes, so
|
|
@@ -13,17 +13,15 @@ const pinSnapshot = async (): Promise<string> => {
|
|
|
13
13
|
if (process.env.SHAPIO_SNAPSHOT) {
|
|
14
14
|
return process.env.SHAPIO_SNAPSHOT;
|
|
15
15
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
// Over HTTP or in process, as the pages read (SHAPIO_MODE).
|
|
17
|
+
const client = createShapioClient();
|
|
18
|
+
try {
|
|
19
|
+
const { snapshot } = await client.snapshots.current();
|
|
20
|
+
return String(snapshot);
|
|
21
|
+
} finally {
|
|
22
|
+
// An in-process client holds database connections; this process has no further use for them.
|
|
23
|
+
await client.close?.();
|
|
19
24
|
}
|
|
20
|
-
const client = createClient({
|
|
21
|
-
baseUrl: process.env.SHAPIO_URL || 'http://localhost:4300',
|
|
22
|
-
token,
|
|
23
|
-
site: process.env.SHAPIO_SITE || undefined,
|
|
24
|
-
});
|
|
25
|
-
const { snapshot } = await client.snapshots.current();
|
|
26
|
-
return String(snapshot);
|
|
27
25
|
};
|
|
28
26
|
|
|
29
27
|
const originOf = (url: string) => {
|
|
@@ -47,6 +45,9 @@ const frameAncestors = () => {
|
|
|
47
45
|
|
|
48
46
|
const nextConfig = async (phase: string): Promise<NextConfig> => ({
|
|
49
47
|
trailingSlash: true,
|
|
48
|
+
// SHAPIO_MODE=in-process: loaded from node_modules once per server process (one database pool), not
|
|
49
|
+
// bundled into every route.
|
|
50
|
+
serverExternalPackages: ['@shapio/local'],
|
|
50
51
|
headers: async () => [
|
|
51
52
|
{ source: '/:path*', headers: [{ key: 'Content-Security-Policy', value: frameAncestors() }] },
|
|
52
53
|
],
|
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
"test": "vitest run"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@shapio/client": "^0.
|
|
21
|
-
"@shapio/
|
|
20
|
+
"@shapio/client": "^0.5.0",
|
|
21
|
+
"@shapio/local": "0.5.0",
|
|
22
|
+
"@shapio/visual": "^0.5.0",
|
|
22
23
|
"next": "16.3.8",
|
|
23
24
|
"react": "19.3.0",
|
|
24
25
|
"react-dom": "19.3.0"
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { createHmac } from 'node:crypto';
|
|
2
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
2
4
|
import { parseArgs } from 'node:util';
|
|
3
5
|
|
|
4
6
|
/**
|
|
@@ -13,6 +15,10 @@ import { parseArgs } from 'node:util';
|
|
|
13
15
|
* `--url` is the running site's origin (default: SMOKE_URL, then http://localhost:4321). `--revalidate` (the
|
|
14
16
|
* Next.js starter) also checks its on-demand revalidation route: an unsigned POST to /api/revalidate is refused,
|
|
15
17
|
* and an event signed with SHAPIO_WEBHOOK_SECRET is answered 200 with the paths it revalidated.
|
|
18
|
+
*
|
|
19
|
+
* `--save <dir>` also writes each checked page's rendered markup to `<dir>` (scripts and the framework's own
|
|
20
|
+
* asset links removed: they name build-specific chunks), so two builds of a site can be compared with
|
|
21
|
+
* `diff -r`, e.g. the Next.js starter reading over HTTP and in process.
|
|
16
22
|
*/
|
|
17
23
|
const DRAFT_TITLE = 'Coming soon: our winter projects';
|
|
18
24
|
const ARTICLE = 'modelling-without-a-deploy';
|
|
@@ -85,6 +91,18 @@ const fetchPage = async (origin: string, path: string) => {
|
|
|
85
91
|
return { status: response.status, html: await response.text() };
|
|
86
92
|
};
|
|
87
93
|
|
|
94
|
+
/** The page as rendered: without scripts and framework asset links, which differ from build to build. */
|
|
95
|
+
const renderedMarkup = (html: string) =>
|
|
96
|
+
html
|
|
97
|
+
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/g, '')
|
|
98
|
+
.replace(/<link\b[^>]*\/_next\/[^>]*>/g, '')
|
|
99
|
+
.replace(/<meta\b[^>]*name="next-size-adjust"[^>]*>/g, '');
|
|
100
|
+
|
|
101
|
+
const savePage = async (directory: string, path: string, html: string) => {
|
|
102
|
+
const name = path.replace(/^\/|\/$/g, '').replace(/\//g, '__') || 'index';
|
|
103
|
+
await writeFile(join(directory, `${name}.html`), `${renderedMarkup(html)}\n`);
|
|
104
|
+
};
|
|
105
|
+
|
|
88
106
|
const REVALIDATE_PATH = '/api/revalidate/';
|
|
89
107
|
|
|
90
108
|
const postEvent = (origin: string, body: string, headers: Record<string, string>) =>
|
|
@@ -137,12 +155,22 @@ const checkRevalidation = async (origin: string) => {
|
|
|
137
155
|
|
|
138
156
|
const main = async () => {
|
|
139
157
|
const { values } = parseArgs({
|
|
140
|
-
options: {
|
|
158
|
+
options: {
|
|
159
|
+
url: { type: 'string' },
|
|
160
|
+
revalidate: { type: 'boolean', default: false },
|
|
161
|
+
save: { type: 'string' },
|
|
162
|
+
},
|
|
141
163
|
});
|
|
142
164
|
const origin = values.url ?? process.env.SMOKE_URL ?? 'http://localhost:4321';
|
|
165
|
+
if (values.save) {
|
|
166
|
+
await mkdir(values.save, { recursive: true });
|
|
167
|
+
}
|
|
143
168
|
for (const page of PAGES) {
|
|
144
169
|
const { status, html } = await fetchPage(origin, page.path);
|
|
145
170
|
check(status === 200, `${page.path}: HTTP ${status}`);
|
|
171
|
+
if (values.save) {
|
|
172
|
+
await savePage(values.save, page.path, html);
|
|
173
|
+
}
|
|
146
174
|
for (const [needle, label] of page.expect) {
|
|
147
175
|
check(html.includes(needle), `${page.path}: ${label}`);
|
|
148
176
|
}
|
|
@@ -8,7 +8,9 @@ import { readWebhookEvent } from '../../../lib/webhookEvent';
|
|
|
8
8
|
* POST /api/revalidate/ (with the slash: `trailingSlash` redirects the other form, and Shapio's webhooks do not
|
|
9
9
|
* follow redirects): the target of the Shapio webhook the seed creates (publish, unpublish, delete,
|
|
10
10
|
* change set shipped, schema change, site settings). A signed delivery refreshes the pages whose content changed since the
|
|
11
|
-
* snapshot the site shows
|
|
11
|
+
* snapshot the site shows and expires the cached reads tagged with their models and entries; `site.updated` and
|
|
12
|
+
* schema changes expire the site tag, which every read carries (src/lib/revalidation.ts). A failure answers 500,
|
|
13
|
+
* so Shapio retries the delivery.
|
|
12
14
|
*/
|
|
13
15
|
export const POST = async (request: Request) => {
|
|
14
16
|
const read = await readWebhookEvent(request, webhookSecret());
|
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
* URL (and `og:url`) unless the entry sets its own.
|
|
13
13
|
* - NEXT_PUBLIC_SHAPIO_URL: the Shapio URL the browser calls for previews (defaults to SHAPIO_URL; inlined into
|
|
14
14
|
* the client bundle at build time).
|
|
15
|
+
* - SHAPIO_MODE: `http` (default) reads Shapio's delivery API over HTTP; `in-process` reads the same API as
|
|
16
|
+
* function calls in this server process (@shapio/local), from Shapio's database at DATABASE_URL. Previews
|
|
17
|
+
* and the browser still use SHAPIO_URL. See documentation/in-process.md.
|
|
18
|
+
* - DATABASE_URL: Shapio's own database (PostgreSQL or MySQL), for SHAPIO_MODE=in-process only.
|
|
15
19
|
*/
|
|
16
20
|
const read = (name: string): string | undefined => {
|
|
17
21
|
const value = process.env[name];
|
|
@@ -32,6 +36,24 @@ export const deliveryToken = () => {
|
|
|
32
36
|
|
|
33
37
|
export const siteKey = () => read('SHAPIO_SITE');
|
|
34
38
|
|
|
39
|
+
export type ShapioMode = 'http' | 'in-process';
|
|
40
|
+
|
|
41
|
+
export const shapioMode = (): ShapioMode => {
|
|
42
|
+
const mode = read('SHAPIO_MODE') ?? 'http';
|
|
43
|
+
if (mode !== 'http' && mode !== 'in-process') {
|
|
44
|
+
throw new Error(`SHAPIO_MODE must be "http" or "in-process", got "${mode}"`);
|
|
45
|
+
}
|
|
46
|
+
return mode;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const databaseUrl = () => {
|
|
50
|
+
const url = read('DATABASE_URL');
|
|
51
|
+
if (!url) {
|
|
52
|
+
throw new Error("SHAPIO_MODE=in-process reads Shapio's database: set DATABASE_URL to it");
|
|
53
|
+
}
|
|
54
|
+
return url;
|
|
55
|
+
};
|
|
56
|
+
|
|
35
57
|
export const siteUrl = () => read('SITE_URL');
|
|
36
58
|
|
|
37
59
|
export const configuredSnapshot = (): number | undefined => {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { ShapioApiError, type SnapshotChange, type SnapshotChangeKind } from '@shapio/client';
|
|
2
|
-
import { revalidatePath } from 'next/cache';
|
|
3
|
-
import { configuredSnapshot, isSnapshotPinned } from './config';
|
|
2
|
+
import { revalidatePath, revalidateTag } from 'next/cache';
|
|
3
|
+
import { configuredSnapshot, isSnapshotPinned, siteKey } from './config';
|
|
4
4
|
import { advanceTo, liveSnapshot } from './liveSnapshot';
|
|
5
5
|
import { log } from './log';
|
|
6
|
+
import { changeTags, siteTags } from './revalidationTags';
|
|
6
7
|
import {
|
|
7
8
|
EVERY_PAGE,
|
|
8
9
|
revalidationTargets,
|
|
@@ -13,12 +14,21 @@ import { shapio } from './shapio';
|
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* On-demand revalidation (`next start`): asks Shapio what changed between the snapshot the site shows and
|
|
16
|
-
* the current one (`/api/snapshots/changes`), revalidates the pages those changes touch and
|
|
17
|
-
* the new snapshot. The diff, not the event, is the
|
|
18
|
-
* entries, retried or missed deliveries, and both locales
|
|
17
|
+
* the current one (`/api/snapshots/changes`), revalidates the pages those changes touch and the cached reads
|
|
18
|
+
* tagged with their models and entries, and moves the site to the new snapshot. The diff, not the event, is the
|
|
19
|
+
* source of truth: one call covers a change set's many entries, retried or missed deliveries, and both locales
|
|
20
|
+
* of a publish.
|
|
19
21
|
*/
|
|
20
22
|
export type RevalidationResult =
|
|
21
|
-
{ from: number; to: number; changed: number; revalidated: RevalidationTarget[]
|
|
23
|
+
| { from: number; to: number; changed: number; revalidated: RevalidationTarget[]; tags: string[] }
|
|
24
|
+
| { skipped: string };
|
|
25
|
+
|
|
26
|
+
/** Expires the cached reads at once (not stale-while-revalidate): the next render reads the new content. */
|
|
27
|
+
const expireTags = (tags: readonly string[]) => {
|
|
28
|
+
for (const tag of tags) {
|
|
29
|
+
revalidateTag(tag, { expire: 0 });
|
|
30
|
+
}
|
|
31
|
+
};
|
|
22
32
|
|
|
23
33
|
/** Models whose pages are addressed by slug. */
|
|
24
34
|
const SLUGGED_MODELS: ReadonlySet<string> = new Set(['page', 'article']);
|
|
@@ -65,18 +75,20 @@ const revalidateNow = async (): Promise<RevalidationResult> => {
|
|
|
65
75
|
const from = liveSnapshot();
|
|
66
76
|
const { snapshot: to } = await shapio().snapshots.current();
|
|
67
77
|
if (to <= from) {
|
|
68
|
-
return { from, to: from, changed: 0, revalidated: [] };
|
|
78
|
+
return { from, to: from, changed: 0, revalidated: [], tags: [] };
|
|
69
79
|
}
|
|
70
80
|
const diff = await shapio().snapshots.allChanges({ from, to });
|
|
71
81
|
const { schemaVersions } = diff;
|
|
72
82
|
const schemaChanged = schemaVersions.from === null || schemaVersions.from !== schemaVersions.to;
|
|
73
83
|
const targets = revalidationTargets(await changedEntries(diff.items, from, to), schemaChanged);
|
|
84
|
+
const tags = changeTags(diff.items, schemaChanged, siteKey());
|
|
74
85
|
await advanceTo(to, () => {
|
|
86
|
+
expireTags(tags);
|
|
75
87
|
for (const target of targets) {
|
|
76
88
|
revalidatePath(target.path, target.type);
|
|
77
89
|
}
|
|
78
90
|
});
|
|
79
|
-
return { from, to, changed: diff.items.length, revalidated: targets };
|
|
91
|
+
return { from, to, changed: diff.items.length, revalidated: targets, tags };
|
|
80
92
|
};
|
|
81
93
|
|
|
82
94
|
/** One run at a time: deliveries that arrive together diff from the snapshot the previous run reached. */
|
|
@@ -89,22 +101,25 @@ export const revalidateChanges = (): Promise<RevalidationResult> => {
|
|
|
89
101
|
log(
|
|
90
102
|
'skipped' in result
|
|
91
103
|
? `Revalidation skipped: ${result.skipped}`
|
|
92
|
-
: `Snapshots ${result.from} → ${result.to}: revalidated ${result.revalidated.length} path(s)`,
|
|
104
|
+
: `Snapshots ${result.from} → ${result.to}: revalidated ${result.revalidated.length} path(s), ${result.tags.length} tag(s)`,
|
|
93
105
|
);
|
|
94
106
|
return result;
|
|
95
107
|
});
|
|
96
108
|
};
|
|
97
109
|
|
|
98
110
|
/**
|
|
99
|
-
* Site-wide changes that publish no snapshot (the site's SEO defaults: `site.updated`):
|
|
100
|
-
* revalidated at the snapshot the site already
|
|
111
|
+
* Site-wide changes that publish no snapshot (the site's SEO defaults: `site.updated`): the site tag expires
|
|
112
|
+
* every cached read (`site.get()` among them) and every page is revalidated at the snapshot the site already
|
|
113
|
+
* shows.
|
|
101
114
|
*/
|
|
102
115
|
export const revalidateEveryPage = (): RevalidationResult => {
|
|
103
116
|
if (isSnapshotPinned()) {
|
|
104
117
|
return { skipped: `the build is pinned to snapshot ${configuredSnapshot()} by SHAPIO_SNAPSHOT` };
|
|
105
118
|
}
|
|
119
|
+
const tags = siteTags(siteKey());
|
|
120
|
+
expireTags(tags);
|
|
106
121
|
revalidatePath(EVERY_PAGE.path, EVERY_PAGE.type);
|
|
107
122
|
const at = liveSnapshot();
|
|
108
123
|
log(`Site settings changed: revalidated every page at snapshot ${at}`);
|
|
109
|
-
return { from: at, to: at, changed: 0, revalidated: [EVERY_PAGE] };
|
|
124
|
+
return { from: at, to: at, changed: 0, revalidated: [EVERY_PAGE], tags };
|
|
110
125
|
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { changeTags, siteTags } from './revalidationTags';
|
|
3
|
+
|
|
4
|
+
describe('changeTags', () => {
|
|
5
|
+
it("tags each changed entry's model and the entry, without duplicates", () => {
|
|
6
|
+
expect(
|
|
7
|
+
changeTags(
|
|
8
|
+
[
|
|
9
|
+
{ routeKey: 'articles', id: 'a1' },
|
|
10
|
+
{ routeKey: 'articles', id: 'a2' },
|
|
11
|
+
{ routeKey: 'siteSettings', id: 's1' },
|
|
12
|
+
{ routeKey: 'articles', id: 'a1' },
|
|
13
|
+
],
|
|
14
|
+
false,
|
|
15
|
+
'marketing',
|
|
16
|
+
),
|
|
17
|
+
).toEqual([
|
|
18
|
+
'shapio:articles',
|
|
19
|
+
'shapio:articles:a1',
|
|
20
|
+
'shapio:articles:a2',
|
|
21
|
+
'shapio:siteSettings',
|
|
22
|
+
'shapio:siteSettings:s1',
|
|
23
|
+
]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('revalidates the site tag (every read) after a schema change', () => {
|
|
27
|
+
expect(changeTags([{ routeKey: 'articles', id: 'a1' }], true, 'marketing')).toEqual([
|
|
28
|
+
'shapio:site:marketing',
|
|
29
|
+
]);
|
|
30
|
+
expect(changeTags([], true, undefined)).toEqual(['shapio:site']);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('has nothing to revalidate when nothing changed', () => {
|
|
34
|
+
expect(changeTags([], false, undefined)).toEqual([]);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('siteTags', () => {
|
|
39
|
+
it("is the client's site tag: keyed when the site is configured, bare otherwise", () => {
|
|
40
|
+
expect(siteTags('marketing')).toEqual(['shapio:site:marketing']);
|
|
41
|
+
expect(siteTags(undefined)).toEqual(['shapio:site']);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { shapioTags, type SnapshotChange } from '@shapio/client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a set of Shapio changes means for Next's data cache: the arguments for `revalidateTag`, matching the
|
|
5
|
+
* tags `@shapio/client` puts on every delivery read (`shapioTags`). Pure, so the mapping is unit-tested;
|
|
6
|
+
* src/lib/revalidation.ts feeds it from the snapshot diff, which names each changed entry by its route key.
|
|
7
|
+
*/
|
|
8
|
+
export type ChangedEntryRef = Pick<SnapshotChange, 'routeKey' | 'id'>;
|
|
9
|
+
|
|
10
|
+
/** Every delivery read of the site carries this tag: the site's settings, and everything after a schema change. */
|
|
11
|
+
export const siteTags = (siteKey: string | undefined): string[] => [shapioTags.site(siteKey)];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The tags to revalidate for the entries that changed between two snapshots: each entry's model and the entry
|
|
15
|
+
* itself. A schema change (fields added, converted or removed) can change any read, so it revalidates the
|
|
16
|
+
* site tag, which every read carries.
|
|
17
|
+
*/
|
|
18
|
+
export const changeTags = (
|
|
19
|
+
changes: readonly ChangedEntryRef[],
|
|
20
|
+
schemaChanged: boolean,
|
|
21
|
+
siteKey: string | undefined,
|
|
22
|
+
): string[] => {
|
|
23
|
+
if (schemaChanged) {
|
|
24
|
+
return siteTags(siteKey);
|
|
25
|
+
}
|
|
26
|
+
const tags = new Set(
|
|
27
|
+
changes.flatMap(({ routeKey, id }) => [shapioTags.model(routeKey), shapioTags.entry(routeKey, id)]),
|
|
28
|
+
);
|
|
29
|
+
return [...tags].sort();
|
|
30
|
+
};
|
|
@@ -1,14 +1,25 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
ShapioApiError,
|
|
3
|
+
type DeliveryListQuery,
|
|
4
|
+
type DeliverySite,
|
|
5
|
+
type ShapioDeliveryClient,
|
|
6
|
+
} from '@shapio/client';
|
|
3
7
|
import { liveSnapshot } from './liveSnapshot';
|
|
8
|
+
import { createShapioClient } from './shapioClient';
|
|
4
9
|
import type { Locale } from './site';
|
|
5
10
|
import type { Article, Page, SiteSettings } from './types';
|
|
6
11
|
|
|
7
12
|
/**
|
|
8
|
-
* The site's read side: Shapio's delivery API
|
|
13
|
+
* The site's read side: Shapio's delivery API (over HTTP, or in this process with SHAPIO_MODE=in-process:
|
|
14
|
+
* src/lib/shapioClient.ts), at one publication snapshot. Next
|
|
9
15
|
* renders pages in several worker processes, so the snapshot is pinned once in next.config.ts (it sets
|
|
10
16
|
* SHAPIO_SNAPSHOT for the whole build) and every request here sends it. Under `next start`, /api/revalidate
|
|
11
17
|
* moves that snapshot forward (src/lib/liveSnapshot.ts).
|
|
18
|
+
*
|
|
19
|
+
* The client tags every read with the site, model and entry, and /api/revalidate expires the tags of what
|
|
20
|
+
* changed (src/lib/revalidationTags.ts) as well as the pages. No `next: { cache }` option: reads pinned to a
|
|
21
|
+
* snapshot are `force-cache` regardless, and `site.get()` (no snapshot) stays fresh on every build, since Next's
|
|
22
|
+
* data cache outlives `next build`.
|
|
12
23
|
*/
|
|
13
24
|
const PAGE_SIZE = 100;
|
|
14
25
|
|
|
@@ -22,11 +33,8 @@ const PAGE_FIELDS = ['title', 'slug', 'description', 'sections', 'seo'];
|
|
|
22
33
|
const ARTICLE_FIELDS = ['title', 'slug', 'excerpt', 'body', 'cover', 'author', 'publishedOn', 'seo'];
|
|
23
34
|
const SITE_SETTINGS_FIELDS = ['siteName', 'tagline', 'footer', 'colophon'];
|
|
24
35
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
let client: ReturnType<typeof createShapio> | undefined;
|
|
29
|
-
export const shapio = () => (client ??= createShapio());
|
|
36
|
+
let client: ShapioDeliveryClient | undefined;
|
|
37
|
+
export const shapio = () => (client ??= createShapioClient());
|
|
30
38
|
|
|
31
39
|
const snapshot = liveSnapshot;
|
|
32
40
|
|
|
@@ -80,8 +88,9 @@ export const getSiteSettings = async (locale: Locale): Promise<SiteSettings | nu
|
|
|
80
88
|
};
|
|
81
89
|
|
|
82
90
|
/**
|
|
83
|
-
* The site and its SEO defaults (name and title template per locale, default image, Twitter handle).
|
|
84
|
-
*
|
|
91
|
+
* The site and its SEO defaults (name and title template per locale, default image, Twitter handle). Cached
|
|
92
|
+
* under the site tag: a `site.updated` webhook expires it and revalidates every page, which then shows the new
|
|
93
|
+
* defaults. Null when
|
|
85
94
|
* the Shapio predates them (the pages still render, with plain titles).
|
|
86
95
|
*/
|
|
87
96
|
export const getSite = async (): Promise<DeliverySite | null> => {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createClient, type ShapioDeliveryClient } from '@shapio/client';
|
|
2
|
+
import { createLocalClient } from '@shapio/local';
|
|
3
|
+
import { databaseUrl, deliveryToken, shapioMode, shapioUrl, siteKey } from './config';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The client the site reads with: Shapio's delivery API over HTTP (SHAPIO_MODE=http, the default), or the same
|
|
7
|
+
* API in this process (SHAPIO_MODE=in-process, `@shapio/local`), reading Shapio's database directly. Both
|
|
8
|
+
* expose the same `delivery`, `site` and `snapshots` calls and answer them identically, with the same delivery
|
|
9
|
+
* token deciding what the site may read.
|
|
10
|
+
*/
|
|
11
|
+
export const createShapioClient = (): ShapioDeliveryClient & { close?: () => Promise<void> } => {
|
|
12
|
+
const site = siteKey();
|
|
13
|
+
if (shapioMode() === 'in-process') {
|
|
14
|
+
return createLocalClient({ databaseUrl: databaseUrl(), token: deliveryToken(), site });
|
|
15
|
+
}
|
|
16
|
+
return createClient({ baseUrl: shapioUrl(), token: deliveryToken(), site });
|
|
17
|
+
};
|
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
"typecheck": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --fail-on-warnings"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@shapio/client": "^0.
|
|
25
|
-
"@shapio/visual": "^0.
|
|
24
|
+
"@shapio/client": "^0.5.0",
|
|
25
|
+
"@shapio/visual": "^0.5.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@sveltejs/adapter-static": "4.0.0",
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { createHmac } from 'node:crypto';
|
|
2
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
2
4
|
import { parseArgs } from 'node:util';
|
|
3
5
|
|
|
4
6
|
/**
|
|
@@ -13,6 +15,10 @@ import { parseArgs } from 'node:util';
|
|
|
13
15
|
* `--url` is the running site's origin (default: SMOKE_URL, then http://localhost:4321). `--revalidate` (the
|
|
14
16
|
* Next.js starter) also checks its on-demand revalidation route: an unsigned POST to /api/revalidate is refused,
|
|
15
17
|
* and an event signed with SHAPIO_WEBHOOK_SECRET is answered 200 with the paths it revalidated.
|
|
18
|
+
*
|
|
19
|
+
* `--save <dir>` also writes each checked page's rendered markup to `<dir>` (scripts and the framework's own
|
|
20
|
+
* asset links removed: they name build-specific chunks), so two builds of a site can be compared with
|
|
21
|
+
* `diff -r`, e.g. the Next.js starter reading over HTTP and in process.
|
|
16
22
|
*/
|
|
17
23
|
const DRAFT_TITLE = 'Coming soon: our winter projects';
|
|
18
24
|
const ARTICLE = 'modelling-without-a-deploy';
|
|
@@ -85,6 +91,18 @@ const fetchPage = async (origin: string, path: string) => {
|
|
|
85
91
|
return { status: response.status, html: await response.text() };
|
|
86
92
|
};
|
|
87
93
|
|
|
94
|
+
/** The page as rendered: without scripts and framework asset links, which differ from build to build. */
|
|
95
|
+
const renderedMarkup = (html: string) =>
|
|
96
|
+
html
|
|
97
|
+
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/g, '')
|
|
98
|
+
.replace(/<link\b[^>]*\/_next\/[^>]*>/g, '')
|
|
99
|
+
.replace(/<meta\b[^>]*name="next-size-adjust"[^>]*>/g, '');
|
|
100
|
+
|
|
101
|
+
const savePage = async (directory: string, path: string, html: string) => {
|
|
102
|
+
const name = path.replace(/^\/|\/$/g, '').replace(/\//g, '__') || 'index';
|
|
103
|
+
await writeFile(join(directory, `${name}.html`), `${renderedMarkup(html)}\n`);
|
|
104
|
+
};
|
|
105
|
+
|
|
88
106
|
const REVALIDATE_PATH = '/api/revalidate/';
|
|
89
107
|
|
|
90
108
|
const postEvent = (origin: string, body: string, headers: Record<string, string>) =>
|
|
@@ -137,12 +155,22 @@ const checkRevalidation = async (origin: string) => {
|
|
|
137
155
|
|
|
138
156
|
const main = async () => {
|
|
139
157
|
const { values } = parseArgs({
|
|
140
|
-
options: {
|
|
158
|
+
options: {
|
|
159
|
+
url: { type: 'string' },
|
|
160
|
+
revalidate: { type: 'boolean', default: false },
|
|
161
|
+
save: { type: 'string' },
|
|
162
|
+
},
|
|
141
163
|
});
|
|
142
164
|
const origin = values.url ?? process.env.SMOKE_URL ?? 'http://localhost:4321';
|
|
165
|
+
if (values.save) {
|
|
166
|
+
await mkdir(values.save, { recursive: true });
|
|
167
|
+
}
|
|
143
168
|
for (const page of PAGES) {
|
|
144
169
|
const { status, html } = await fetchPage(origin, page.path);
|
|
145
170
|
check(status === 200, `${page.path}: HTTP ${status}`);
|
|
171
|
+
if (values.save) {
|
|
172
|
+
await savePage(values.save, page.path, html);
|
|
173
|
+
}
|
|
146
174
|
for (const [needle, label] of page.expect) {
|
|
147
175
|
check(html.includes(needle), `${page.path}: ${label}`);
|
|
148
176
|
}
|