afterimage-cli 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,171 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { sourceExcludes } from './config.js';
6
+ import { readApp, verifyHistory } from './history.js';
7
+ import { isLocked, retryLocked } from './retry.js';
8
+ import { runBuild } from './run-build.js';
9
+
10
+ // The package's own viewer and runtime, found from this file so they work from node_modules and from a checkout.
11
+ export const packagedViewerDir = fileURLToPath(new URL('../../viewer-dist/', import.meta.url));
12
+ export const packagedRuntimeDir = fileURLToPath(new URL('../runtime/', import.meta.url));
13
+
14
+ const servedFolder = 'afterimage';
15
+ const keep = { recursive: true, force: true, maxRetries: 3 };
16
+ const escapePattern = text => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
17
+
18
+ // Windows and macOS ignore case by default, so two spellings of one folder must compare equal there.
19
+ const fold = value => (process.platform === 'win32' || process.platform === 'darwin' ? value.toLowerCase() : value);
20
+
21
+ // True when child is parent or lies under it. A folder named "..x" is inside; only ".." itself leaves.
22
+ export function isSameOrInside(child, parent) {
23
+ const relative = path.relative(fold(parent), fold(child));
24
+ return !(relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative));
25
+ }
26
+
27
+ // Out is replaced wholesale, so it must not be, hold or sit inside anything that is not generated output.
28
+ function checkOut({ outDir, projectDir, historyDir, sourceDir, includeApp }) {
29
+ const replaces = dir => { throw new Error(`"out" (${outDir}) would replace ${dir}. Point "out" at a folder of its own.`); };
30
+ if (isSameOrInside(projectDir, outDir)) replaces(projectDir);
31
+ if (isSameOrInside(historyDir, outDir)) replaces(historyDir);
32
+ if (isSameOrInside(outDir, historyDir)) throw new Error(`"out" (${outDir}) is inside the history folder, where saved versions live. Point "out" at a folder of its own.`);
33
+ if (!includeApp) return;
34
+ if (isSameOrInside(sourceDir, outDir)) replaces(sourceDir);
35
+ // With the prototype at the project root, config.js already leaves out of the app; anywhere else it would copy itself.
36
+ if (!isSameOrInside(projectDir, sourceDir) && isSameOrInside(outDir, sourceDir)) {
37
+ throw new Error(`"out" (${outDir}) is inside the source folder, so each build would copy the last one into itself. Point "out" outside ${sourceDir}.`);
38
+ }
39
+ }
40
+
41
+ // Leftovers of a build that crashed, matched by this file's own naming so nothing else is touched.
42
+ async function removeStale(outDir) {
43
+ const pattern = new RegExp(`^${escapePattern(path.basename(outDir))}\\.tmp-[0-9a-f]{8}(\\.old)?$`);
44
+ try {
45
+ for (const name of await fs.readdir(path.dirname(outDir))) {
46
+ if (pattern.test(name)) await fs.rm(path.join(path.dirname(outDir), name), keep);
47
+ }
48
+ } catch { /* best effort */ }
49
+ }
50
+
51
+ const collision = () => new Error(`The app already has an ${servedFolder} file or folder, and Afterimage serves the history at /${servedFolder}/. Rename it or add it to exclude.`);
52
+
53
+ // A top-level file called afterimage would stop readSnapshot with a less helpful message, so look for it first.
54
+ async function checkCollision(sourceDir, excluded) {
55
+ let entries = [];
56
+ try {
57
+ entries = await fs.readdir(sourceDir, { withFileTypes: true });
58
+ } catch (error) {
59
+ if (error.code !== 'ENOENT') throw error;
60
+ }
61
+ if (entries.some(entry => !entry.isDirectory() && entry.name.toLowerCase() === servedFolder && !excluded.includes(entry.name))) throw collision();
62
+ }
63
+
64
+ // Builds the deployable site: the app at the root, the viewer and every saved version under /afterimage/.
65
+ // With includeApp false (the dev server) only the afterimage/ folder is made. The old out stays in place until the
66
+ // new one is complete, so a failure anywhere leaves the previous site untouched.
67
+ // Returns warnings for the caller to print. fsOps lets tests make the swap fail; it is not part of the public signature.
68
+ export async function assembleSite({ projectDir, config, viewerDir = packagedViewerDir, runtimeDir = packagedRuntimeDir, includeApp = true, buildStdio, fsOps = {} }) {
69
+ const ops = { rename: fs.rename, rm: fs.rm, ...fsOps };
70
+ const historyDir = path.resolve(projectDir, config.history);
71
+ const sourceDir = path.resolve(projectDir, config.source);
72
+ const outDir = path.resolve(projectDir, config.out);
73
+ const warnings = [];
74
+
75
+ const manifest = await verifyHistory(historyDir);
76
+ if (!manifest?.versions.length) throw new Error('Save a first version with: afterimage save "Name"');
77
+ try {
78
+ await fs.access(path.join(viewerDir, 'index.html'));
79
+ } catch {
80
+ throw new Error('The viewer is not built. Run "npm run build:viewer" first.');
81
+ }
82
+ // Without the runtime the viewer still loads, but every built app in it renders without its files, so a package
83
+ // that lost it must fail here rather than ship.
84
+ try {
85
+ await fs.access(runtimeDir);
86
+ } catch {
87
+ throw new Error(`The frame runtime is missing (${runtimeDir}). Reinstall afterimage-cli.`);
88
+ }
89
+ checkOut({ outDir, projectDir: path.resolve(projectDir), historyDir, sourceDir, includeApp });
90
+ // Asked twice: the user's build command can remove or create out, so the answer before it may be stale at the swap.
91
+ const outExists = async () => {
92
+ const found = await fs.lstat(outDir).catch(() => null);
93
+ if (found && !found.isDirectory()) throw new Error(`"out" (${outDir}) exists and is not a folder. Remove it or point "out" at another path.`);
94
+ return Boolean(found);
95
+ };
96
+ await outExists();
97
+ await removeStale(outDir);
98
+
99
+ let app;
100
+ if (includeApp) {
101
+ if (config.build) await runBuild(config.build, projectDir, { stdio: buildStdio, closing: 'Nothing was built.' });
102
+ const excluded = await sourceExcludes(projectDir, config);
103
+ await checkCollision(sourceDir, excluded);
104
+ // The app's output as its own build made it, not a save's selection: nothing Afterimage does may change the live root.
105
+ // Files no build wrote are the person's own folder, which can hold .env and editor settings like the project root.
106
+ app = await readApp(sourceDir, { exclude: excluded, plain: !config.build || sourceDir === path.resolve(projectDir) });
107
+ // Case-insensitive: on Windows and macOS Afterimage/ is the same folder.
108
+ if ([...app.keys()].some(name => name.split('/')[0].toLowerCase() === servedFolder)) throw collision();
109
+ }
110
+
111
+ await fs.mkdir(path.dirname(outDir), { recursive: true });
112
+ const staging = path.join(path.dirname(outDir), `${path.basename(outDir)}.tmp-${randomUUID().slice(0, 8)}`);
113
+ const previous = `${staging}.old`;
114
+ const rename = async (from, to) => {
115
+ try {
116
+ await retryLocked(() => ops.rename(from, to));
117
+ } catch (error) {
118
+ if (!isLocked(error)) throw error;
119
+ // The caller adds what became of the old site, which depends on how far the swap got.
120
+ throw Object.assign(new Error(`Could not replace ${config.out}: a file in it is in use. Close anything serving it (like afterimage dev or a static server) and run build again.`, { cause: error }), { locked: true });
121
+ }
122
+ };
123
+ try {
124
+ const served = path.join(staging, servedFolder);
125
+ await fs.mkdir(served, { recursive: true });
126
+ for (const [name, bytes] of app ?? []) {
127
+ const target = path.join(staging, name);
128
+ await fs.mkdir(path.dirname(target), { recursive: true });
129
+ await fs.writeFile(target, bytes);
130
+ }
131
+ await fs.cp(viewerDir, served, { recursive: true });
132
+ // Only what versions.json lists. The history folder also holds the save lock and stage folders.
133
+ await fs.copyFile(path.join(historyDir, 'versions.json'), path.join(served, 'versions.json'));
134
+ for (const { id } of manifest.versions) {
135
+ await fs.cp(path.join(historyDir, 'versions', id), path.join(served, 'versions', id), { recursive: true });
136
+ }
137
+ // map.js is for node (history.js injects with it); frame.js and sw.js carry their own copies of what they need,
138
+ // so the browser never loads it and it is not served.
139
+ await fs.cp(runtimeDir, served, { recursive: true, filter: source => path.relative(runtimeDir, source) !== 'map.js' });
140
+
141
+ // Windows cannot rename over an existing folder, so the old site steps aside first and comes back if the swap fails.
142
+ const existing = await outExists();
143
+ const unchanged = error => { if (error.locked) error.message += ' The previous site is unchanged.'; return error; };
144
+ if (existing) await rename(outDir, previous).catch(error => { throw unchanged(error); });
145
+ try {
146
+ await rename(staging, outDir);
147
+ } catch (error) {
148
+ if (existing) {
149
+ try {
150
+ await ops.rename(previous, outDir);
151
+ } catch {
152
+ // Keep the original error as the story; only add where the old site is.
153
+ throw new Error(`${error.message} The previous site could not be put back; it was left at ${previous}.`, { cause: error });
154
+ }
155
+ }
156
+ throw existing ? unchanged(error) : error;
157
+ }
158
+ // The new site is already live, so failing to clear the old one is a warning, not a failed build.
159
+ if (existing) {
160
+ try {
161
+ await ops.rm(previous, keep);
162
+ } catch (error) {
163
+ warnings.push(`Built, but could not remove the previous site at ${previous}: ${error.message}. It is safe to delete; the next build tries again.`);
164
+ }
165
+ }
166
+ } catch (error) {
167
+ await fs.rm(staging, keep).catch(() => {});
168
+ throw error;
169
+ }
170
+ return { outDir, versions: manifest.versions.length, warnings };
171
+ }
@@ -0,0 +1,46 @@
1
+ // Serving files from a folder over local HTTP. Shared by afterimage dev and the end-to-end harness, so both guard paths the same way.
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ // A save keeps any file, so this covers what prototypes commonly ship. Anything else is sent as application/octet-stream.
6
+ export const TYPES = {
7
+ html: 'text/html; charset=utf-8', js: 'text/javascript', mjs: 'text/javascript', css: 'text/css', json: 'application/json',
8
+ webmanifest: 'application/manifest+json', xml: 'application/xml', pdf: 'application/pdf', txt: 'text/plain', md: 'text/markdown',
9
+ csv: 'text/csv', vtt: 'text/vtt', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
10
+ webp: 'image/webp', avif: 'image/avif', ico: 'image/x-icon', woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf',
11
+ mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime', mp3: 'audio/mpeg', m4a: 'audio/mp4', wav: 'audio/wav', ogg: 'audio/ogg',
12
+ wasm: 'application/wasm', glb: 'model/gltf-binary', gltf: 'model/gltf+json', riv: 'application/octet-stream'
13
+ };
14
+
15
+ // The decoded path of a request URL, or null when it can't be decoded or holds a NUL byte (no file has one, and
16
+ // the file system would throw on it).
17
+ export function requestPath(url) {
18
+ try {
19
+ const decoded = decodeURIComponent(new URL(url, 'http://localhost').pathname);
20
+ return decoded.includes('\0') ? null : decoded;
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ // The file under `root` that `relative` (a URL path starting with /) asks for, or null when it would leave
27
+ // `root` or reach a hidden file or folder. Both slashes count as separators, since Windows accepts either.
28
+ export function fileFor(root, relative) {
29
+ if (relative.split(/[\\/]/).some(part => part.startsWith('.'))) return null;
30
+ const file = path.resolve(root, '.' + relative + (relative.endsWith('/') ? 'index.html' : ''));
31
+ return file.startsWith(path.resolve(root) + path.sep) ? file : null;
32
+ }
33
+
34
+ // Sends `file` uncached, or a 404 when it isn't there.
35
+ export async function sendFile(res, file) {
36
+ try {
37
+ const data = await fs.readFile(file);
38
+ res.setHeader('Content-Type', TYPES[path.extname(file).slice(1).toLowerCase()] || 'application/octet-stream');
39
+ res.setHeader('Cache-Control', 'no-store');
40
+ res.end(data);
41
+ } catch (error) {
42
+ if (!['ENOENT', 'EISDIR', 'ENOTDIR'].includes(error.code)) console.error(error);
43
+ res.statusCode = 404;
44
+ res.end('Not found');
45
+ }
46
+ }
@@ -0,0 +1,19 @@
1
+ export function validateManifest(manifest) {
2
+ if (manifest?.schemaVersion !== 1 || !Array.isArray(manifest.versions) || !manifest.versions.length) {
3
+ throw new Error('Version history is missing or has an unsupported format.');
4
+ }
5
+ let previous = Infinity;
6
+ for (const version of manifest.versions) {
7
+ const number = Number(version?.id?.slice(1));
8
+ if (!/^v[1-9]\d*$/.test(version?.id) || !Number.isSafeInteger(number) || number >= previous ||
9
+ version.file !== `versions/${version.id}/index.html` || version.label !== `Version ${number}` ||
10
+ typeof version.name !== 'string' || !version.name.trim() ||
11
+ !Array.isArray(version.notes) || version.notes.some(note => typeof note !== 'string') ||
12
+ !/^[a-f0-9]{64}$/.test(version.sha256) ||
13
+ (version.savedAt !== null && (typeof version.savedAt !== 'string' || !Number.isFinite(Date.parse(version.savedAt))))) {
14
+ throw new Error('Version history contains an invalid entry.');
15
+ }
16
+ previous = number;
17
+ }
18
+ return manifest;
19
+ }
@@ -0,0 +1,84 @@
1
+ // The frame runtime, served at /afterimage/frame.js. A save puts it first in every saved page's <head>, as
2
+ // <script src="/afterimage/frame.js" data-afterimage-version="vN">, so it runs before any of the app's own scripts.
3
+ // It is a classic script for that reason, and so it cannot import map.js; the one mapping it needs is inlined.
4
+ // sw.js explains the approach and its limits.
5
+ (() => {
6
+ const version = document.currentScript?.dataset.afterimageVersion;
7
+ const prefix = `/afterimage/versions/${version}`;
8
+ if (!version || !location.pathname.startsWith(`${prefix}/`)) return;
9
+ // The viewer reads this to tell which version a frame holds, since the URL below may no longer say.
10
+ window.__afterimageVersion = version;
11
+
12
+ // The app's own path at the root, so its router matches: .../vN/index.html -> /, .../vN/about/index.html -> /about/,
13
+ // .../vN/two.html -> /two.html. Relative URLs now resolve against it too; the worker maps the result back.
14
+ // Only a frame the worker controls is moved. Without one (no service workers on plain http, a host that won't serve
15
+ // sw.js) nothing would map root paths back, so the page keeps its own URL and its relative files still load from
16
+ // the version folder.
17
+ const mapped = Boolean(window.navigator?.serviceWorker?.controller);
18
+ const own = mapped ? location.pathname.slice(prefix.length).replace(/(^|\/)index\.html$/, '$1') : location.pathname;
19
+ // The file this document was loaded from, so the viewer can tell its own page from one reached by a hard link.
20
+ window.__afterimageFile = location.pathname;
21
+ if (mapped) history.replaceState(history.state, '', own + location.search + location.hash);
22
+
23
+ // The page the viewer compares: the frame's path as the app leaves it on its own. An app that redirects on load
24
+ // (replaceState to /home, / to /en) settles there. The person's first press or key inside the frame freezes it, so
25
+ // a route they open afterwards is not the page being compared. currententrychange follows the app's own
26
+ // same-document moves once they commit; load catches the rest, and is the only check without the Navigation API.
27
+ // Limits: without it, a move after load is missed; a redirect to another document (location.replace('/en')) loads
28
+ // another file, which the viewer does not compare; a press before the app has moved freezes the path early.
29
+ window.__afterimagePath = own + location.search;
30
+ let touched = false;
31
+ const follow = () => {
32
+ if (!touched) window.__afterimagePath = location.pathname + location.search;
33
+ };
34
+ const freeze = () => {
35
+ follow();
36
+ touched = true;
37
+ };
38
+ window.addEventListener('pointerdown', freeze, true);
39
+ window.addEventListener('keydown', freeze, true);
40
+ window.addEventListener('load', follow);
41
+ if (window.navigation) navigation.addEventListener('currententrychange', follow);
42
+
43
+ // Where a navigation to `href` belongs: same-origin paths outside /afterimage/ move into this version. Anything
44
+ // else (another origin, the viewer, a path already in a version) is left alone and gets null. So are blob:, data:
45
+ // and other non-http URLs: a blob: URL has this page's origin but no path to move, and would come back unchanged.
46
+ const inVersion = href => {
47
+ const url = new URL(href, location.href);
48
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
49
+ if (url.origin !== location.origin || url.pathname === '/afterimage' || url.pathname.startsWith('/afterimage/')) return null;
50
+ url.pathname = prefix + url.pathname;
51
+ return url.href;
52
+ };
53
+
54
+ // A page navigation to /about is outside the worker's scope, so it would load the live app. The Navigation API
55
+ // sees every one (links, location.assign and href, forms) and is the only way to catch location.assign, which
56
+ // cannot be patched. Same-document navigations (a router's pushState) are left alone, and so are traversals,
57
+ // downloads and POST forms, which cannot be re-sent elsewhere.
58
+ if (window.navigation) {
59
+ navigation.addEventListener('navigate', event => {
60
+ if (event.destination.sameDocument || !event.cancelable || event.navigationType === 'traverse' || event.downloadRequest !== null || event.formData) return;
61
+ const target = inVersion(event.destination.url);
62
+ // Redirecting to where it already goes would only start this handler again.
63
+ if (!target || target === event.destination.url) return;
64
+ event.preventDefault();
65
+ const redirect = navigation.navigate(target, { history: event.navigationType === 'push' ? 'auto' : 'replace' });
66
+ // Both reject when the redirect is cut short (the viewer moves the frame meanwhile); that is not the app's error.
67
+ redirect.committed.catch(() => {});
68
+ redirect.finished.catch(() => {});
69
+ });
70
+ return;
71
+ }
72
+
73
+ // Without the Navigation API only plain link clicks can be caught. This listens on window in the bubble phase, so
74
+ // it runs after the app's own handlers and leaves alone any click a router already took (defaultPrevented).
75
+ window.addEventListener('click', event => {
76
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
77
+ const link = event.target instanceof Element ? event.target.closest('a[href]') : null;
78
+ if (!link || typeof link.href !== 'string' || (link.target && link.target !== '_self') || link.hasAttribute('download')) return;
79
+ const target = inVersion(link.href);
80
+ if (!target) return;
81
+ event.preventDefault();
82
+ location.assign(target);
83
+ });
84
+ })();
@@ -0,0 +1,79 @@
1
+ // Pure helpers for the frame runtime. history.js imports this module in node. sw.js is a classic worker, which cannot
2
+ // import an ES module, so it carries its own copy of mapRequest, versionInPath and isServedPath; tests/runtime.test.mjs
3
+ // runs one table against both copies so they cannot drift apart.
4
+
5
+ const served = '/afterimage';
6
+ const versionId = /^v[1-9]\d*$/;
7
+
8
+ // The viewer, the runtime and the saved versions: everything Afterimage itself serves.
9
+ export const isServedPath = pathname => pathname === served || pathname.startsWith(`${served}/`);
10
+
11
+ // The version a document lives in, from its path: /afterimage/versions/v3/... -> 'v3'. Anything else -> null.
12
+ export function versionInPath(pathname) {
13
+ const match = /^\/afterimage\/versions\/(v[1-9]\d*)\//.exec(pathname);
14
+ return match ? match[1] : null;
15
+ }
16
+
17
+ // Where a request from a version's document really lives. A built app asks for /assets/x.js as if it were at the
18
+ // root; from v3 that is /afterimage/versions/v3/assets/x.js. Requests already under /afterimage/ (relative URLs,
19
+ // the runtime itself) and requests from a client of no known version pass through unchanged. Takes a root path
20
+ // or an absolute URL and returns the same kind. The caller checks the origin.
21
+ export function mapRequest({ clientVersion, url }) {
22
+ if (typeof clientVersion !== 'string' || !versionId.test(clientVersion)) return url;
23
+ const absolute = /^[a-z][a-z\d+.-]*:/i.test(url);
24
+ const parsed = new URL(url, 'http://afterimage.invalid');
25
+ if (isServedPath(parsed.pathname)) return url;
26
+ parsed.pathname = `${served}/versions/${clientVersion}${parsed.pathname}`;
27
+ return absolute ? parsed.href : parsed.pathname + parsed.search + parsed.hash;
28
+ }
29
+
30
+ // The one tag a save adds to each page. A classic script, so it runs before anything else on the page.
31
+ export const frameTag = id => `<script src="/afterimage/frame.js" data-afterimage-version="${id}"></script>`;
32
+ // Anchored: only a tag at the insertion point counts as ours. The same text elsewhere (a string in a script) is content.
33
+ const savedTag = /^<script src="\/afterimage\/frame\.js" data-afterimage-version="v[1-9]\d*"><\/script>/;
34
+
35
+ // Everything that may sit before <head> in a page: a byte order mark (as a character, or as UTF-8 bytes read as
36
+ // latin1, which is how a save reads them), white space, comments, <?...?> and the doctype.
37
+ const bom = /^(?:\uFEFF|\xEF\xBB\xBF)/;
38
+ const space = /^[\t\n\f\r ]+/;
39
+ const prolog = /^(?:<!--[\s\S]*?-->|<\?[^>]*>|<!doctype[^>]*>)/i;
40
+ // An open tag with any attributes, quoted values may hold ">". The lookahead keeps <header> from counting as <head>.
41
+ const openTag = name => new RegExp(`^<${name}(?=[\\t\\n\\f\\r />])(?:[^>"']|"[^"]*"|'[^']*')*>`, 'i');
42
+ const htmlTag = openTag('html');
43
+ const headTag = openTag('head');
44
+ const isDocument = /<!doctype[\t\n\f\r >]|<(?:html|head)(?=[\t\n\f\r />])/i;
45
+
46
+ // Puts the frame tag first in <head>, inserting it and changing no other byte. Without a <head> tag it goes right
47
+ // after <html ...>, else after the doctype and any comments, else at the start; the parser puts it in the head either
48
+ // way. A page that already has the tag there (saved from a version's own files) only has its version id updated, so
49
+ // a tag is never doubled and the same build always injects to the same bytes.
50
+ // Only a page someone opens gets the tag, and a fragment a prototype fetches and inserts (htmx's hx-get, fetch and
51
+ // innerHTML) is left as it is: a tag there would become its first child, and htmx would run it. A page is told apart by
52
+ // a doctype, an <html> or a <head>, in any case. A version's entry page always gets it (page: true), since a hand-written
53
+ // index.html may start straight at <h1>.
54
+ export function injectFrameScript(html, id, { page = isDocument.test(html) } = {}) {
55
+ if (!page) return html;
56
+ let position = bom.exec(html)?.[0].length ?? 0;
57
+ let insertAt = position;
58
+ const skip = pattern => {
59
+ const match = pattern.exec(html.slice(position));
60
+ if (match) position += match[0].length;
61
+ return Boolean(match);
62
+ };
63
+ const skipProlog = () => {
64
+ for (;;) {
65
+ skip(space);
66
+ if (!skip(prolog)) return;
67
+ insertAt = position;
68
+ }
69
+ };
70
+ skipProlog();
71
+ if (skip(htmlTag)) {
72
+ insertAt = position;
73
+ skipProlog();
74
+ }
75
+ if (skip(headTag)) insertAt = position;
76
+ const rest = html.slice(insertAt);
77
+ const existing = savedTag.exec(rest);
78
+ return html.slice(0, insertAt) + frameTag(id) + (existing ? rest.slice(existing[0].length) : rest);
79
+ }
@@ -0,0 +1,218 @@
1
+ // The frame runtime's service worker, served at /afterimage/sw.js with scope /afterimage/.
2
+ //
3
+ // Approach: each saved page carries frame.js (added at save time), which rewrites the frame's URL with replaceState
4
+ // from /afterimage/versions/vN/<path> to /<path>, so the app's router sees its own paths. Built apps also ask for
5
+ // root paths (/assets/x.js, /_next/...). This worker answers those from the version's folder. Nothing in it knows
6
+ // any framework. The alternatives (a relative base set through the framework's config, rewriting paths at save
7
+ // time) need per-framework work and miss URLs built at runtime, so they were not used.
8
+ //
9
+ // A classic worker, not a module one, so it runs wherever service workers do. It cannot import map.js (an ES module
10
+ // that history.js uses in node), so the three helpers below are an inline copy; tests/runtime.test.mjs runs the same
11
+ // table against both copies.
12
+ //
13
+ // Why this works:
14
+ // - Scope. A navigation is routed by the destination URL's scope, so the frames (/afterimage/versions/vN/...) are
15
+ // controlled and the live app at / never is. If the app registers its own worker at scope /, the longer
16
+ // /afterimage/ scope still wins for the viewer and the frames, and ours never reaches the root.
17
+ // - Subresources. A controlled document's requests come here whatever their URL, also after replaceState moved
18
+ // the document to /<path>. So a root path from a version maps into the version, and a request already under
19
+ // /afterimage/ (a relative URL resolved before the rewrite, the runtime itself) passes through.
20
+ // - Response URL. A mapped answer is a new Response with no URL of its own, so the page treats it as coming from the
21
+ // path it asked for. Otherwise a module would get the version folder as its base URL, and a split chunk's
22
+ // "./index.js" would be a second copy of the entry module, run twice.
23
+ // - Versions per client. The worker learns a frame's version from the navigation that creates it
24
+ // (resultingClientId on the request to /afterimage/versions/vN/...). It keeps that in memory and in the Cache API,
25
+ // keyed by client id, because the browser stops idle workers and a restarted one has no memory. Unknown clients
26
+ // (the viewer, anything that never navigated into a version) pass through.
27
+ // - Navigations. A frame going to /about is out of scope, so this worker never sees it. frame.js catches those
28
+ // and sends them to /afterimage/versions/vN/about instead.
29
+ // - Clean URLs. Static hosts answer /about from about.html (Netlify by default, Vercel with cleanUrls) or redirect it
30
+ // to /about/ when there is an about/index.html, and built apps rely on that: Next's static export writes about.html,
31
+ // links to /about and sends HEAD /about before it prefetches; Astro writes about/index.html and links to /about.
32
+ // Whatever serves the version folders may do neither (afterimage dev and plain file servers do not), so a 404 for
33
+ // a path in a version, page or subresource, is tried again here the same way: <path>.html answers in its place,
34
+ // and <path>/index.html turns it into a redirect to <path>/. The app sees the path it asked for, as on such a host.
35
+ // A path ending in / or in a file name with an extension (/x.js, /about.html, Next's /about.txt), and any answer
36
+ // other than 404, are left as the host gave them, so a missing asset costs no extra requests.
37
+ // - Timing. The viewer waits until this worker is active before it creates frames, and install/activate take over
38
+ // at once (skipWaiting, clients.claim), so a frame's first request is already mapped.
39
+ // - History. The viewer moves a frame to another version with location.replace, so its own version switches add no
40
+ // history entries for Back to land on.
41
+ //
42
+ // Known limits. Each of these reaches the live app at the root instead of the version:
43
+ // - Reloading a frame from the browser's menu, and going back or forward to an entry of a document the frame has
44
+ // left: after a hard link, or after a router's pushState followed by a version switch. Those entries hold the
45
+ // rewritten /<path>, which is out of scope. A router's own back and forward inside one document work.
46
+ // - Links that open elsewhere: target=_blank, _top and _parent (the navigation happens in another window, which
47
+ // frame.js does not run in), and download links (a download is not a navigation frame.js can redirect).
48
+ // - POST forms, which cannot be re-sent to another URL.
49
+ // - The app's own iframes: their navigations are their own, and an about:blank child is not controlled.
50
+ // - Without the Navigation API (older Safari) frame.js sees only plain link clicks, not location.assign or forms.
51
+ // - Requests made by a version's own Web Workers.
52
+ // - Hosts that answer a missing file with 403 instead of 404 (S3 and similar object stores) get no clean-URL
53
+ // fallback: /about stays a 404 inside a version when only about.html exists (a miss, not the live app).
54
+ // - Without service workers (http on a non-localhost address, a host that does not serve sw.js as JavaScript) the
55
+ // viewer still renders. frame.js then leaves each page at its own URL, so relative files load from the version,
56
+ // but root paths (/assets/x.js) reach the live app: a built app renders unstyled, and its router sees
57
+ // /afterimage/versions/vN/... instead of its own paths.
58
+ // And one that comes with versions sharing the live app's origin: an old version's code runs there. It can register
59
+ // a worker at scope / (its register('/sw.js') resolves against the root) and write cookies, localStorage and
60
+ // IndexedDB that the live app then reads.
61
+
62
+ const served = '/afterimage';
63
+ const versionId = /^v[1-9]\d*$/;
64
+
65
+ // ---- inline copy of map.js (keep in step; tests/runtime.test.mjs checks both) ----
66
+ function isServedPath(pathname) {
67
+ return pathname === served || pathname.startsWith(`${served}/`);
68
+ }
69
+
70
+ function versionInPath(pathname) {
71
+ const match = /^\/afterimage\/versions\/(v[1-9]\d*)\//.exec(pathname);
72
+ return match ? match[1] : null;
73
+ }
74
+
75
+ function mapRequest({ clientVersion, url }) {
76
+ if (typeof clientVersion !== 'string' || !versionId.test(clientVersion)) return url;
77
+ const absolute = /^[a-z][a-z\d+.-]*:/i.test(url);
78
+ const parsed = new URL(url, 'http://afterimage.invalid');
79
+ if (isServedPath(parsed.pathname)) return url;
80
+ parsed.pathname = `${served}/versions/${clientVersion}${parsed.pathname}`;
81
+ return absolute ? parsed.href : parsed.pathname + parsed.search + parsed.hash;
82
+ }
83
+ // ---- end of the copy ----
84
+
85
+ const store = 'afterimage-clients';
86
+ const keyFor = clientId => new URL(`/afterimage/__clients/${encodeURIComponent(clientId)}`, self.location.href).href;
87
+ // A frame being created is not in clients.matchAll() until its document is ready, and the viewer creates two at once.
88
+ // So an entry is only dropped once its client has been listed and then is gone. Entries never listed (a navigation
89
+ // that was abandoned) are kept, but only the newest few, so the store stays small without any timing.
90
+ const unseenKept = 16;
91
+ // clientId -> { id, seen }: the version, and whether the client has been listed yet.
92
+ const versions = new Map();
93
+
94
+ self.addEventListener('install', () => self.skipWaiting());
95
+ self.addEventListener('activate', event => event.waitUntil(self.clients.claim()));
96
+
97
+ async function read(response) {
98
+ try {
99
+ return response ? JSON.parse(await response.text()) : null;
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ async function remember(clientId, entry) {
106
+ const cache = await caches.open(store);
107
+ await cache.put(keyFor(clientId), new Response(JSON.stringify(entry)));
108
+ // Frames come and go as the viewer switches versions; drop what belongs to clients that are gone.
109
+ const alive = new Set((await self.clients.matchAll({ includeUncontrolled: true })).map(client => client.id));
110
+ const unseen = [];
111
+ for (const request of await cache.keys()) {
112
+ const known = decodeURIComponent(new URL(request.url).pathname.split('/').pop());
113
+ const value = await read(await cache.match(request));
114
+ if (!value) {
115
+ await cache.delete(request);
116
+ } else if (alive.has(known)) {
117
+ if (!value.seen) await cache.put(request, new Response(JSON.stringify({ ...value, seen: true })));
118
+ versions.set(known, { ...value, seen: true });
119
+ } else if (value.seen) {
120
+ await cache.delete(request);
121
+ versions.delete(known);
122
+ } else {
123
+ unseen.push([request, known]);
124
+ }
125
+ }
126
+ // keys() lists entries oldest first, so the end of the list is the newest.
127
+ for (const [request, known] of unseen.slice(0, -unseenKept)) {
128
+ await cache.delete(request);
129
+ versions.delete(known);
130
+ }
131
+ }
132
+
133
+ // Storage can be blocked or full; then the request is treated as one from an unknown client and passes through.
134
+ async function recall(clientId) {
135
+ try {
136
+ const value = await read(await (await caches.open(store)).match(keyFor(clientId)));
137
+ if (value) versions.set(clientId, value);
138
+ return value?.id;
139
+ } catch {
140
+ return undefined;
141
+ }
142
+ }
143
+
144
+ // `request` sent to `url` instead. The init is spelled out: a Request passed as init would drop its body's streaming
145
+ // mode. new Request refuses mode 'navigate'; for a page's own origin it amounts to 'same-origin'. Only GET and HEAD
146
+ // get here.
147
+ function like(request, url, method = request.method) {
148
+ const { headers, mode, credentials, cache, redirect, referrer, referrerPolicy, integrity, signal } = request;
149
+ return new Request(url, { method, headers, mode: mode === 'navigate' ? 'same-origin' : mode, credentials, cache, redirect, referrer, referrerPolicy, integrity, signal });
150
+ }
151
+
152
+ // The response with no URL of its own (see Response URL above). A request with redirect 'manual' gets an opaque
153
+ // redirect (status 0), which a new Response cannot carry. It is passed back as it is: a redirect has no body or base
154
+ // URL to keep, and the page follows it or not as it asked. redirect 'error' needs nothing: fetch rejects on a
155
+ // redirect, as it would have without the worker.
156
+ function plain(response) {
157
+ if (response.type === 'opaqueredirect' || response.status === 0) return response;
158
+ return new Response(response.body, { status: response.status, statusText: response.statusText, headers: response.headers });
159
+ }
160
+
161
+ // A response that is not passed on. Its body would otherwise stay open until the browser collects it.
162
+ const drop = response => response.body?.cancel().catch(() => {});
163
+
164
+ // What the page gets for `found`, the host's answer for `inVersion` (the path in the version folder), with clean URLs
165
+ // (see above): the host's answer or <path>.html, without a URL of their own, or a redirect. `asked` is the URL the
166
+ // request was made with, which the redirect adds the slash to, so the page goes on to ask for the path it would on
167
+ // such a host. The redirect goes back as Response.redirect made it, not through plain().
168
+ async function withCleanUrls(request, inVersion, asked, found) {
169
+ const { pathname } = new URL(inVersion);
170
+ const name = pathname.slice(pathname.lastIndexOf('/') + 1);
171
+ if (found.status !== 404 || !name || /\.[^.]+$/.test(name)) return plain(found);
172
+ const at = suffix => {
173
+ const url = new URL(inVersion);
174
+ url.pathname += suffix;
175
+ return url.href;
176
+ };
177
+ const page = await fetch(like(request, at('.html')));
178
+ if (page.ok) {
179
+ drop(found);
180
+ return plain(page);
181
+ }
182
+ drop(page);
183
+ const index = await fetch(like(request, at('/index.html'), 'HEAD'));
184
+ drop(index);
185
+ if (!index.ok) return plain(found);
186
+ drop(found);
187
+ const slashed = new URL(asked);
188
+ slashed.pathname += '/';
189
+ return Response.redirect(slashed.href, 301);
190
+ }
191
+
192
+ async function answer(request, id) {
193
+ if (!id) return fetch(request);
194
+ const inVersion = mapRequest({ clientVersion: id, url: request.url });
195
+ return withCleanUrls(request, inVersion, request.url, await fetch(like(request, inVersion)));
196
+ }
197
+
198
+ self.addEventListener('fetch', event => {
199
+ const { request } = event;
200
+ const url = new URL(request.url);
201
+ if (url.origin !== self.location.origin) return;
202
+ if (request.mode === 'navigate') {
203
+ const id = versionInPath(url.pathname);
204
+ if (!id) return;
205
+ if (event.resultingClientId) {
206
+ const entry = { id, seen: false };
207
+ versions.set(event.resultingClientId, entry);
208
+ event.waitUntil(remember(event.resultingClientId, entry).catch(() => {}));
209
+ }
210
+ // Answered here only for the clean URLs; a page the host has comes back with its own status, headers and body.
211
+ event.respondWith(fetch(request).then(found => withCleanUrls(request, request.url, request.url, found)));
212
+ return;
213
+ }
214
+ // A static site has nothing that takes a POST, so other methods pass through with their bodies untouched.
215
+ if (!event.clientId || isServedPath(url.pathname) || (request.method !== 'GET' && request.method !== 'HEAD')) return;
216
+ const known = versions.get(event.clientId)?.id;
217
+ event.respondWith(known ? answer(request, known) : recall(event.clientId).then(id => answer(request, id)));
218
+ });