|
| 1 | +#!/usr/bin/env node |
| 2 | +// Build LevelCode in your browser: one static directory, dist-web/, that any static host can serve. |
| 3 | +// |
| 4 | +// node scripts/build-web.mjs [--vscode vscode] [--out dist-web] [--account https://levelcode.ai] |
| 5 | +// [--webview-origin 'https://{{uuid}}.view.example.com'] |
| 6 | +// [--ext-host-origin 'https://{{uuid}}.ext.example.com'] |
| 7 | +// [--static <prebuilt vscode-web dir>] [--id <build id>] |
| 8 | +// |
| 9 | +// What it does, in order (docs/WEB.md has the reasoning): |
| 10 | +// 1. builds Code-OSS's web client from the bootstrapped checkout (gulp vscode-web-min-ci, about a |
| 11 | +// minute), or takes a prebuilt one with --static; |
| 12 | +// 2. bundles levelcode-ai for the web worker extension host (web/ai-extension), copies the scratch |
| 13 | +// workspace extension (web/workspace) and every declarative built-in (grammars, themes, icons); |
| 14 | +// 3. lays everything out under a build-addressed prefix (/_/<id>/...), renders index.html with the |
| 15 | +// configuration baked in, and writes the HTTP policy for the host (_headers, nginx). |
| 16 | +// |
| 17 | +// It changes nothing outside --out. It does not deploy. |
| 18 | +import { spawnSync, execFileSync } from 'node:child_process'; |
| 19 | +import fs from 'node:fs'; |
| 20 | +import path from 'node:path'; |
| 21 | +import { createHash } from 'node:crypto'; |
| 22 | +import { fileURLToPath } from 'node:url'; |
| 23 | +import { webConfig, renderIndex } from '../web/lib/config.mjs'; |
| 24 | +import { stageDeclarativeExtensions, copyExtension } from '../web/lib/extensions.mjs'; |
| 25 | +import { policy, toHeadersFile, toNginx } from '../web/lib/headers.mjs'; |
| 26 | + |
| 27 | +const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); |
| 28 | +const WEB = path.join(REPO, 'web'); |
| 29 | + |
| 30 | +const args = Object.fromEntries(process.argv.slice(2).reduce((a, v, i, all) => { |
| 31 | + if (v.startsWith('--')) { a.push([v.slice(2), all[i + 1] && !all[i + 1].startsWith('--') ? all[i + 1] : 'true']); } |
| 32 | + return a; |
| 33 | +}, [])); |
| 34 | +const vscodeDir = path.resolve(REPO, args.vscode || 'vscode'); |
| 35 | +const outDir = path.resolve(REPO, args.out || 'dist-web'); |
| 36 | +const account = String(args.account || process.env.LEVELCODE_WEB_ACCOUNT || 'https://levelcode.ai').replace(/\/+$/, ''); |
| 37 | +const webviewOrigin = args['webview-origin'] || process.env.LEVELCODE_WEB_WEBVIEW_ORIGIN || ''; |
| 38 | +const extHostOrigin = args['ext-host-origin'] || process.env.LEVELCODE_WEB_EXTHOST_ORIGIN || ''; |
| 39 | + |
| 40 | +const log = (m) => console.log('[build-web] ' + m); |
| 41 | +const die = (m) => { console.error('[build-web] ' + m); process.exit(1); }; |
| 42 | +const sizeOf = (p) => { let n = 0; const walk = (d) => { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const f = path.join(d, e.name); e.isDirectory() ? walk(f) : (n += fs.statSync(f).size); } }; walk(p); return n; }; |
| 43 | +const mb = (n) => (n / 1048576).toFixed(1) + ' MB'; |
| 44 | + |
| 45 | +/* ----- 1. Code-OSS's web client ---------------------------------------------------------------- */ |
| 46 | +let staticDir; |
| 47 | +if (args.static) { |
| 48 | + staticDir = path.resolve(args.static); |
| 49 | + log('using the prebuilt web client at ' + staticDir); |
| 50 | +} else { |
| 51 | + if (!fs.existsSync(path.join(vscodeDir, 'node_modules'))) { die('no bootstrapped checkout at ' + vscodeDir + ' — run scripts/bootstrap.sh first'); } |
| 52 | + const product = JSON.parse(fs.readFileSync(path.join(vscodeDir, 'product.json'), 'utf8')); |
| 53 | + if (product.nameShort !== 'LevelCode') { die('the checkout is not branded (product.json nameShort is "' + product.nameShort + '") — run scripts/apply-branding.mjs'); } |
| 54 | + log('building the web client (gulp vscode-web-min-ci) in ' + vscodeDir); |
| 55 | + const r = spawnSync('npm', ['run', 'gulp', '--', 'vscode-web-min-ci'], { cwd: vscodeDir, stdio: 'inherit', env: { ...process.env, NODE_OPTIONS: '--max-old-space-size=8192' } }); |
| 56 | + if (r.status !== 0) { die('the web client build failed'); } |
| 57 | + staticDir = path.resolve(vscodeDir, '..', 'vscode-web'); |
| 58 | +} |
| 59 | +for (const must of ['out/vs/workbench/workbench.web.main.internal.js', 'out/vs/workbench/workbench.web.main.internal.css', 'out/nls.messages.js', |
| 60 | + 'out/vs/workbench/services/extensions/worker/webWorkerExtensionHostIframe.html', 'out/vs/workbench/contrib/webview/browser/pre/index.html']) { |
| 61 | + if (!fs.existsSync(path.join(staticDir, must))) { die('the web client is missing ' + must); } |
| 62 | +} |
| 63 | + |
| 64 | +/* ----- 2. identity of this build ---------------------------------------------------------------- */ |
| 65 | +const fingerprint = createHash('sha256'); |
| 66 | +fingerprint.update(fs.readFileSync(path.join(staticDir, 'out/vs/workbench/workbench.web.main.internal.js'))); |
| 67 | +for (const f of ['main.js', 'boot.css', 'index.html']) { fingerprint.update(fs.readFileSync(path.join(WEB, f))); } |
| 68 | +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))); } } |
| 70 | +const id = String(args.id || fingerprint.digest('hex').slice(0, 12)); |
| 71 | +let gitHead = 'unknown'; |
| 72 | +try { gitHead = execFileSync('git', ['-C', REPO, 'rev-parse', '--short=10', 'HEAD'], { encoding: 'utf8' }).trim(); } catch { /* not a checkout */ } |
| 73 | +log('build id ' + id + ' (levelcode ' + gitHead + ')'); |
| 74 | + |
| 75 | +/* ----- 3. assemble ------------------------------------------------------------------------------- */ |
| 76 | +fs.rmSync(outDir, { recursive: true, force: true }); |
| 77 | +const prefix = `/_/${id}`; |
| 78 | +const root = path.join(outDir, '_', id); |
| 79 | +fs.mkdirSync(root, { recursive: true }); |
| 80 | + |
| 81 | +log('copying the web client'); |
| 82 | +fs.cpSync(staticDir, path.join(root, 'static'), { recursive: true, filter: (src) => !/\.(js|css)\.map$/.test(src) }); |
| 83 | +const baked = new Set(fs.existsSync(path.join(staticDir, 'extensions')) ? fs.readdirSync(path.join(staticDir, 'extensions')) : []); |
| 84 | + |
| 85 | +const extOut = path.join(root, 'extensions'); |
| 86 | +fs.mkdirSync(extOut, { recursive: true }); |
| 87 | + |
| 88 | +log('bundling levelcode-ai for the web worker extension host'); |
| 89 | +const r = spawnSync(process.execPath, [path.join(WEB, 'ai-extension', 'build.mjs'), '--out', extOut, '--vscode', vscodeDir, '--minify'], { stdio: 'inherit' }); |
| 90 | +if (r.status !== 0) { die('the levelcode-ai browser build failed'); } |
| 91 | + |
| 92 | +log('staging the scratch workspace and the declarative built-ins'); |
| 93 | +const scratchOut = path.join(extOut, 'levelcode-web'); |
| 94 | +copyExtension(path.join(WEB, 'workspace'), scratchOut); |
| 95 | +fs.writeFileSync(path.join(scratchOut, 'package.nls.json'), '{}\n'); |
| 96 | +const extensionsSource = path.join(vscodeDir, 'extensions'); |
| 97 | +if (!fs.existsSync(extensionsSource)) { die('no extensions/ in ' + vscodeDir + ' to take the declarative built-ins from'); } |
| 98 | +const declarative = stageDeclarativeExtensions(extensionsSource, extOut, { exclude: [...baked, 'levelcode-ai', 'levelcode-web'] }); |
| 99 | +// A checkout that has not had LevelCode's extensions copied in still builds; the themes are then absent. |
| 100 | +if (!declarative.includes('levelcode-themes')) { |
| 101 | + const themes = path.join(REPO, 'extensions', 'levelcode-themes'); |
| 102 | + if (fs.existsSync(themes)) { copyExtension(themes, path.join(extOut, 'levelcode-themes')); declarative.push('levelcode-themes'); } |
| 103 | +} |
| 104 | +const extensions = ['levelcode-ai', 'levelcode-web', ...declarative.sort()]; |
| 105 | +log(`${extensions.length} extensions: levelcode-ai, levelcode-web and ${declarative.length} declarative (${declarative.slice(0, 6).join(', ')}, ...)`); |
| 106 | + |
| 107 | +log('writing the page'); |
| 108 | +// main.js and boot.css are addressed by the build id like everything else; the page names them. |
| 109 | +for (const f of ['main.js', 'boot.css']) { fs.copyFileSync(path.join(WEB, f), path.join(root, f)); } |
| 110 | + |
| 111 | +const productConfiguration = { |
| 112 | + // No gallery: nothing is installed from the Internet into the page's own origin. |
| 113 | + extensionsGallery: null, |
| 114 | +}; |
| 115 | +if (!webviewOrigin) { |
| 116 | + // Code-OSS publishes each stable commit's webview host pages to its CDN. product.json pins an older insider commit, |
| 117 | + // whose service worker is a version behind this build's; the template below names this build's own commit. |
| 118 | + productConfiguration.webviewContentExternalBaseUrlTemplate = 'https://{{uuid}}.vscode-cdn.net/{{quality}}/{{commit}}/out/vs/workbench/contrib/webview/browser/pre/'; |
| 119 | + console.warn('[build-web] note: no --webview-origin, so the chat panel loads its host page from Code-OSS\'s CDN (*.vscode-cdn.net, this build\'s own commit).\n' |
| 120 | + + ' That works with no setup. To serve it yourself give a wildcard origin, e.g. --webview-origin \'https://{{uuid}}.view.example.com\'\n' |
| 121 | + + ' (each webview needs its own subdomain; see docs/WEB.md).'); |
| 122 | +} |
| 123 | +if (webviewOrigin) { productConfiguration.webviewContentExternalBaseUrlTemplate = `${webviewOrigin}${prefix}/static/out/vs/workbench/contrib/webview/browser/pre/`; } |
| 124 | +if (extHostOrigin) { productConfiguration.webEndpointUrlTemplate = `${extHostOrigin}${prefix}/static`; } |
| 125 | +const config = webConfig({ |
| 126 | + account, |
| 127 | + extensions, |
| 128 | + base: prefix, |
| 129 | + staticBase: `${prefix}/static`, |
| 130 | + extensionsBase: `${prefix}/extensions`, |
| 131 | + productConfiguration, |
| 132 | +}); |
| 133 | +fs.writeFileSync(path.join(outDir, 'index.html'), renderIndex(config)); |
| 134 | +for (const f of ['callback.html', 'callback.js', 'callback.css', 'icon.svg', 'icon-192.png', 'icon-512.png', 'manifest.webmanifest']) { |
| 135 | + fs.copyFileSync(path.join(WEB, f), path.join(outDir, f)); |
| 136 | +} |
| 137 | + |
| 138 | +/* ----- 4. the host's half ------------------------------------------------------------------------ */ |
| 139 | +const toPattern = (o) => (o ? o.replace('{{uuid}}', '*') : ''); |
| 140 | +const pol = policy({ account, webviewOrigin: toPattern(webviewOrigin), extHostOrigin: toPattern(extHostOrigin), base: '/_' }); |
| 141 | +fs.writeFileSync(path.join(outDir, '_headers'), toHeadersFile(pol)); |
| 142 | +fs.mkdirSync(path.join(outDir, 'deploy'), { recursive: true }); |
| 143 | +fs.writeFileSync(path.join(outDir, 'deploy', 'nginx.conf'), toNginx(pol)); |
| 144 | +const info = { |
| 145 | + id, levelcode: gitHead, builtAt: new Date().toISOString(), account, |
| 146 | + webviewOrigin: webviewOrigin || '(Code-OSS default: *.vscode-cdn.net)', |
| 147 | + extHostOrigin: extHostOrigin || '(same origin as the editor)', |
| 148 | + extensions, |
| 149 | +}; |
| 150 | +fs.writeFileSync(path.join(outDir, 'build.json'), JSON.stringify(info, null, 2) + '\n'); |
| 151 | + |
| 152 | +/* ----- 5. check what was written ----------------------------------------------------------------- */ |
| 153 | +const html = fs.readFileSync(path.join(outDir, 'index.html'), 'utf8'); |
| 154 | +const m = html.match(/<script type="application\/json" id="levelcode-web-config">([\s\S]*?)<\/script>/); |
| 155 | +if (!m) { die('index.html has no configuration'); } |
| 156 | +JSON.parse(m[1]); |
| 157 | +if (/\{\{[A-Z]+\}\}/.test(html)) { die('index.html still has an unfilled placeholder'); } |
| 158 | +for (const must of [`${prefix}/main.js`, `${prefix}/boot.css`, `${prefix}/static/out/nls.messages.js`, `${prefix}/extensions/levelcode-ai/extension.web.js`, |
| 159 | + `${prefix}/extensions/levelcode-ai/package.json`, `${prefix}/extensions/levelcode-web/extension.js`]) { |
| 160 | + if (!fs.existsSync(path.join(outDir, must))) { die('missing ' + must); } |
| 161 | +} |
| 162 | +log(`done: ${outDir} ${mb(sizeOf(outDir))} (client ${mb(sizeOf(path.join(root, 'static')))}, extensions ${mb(sizeOf(extOut))})`); |
| 163 | +log('serve it with: node web/serve.mjs --dist ' + path.relative(process.cwd(), outDir)); |
0 commit comments