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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "afterimage-cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "A version history for prototypes, deployed with the prototype itself.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -49,7 +49,8 @@ async function listen(server, start) {
49
49
  });
50
50
  return server.address().port;
51
51
  } catch (error) {
52
- if (error.code !== 'EADDRINUSE') throw error;
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 brings back for the stripping. Other presets (Vite, Astro, SvelteKit) run in
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 wanted = hostSettings(await usesNext(cwd));
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 environment = next ? `\n[build.environment]\n ${skipRuntime} = "true"\n` : '';
161
- return { change: 'Created netlify.toml', notes: [], apply: writer(file, `[build]\n command = "${hostBuild}"\n publish = "${hostOutput}"\n${environment}`) };
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
- if (hasCommand && hasPublish && !wantsSkip) return { change: null, notes: [], apply: null };
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')}`],