afterimage-cli 0.1.0 → 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.
- package/CHANGELOG.md +5 -0
- package/package.json +1 -1
- package/src/cli/commands/dev.js +2 -1
- package/src/cli/hosts.js +24 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
- **`init` keeps Next.js's long cache on the live app.** For a project that depends on `next`, `vercel.json` gets a header rule and a new `netlify.toml` a `[[headers]]` block that serve `/_next/static/` with `public, max-age=31536000, immutable`. The host settings Afterimage needs for Next.js turn off each host's own Next.js handling, which set that header, and real deploys of 0.1.0 showed those files served with `max-age=0`. Run `npx afterimage init` again to add it to a project set up with 0.1.0; for an existing `netlify.toml` it prints the lines to add.
|
|
6
|
+
- **`afterimage dev` passes over a port Windows refuses.** Windows reserves port ranges for Hyper-V, WSL and Docker and refuses them with a permission error, which stopped `dev` instead of moving it to the next port, as a taken port does.
|
|
7
|
+
|
|
3
8
|
## 0.1.0
|
|
4
9
|
|
|
5
10
|
The first release.
|
package/package.json
CHANGED
package/src/cli/commands/dev.js
CHANGED
|
@@ -49,7 +49,8 @@ async function listen(server, start) {
|
|
|
49
49
|
});
|
|
50
50
|
return server.address().port;
|
|
51
51
|
} catch (error) {
|
|
52
|
-
|
|
52
|
+
// EACCES: Windows refuses ports in the ranges Hyper-V, WSL or Docker reserve, which nothing answers on beforehand.
|
|
53
|
+
if (error.code !== 'EADDRINUSE' && error.code !== 'EACCES') throw error;
|
|
53
54
|
}
|
|
54
55
|
}
|
|
55
56
|
throw new Error(`No free port from ${start} to ${start + portAttempts - 1}. Pass one with --port.`);
|
package/src/cli/hosts.js
CHANGED
|
@@ -21,7 +21,7 @@ export const cleanUrlRewrite = { source: '/:path((?!afterimage/|afterimage$).+)'
|
|
|
21
21
|
// own builder takes over: @vercel/next finds .next/export-detail.json after the build and serves the export's folder (out/),
|
|
22
22
|
// not outputDirectory, so /afterimage/ would not deploy. Vercel detects the framework from package.json, so this is keyed
|
|
23
23
|
// on a dependency on next and not on the type init chose. It costs the live app the builder's _next/static caching and its
|
|
24
|
-
// .html stripping, which the rewrite below
|
|
24
|
+
// .html stripping, which nextStaticCache and the rewrite below bring back. Other presets (Vite, Astro, SvelteKit) run in
|
|
25
25
|
// @vercel/static-build, which uses outputDirectory ahead of the preset's own folder, and null would cost them their cache
|
|
26
26
|
// headers or SPA fallback, so they are left as they are.
|
|
27
27
|
// Source: https://github.com/vercel/vercel/blob/main/packages/next/src/index.ts
|
|
@@ -32,6 +32,15 @@ const hostSettings = (next) => (next ? { framework: null, buildCommand: hostBuil
|
|
|
32
32
|
// is not caught and nothing loops.
|
|
33
33
|
export const slashRedirect = { source: '/afterimage', destination: '/afterimage/' };
|
|
34
34
|
|
|
35
|
+
// The long cache on a Next.js app's hashed files (/_next/static: content-hashed names, or the build id in the path), which
|
|
36
|
+
// Vercel's Next.js builder and Netlify's Next.js runtime set and which framework: null and NETLIFY_NEXT_PLUGIN_SKIP give up.
|
|
37
|
+
// Both hosts then serve them with max-age=0 (seen on real deploys, docs/checking-a-deploy.md). The source matches the live
|
|
38
|
+
// app's paths only: a version's copies, under /afterimage/versions/vN/_next/static/, keep the host's default.
|
|
39
|
+
const immutable = 'public, max-age=31536000, immutable';
|
|
40
|
+
export const nextStaticCache = { source: '/_next/static/(.*)', headers: [{ key: 'Cache-Control', value: immutable }] };
|
|
41
|
+
// Any rule of the project's own for those paths counts, whatever it sets.
|
|
42
|
+
const isStaticCache = rule => typeof rule?.source === 'string' && rule.source.trim().startsWith('/_next/static/');
|
|
43
|
+
|
|
35
44
|
// A rule counts as ours when it points at <path>.html and keeps /afterimage out, however the rest is spelled.
|
|
36
45
|
const isCleanUrlRewrite = rule => typeof rule?.destination === 'string' && /^\/:\w+\*?\.html$/.test(rule.destination) && typeof rule.source === 'string' && rule.source.includes('afterimage');
|
|
37
46
|
const fromAfterimage = rule => typeof rule?.source === 'string' && rule.source.trim().replace(/\/+$/, '') === slashRedirect.source;
|
|
@@ -75,7 +84,8 @@ export async function planVercel(cwd, { isTTY, ask }) {
|
|
|
75
84
|
const file = path.join(cwd, 'vercel.json');
|
|
76
85
|
const { bom, body: before } = await readJsonText(file);
|
|
77
86
|
const settings = before === null ? {} : parseJson(before, 'vercel.json');
|
|
78
|
-
const
|
|
87
|
+
const next = await usesNext(cwd);
|
|
88
|
+
const wanted = hostSettings(next);
|
|
79
89
|
const notes = [];
|
|
80
90
|
if (await dependsOn(cwd, '@astrojs/vercel')) notes.push(astroVercelNote);
|
|
81
91
|
|
|
@@ -101,6 +111,7 @@ export async function planVercel(cwd, { isTTY, ask }) {
|
|
|
101
111
|
lists.redirects = planList(settings, 'redirects', slashRedirect, isSlashRedirect, 'so /afterimage opens the viewer');
|
|
102
112
|
}
|
|
103
113
|
}
|
|
114
|
+
if (next) lists.headers = planList(settings, 'headers', nextStaticCache, isStaticCache, "for the long cache on the live app's /_next/static");
|
|
104
115
|
const added = {};
|
|
105
116
|
for (const [key, plan] of Object.entries(lists)) {
|
|
106
117
|
if (plan.note) notes.push(plan.note);
|
|
@@ -116,14 +127,14 @@ export async function planVercel(cwd, { isTTY, ask }) {
|
|
|
116
127
|
if (!agreed) {
|
|
117
128
|
// Values to set, and entries to add to lists the project may already have: pasting must never replace its own rules.
|
|
118
129
|
const lines = [`Left vercel.json as it is. To deploy the history with Afterimage, set:\n${JSON.stringify(wanted, null, 2).slice(2, -2)}`];
|
|
119
|
-
for (const [key, entry] of [['rewrites', cleanUrlRewrite], ['redirects', slashRedirect]]) {
|
|
130
|
+
for (const [key, entry] of [['rewrites', cleanUrlRewrite], ['redirects', slashRedirect], ['headers', nextStaticCache]]) {
|
|
120
131
|
if (key in added) lines.push(`and add this to "${key}", keeping the rules already there:\n${JSON.stringify(entry, null, 2)}`);
|
|
121
132
|
}
|
|
122
133
|
return { change: null, notes: [lines.join('\n'), ...notes], apply: null };
|
|
123
134
|
}
|
|
124
135
|
set.unshift(...differs.filter(key => !set.includes(key)));
|
|
125
136
|
}
|
|
126
|
-
const order = [...Object.keys(wanted), 'rewrites', 'redirects'];
|
|
137
|
+
const order = [...Object.keys(wanted), 'rewrites', 'redirects', 'headers'];
|
|
127
138
|
const text = JSON.stringify({ ...settings, ...wanted, ...added }, null, 2);
|
|
128
139
|
return {
|
|
129
140
|
change: before === null ? 'Created vercel.json' : `Updated vercel.json (set ${order.filter(key => set.includes(key)).join(', ')})`,
|
|
@@ -151,24 +162,30 @@ const usesNext = cwd => dependsOn(cwd, 'next');
|
|
|
151
162
|
// deploy. NETLIFY_NEXT_PLUGIN_SKIP makes it step aside. Read in opennextjs-netlify's source (src/index.ts,
|
|
152
163
|
// src/build/content/static.ts), not tried on a real deploy. Netlify answers /about from about.html on its own.
|
|
153
164
|
const skipRuntime = 'NETLIFY_NEXT_PLUGIN_SKIP';
|
|
165
|
+
// nextStaticCache, for Netlify. Its paths match the live app's only, as on Vercel.
|
|
166
|
+
const netlifyStaticCache = `[[headers]]\n for = "/_next/static/*"\n [headers.values]\n Cache-Control = "${immutable}"\n`;
|
|
154
167
|
|
|
155
168
|
export async function planNetlify(cwd) {
|
|
156
169
|
const file = path.join(cwd, 'netlify.toml');
|
|
157
170
|
const before = await readText(file);
|
|
158
171
|
const next = await usesNext(cwd);
|
|
159
172
|
if (before === null) {
|
|
160
|
-
const
|
|
161
|
-
return { change: 'Created netlify.toml', notes: [], apply: writer(file, `[build]\n command = "${hostBuild}"\n publish = "${hostOutput}"\n${
|
|
173
|
+
const nextOnly = next ? `\n[build.environment]\n ${skipRuntime} = "true"\n\n${netlifyStaticCache}` : '';
|
|
174
|
+
return { change: 'Created netlify.toml', notes: [], apply: writer(file, `[build]\n command = "${hostBuild}"\n publish = "${hostOutput}"\n${nextOnly}`) };
|
|
162
175
|
}
|
|
163
176
|
// TOML is not parsed or rewritten here: a hand edit is safer than a guessed merge.
|
|
164
177
|
const hasCommand = new RegExp(`^\\s*command\\s*=\\s*["']${hostBuild}["']`, 'm').test(before);
|
|
165
178
|
const hasPublish = new RegExp(`^\\s*publish\\s*=\\s*["']${hostOutput.replace('.', '\\.')}["']`, 'm').test(before);
|
|
166
179
|
const hasSkip = new RegExp(`^\\s*${skipRuntime}\\s*=\\s*["']?(true|1)["']?\\s*$`, 'm').test(before);
|
|
180
|
+
// Any [[headers]] rule of the project's own for those paths counts, whatever it sets.
|
|
181
|
+
const hasCache = /^\s*for\s*=\s*["']\/_next\/static\//m.test(before);
|
|
167
182
|
const wantsSkip = next && !hasSkip;
|
|
168
|
-
|
|
183
|
+
const wantsCache = next && !hasCache;
|
|
184
|
+
if (hasCommand && hasPublish && !wantsSkip && !wantsCache) return { change: null, notes: [], apply: null };
|
|
169
185
|
const lines = [];
|
|
170
186
|
if (!hasCommand || !hasPublish) lines.push(`Under [build], set:\n command = "${hostBuild}"\n publish = "${hostOutput}"`);
|
|
171
187
|
if (wantsSkip) lines.push(`Under [build.environment], set (so Netlify's Next.js runtime leaves the history in place instead of deploying out/):\n ${skipRuntime} = "true"`);
|
|
188
|
+
if (wantsCache) lines.push(`To keep the long cache on the live app's /_next/static, which the runtime would have set, add:\n${netlifyStaticCache.trimEnd()}`);
|
|
172
189
|
return {
|
|
173
190
|
change: null,
|
|
174
191
|
notes: [`netlify.toml already exists, so Afterimage left it alone. ${lines.join('\n')}`],
|