Skip to content

Commit 9c86c94

Browse files
committed
web(build): the build id covers everything that ships under it
A release puts everything under /_/<id>/ and tells browsers it never changes. The id was a hash of the client, the page and the extension's top-level .js files, so a changed scratch-workspace extension, shim, media file or skill kept its id — and would have been served stale, from cache, for a year. The hash now covers the extension's whole tree, its browser build, the scratch workspace and the page's configuration code.
1 parent 5393933 commit 9c86c94

3 files changed

Lines changed: 108 additions & 3 deletions

File tree

‎scripts/build-web.mjs‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/env node
22
// Build LevelCode in your browser: one static directory, dist-web/, that any static host can serve.
33
//
4-
// node scripts/build-web.mjs [--vscode vscode] [--out dist-web] [--account https://levelcode.ai]
4+
// node scripts/build-web.mjs [--vscode vscode] [--out dist-web] [--account https://levelcode.ai] [--api-url <origin>]
55
// [--webview-origin 'https://{{uuid}}.view.example.com']
66
// [--ext-host-origin 'https://{{uuid}}.ext.example.com']
77
// [--static <prebuilt vscode-web dir>] [--id <build id>]
@@ -23,6 +23,7 @@ import { fileURLToPath } from 'node:url';
2323
import { webConfig, renderIndex } from '../web/lib/config.mjs';
2424
import { stageDeclarativeExtensions, copyExtension } from '../web/lib/extensions.mjs';
2525
import { policy, toHeadersFile, toNginx } from '../web/lib/headers.mjs';
26+
import { hashTree } from '../web/lib/fingerprint.mjs';
2627

2728
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
2829
const WEB = path.join(REPO, 'web');
@@ -34,6 +35,8 @@ const args = Object.fromEntries(process.argv.slice(2).reduce((a, v, i, all) => {
3435
const vscodeDir = path.resolve(REPO, args.vscode || 'vscode');
3536
const outDir = path.resolve(REPO, args.out || 'dist-web');
3637
const account = String(args.account || process.env.LEVELCODE_WEB_ACCOUNT || 'https://levelcode.ai').replace(/\/+$/, '');
38+
// Only for a deployment whose API is not on the account origin (production has one host for both).
39+
const apiUrl = String(args['api-url'] || process.env.LEVELCODE_WEB_API_URL || '').replace(/\/+$/, '');
3740
const webviewOrigin = args['webview-origin'] || process.env.LEVELCODE_WEB_WEBVIEW_ORIGIN || '';
3841
const extHostOrigin = args['ext-host-origin'] || process.env.LEVELCODE_WEB_EXTHOST_ORIGIN || '';
3942

@@ -65,8 +68,13 @@ for (const must of ['out/vs/workbench/workbench.web.main.internal.js', 'out/vs/w
6568
const fingerprint = createHash('sha256');
6669
fingerprint.update(fs.readFileSync(path.join(staticDir, 'out/vs/workbench/workbench.web.main.internal.js')));
6770
for (const f of ['main.js', 'boot.css', 'index.html']) { fingerprint.update(fs.readFileSync(path.join(WEB, f))); }
71+
// Everything that goes into /_/<id>/ and is not the web client itself: the extension (its sources, media and skills),
72+
// its browser build, the scratch workspace, and the page's own configuration code.
6873
const aiDir = path.join(REPO, 'extensions', 'levelcode-ai');
69-
for (const f of fs.readdirSync(aiDir)) { if (f.endsWith('.js')) { fingerprint.update(fs.readFileSync(path.join(aiDir, f))); } }
74+
hashTree(fingerprint, aiDir, { skip: ['test', 'node_modules', 'scripts', '.DS_Store'] });
75+
hashTree(fingerprint, path.join(WEB, 'ai-extension'), { skip: ['assets.generated.js', 'node_modules', '.DS_Store'] });
76+
hashTree(fingerprint, path.join(WEB, 'workspace'), { skip: ['node_modules', '.DS_Store'] });
77+
hashTree(fingerprint, path.join(WEB, 'lib'), { skip: ['.DS_Store'] });
7078
const id = String(args.id || fingerprint.digest('hex').slice(0, 12));
7179
let gitHead = 'unknown';
7280
try { gitHead = execFileSync('git', ['-C', REPO, 'rev-parse', '--short=10', 'HEAD'], { encoding: 'utf8' }).trim(); } catch { /* not a checkout */ }
@@ -124,6 +132,7 @@ if (webviewOrigin) { productConfiguration.webviewContentExternalBaseUrlTemplate
124132
if (extHostOrigin) { productConfiguration.webEndpointUrlTemplate = `${extHostOrigin}${prefix}/static`; }
125133
const config = webConfig({
126134
account,
135+
apiUrl,
127136
extensions,
128137
base: prefix,
129138
staticBase: `${prefix}/static`,
@@ -142,7 +151,7 @@ fs.writeFileSync(path.join(outDir, '_headers'), toHeadersFile(pol));
142151
fs.mkdirSync(path.join(outDir, 'deploy'), { recursive: true });
143152
fs.writeFileSync(path.join(outDir, 'deploy', 'nginx.conf'), toNginx(pol));
144153
const info = {
145-
id, levelcode: gitHead, builtAt: new Date().toISOString(), account,
154+
id, levelcode: gitHead, builtAt: new Date().toISOString(), account, apiUrl: apiUrl || '(the account origin)',
146155
webviewOrigin: webviewOrigin || '(Code-OSS default: *.vscode-cdn.net)',
147156
extHostOrigin: extHostOrigin || '(same origin as the editor)',
148157
extensions,

‎web/lib/fingerprint.mjs‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// What identifies a build. A release puts everything under /_/<id>/ and tells browsers it never changes, so the id has to
2+
// change whenever anything under it does: a file of the extension that was edited but not named here would be served
3+
// stale, from cache, for a year.
4+
import fs from 'node:fs';
5+
import path from 'node:path';
6+
7+
/**
8+
* Feed every file under `dir` — its path relative to `dir`, then its bytes — into `hash`, in a stable order.
9+
* Files and folders whose NAME is in `skip` are left out wherever they are.
10+
* @param {import('node:crypto').Hash} hash
11+
* @param {string} dir
12+
* @param {{ skip?: string[] }} [opts]
13+
* @returns {import('node:crypto').Hash}
14+
*/
15+
export function hashTree(hash, dir, { skip = [] } = {}) {
16+
const skipped = new Set(skip);
17+
const walk = (d, rel) => {
18+
const entries = fs.readdirSync(d, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
19+
for (const e of entries) {
20+
if (skipped.has(e.name)) { continue; }
21+
const full = path.join(d, e.name);
22+
const r = rel ? rel + '/' + e.name : e.name;
23+
if (e.isDirectory()) { walk(full, r); }
24+
else if (e.isFile()) { hash.update('\0' + r + '\0'); hash.update(fs.readFileSync(full)); }
25+
}
26+
};
27+
walk(dir, '');
28+
return hash;
29+
}

‎web/test/unit/fingerprint.test.js‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* web/lib/fingerprint.mjs — run: node web/test/unit/fingerprint.test.js
3+
*
4+
* A release is cached for a year under /_/<id>/. The id is a hash of what goes into it, so a changed file that the
5+
* hash did not see is a stale file for a year. These tests are about what the hash sees.
6+
*--------------------------------------------------------------------------------------------*/
7+
// @ts-check
8+
'use strict';
9+
10+
const assert = require('assert');
11+
const fs = require('fs');
12+
const os = require('os');
13+
const path = require('path');
14+
const { createHash } = require('crypto');
15+
const { pathToFileURL } = require('url');
16+
17+
let n = 0;
18+
async function test(name, fn) { await fn(); n++; console.log(' ok - ' + name); }
19+
const write = (root, rel, text) => { const f = path.join(root, rel); fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, text); };
20+
21+
(async () => {
22+
const { hashTree } = await import(pathToFileURL(path.join(__dirname, '..', '..', 'lib', 'fingerprint.mjs')).href);
23+
const digest = (dir, opts) => hashTree(createHash('sha256'), dir, opts).digest('hex');
24+
const make = () => { const d = fs.mkdtempSync(path.join(os.tmpdir(), 'lc-fp-')); write(d, 'a.js', 'A'); write(d, 'sub/b.json', '{"b":1}'); write(d, 'sub/deep/c.txt', 'C'); return d; };
25+
26+
await test('the same files give the same hash, wherever they are and in whatever order they were written', () => {
27+
const one = make();
28+
const two = fs.mkdtempSync(path.join(os.tmpdir(), 'lc-fp-'));
29+
write(two, 'sub/deep/c.txt', 'C'); write(two, 'sub/b.json', '{"b":1}'); write(two, 'a.js', 'A');
30+
assert.strictEqual(digest(one), digest(two));
31+
});
32+
await test('a changed byte in any file, at any depth, changes it', () => {
33+
const d = make();
34+
const before = digest(d);
35+
for (const rel of ['a.js', 'sub/b.json', 'sub/deep/c.txt']) {
36+
const original = fs.readFileSync(path.join(d, rel), 'utf8');
37+
fs.writeFileSync(path.join(d, rel), original + 'x');
38+
assert.notStrictEqual(digest(d), before, rel + ' was not seen');
39+
fs.writeFileSync(path.join(d, rel), original);
40+
}
41+
assert.strictEqual(digest(d), before);
42+
});
43+
await test('a new file, a removed file and a renamed file change it', () => {
44+
const d = make();
45+
const before = digest(d);
46+
write(d, 'new.js', '');
47+
assert.notStrictEqual(digest(d), before);
48+
fs.rmSync(path.join(d, 'new.js'));
49+
assert.strictEqual(digest(d), before);
50+
fs.renameSync(path.join(d, 'a.js'), path.join(d, 'z.js'));
51+
assert.notStrictEqual(digest(d), before, 'the same bytes under another name are another build');
52+
});
53+
await test('where the bytes end and the name begins cannot be confused', () => {
54+
const one = fs.mkdtempSync(path.join(os.tmpdir(), 'lc-fp-')); write(one, 'ab', 'c');
55+
const two = fs.mkdtempSync(path.join(os.tmpdir(), 'lc-fp-')); write(two, 'a', 'bc');
56+
assert.notStrictEqual(digest(one), digest(two));
57+
});
58+
await test('skipped names are left out wherever they are, and nothing else is', () => {
59+
const d = make();
60+
write(d, 'test/x.test.js', 't'); write(d, 'sub/node_modules/y.js', 'y');
61+
const without = digest(d, { skip: ['test', 'node_modules'] });
62+
write(d, 'test/x.test.js', 'changed'); write(d, 'sub/node_modules/y.js', 'changed');
63+
assert.strictEqual(digest(d, { skip: ['test', 'node_modules'] }), without);
64+
assert.notStrictEqual(digest(d), without);
65+
});
66+
console.log(`\n${n} tests passed`);
67+
})().catch((e) => { console.error(e); process.exit(1); });

0 commit comments

Comments
 (0)