Skip to content

Commit 044d71a

Browse files
committed
feat(web): scripts/build-web.mjs — one static directory, with the host's HTTP policy
Builds Code-OSS's web client (or takes a prebuilt one), bundles levelcode-ai for the web worker extension host, stages the scratch workspace and the declarative built-ins, and lays it all out under a build-addressed prefix (/_/<id>) so it caches for a year. index.html is rendered with its configuration baked in. _headers and an nginx fragment carry the same CSP and cache policy; web/serve.mjs --dist serves a release with them, and the end-to-end check runs under that policy. Findings that shaped it, each checked in a real browser: - the checkout's web packaging emits only the ~20 extensions with a browser entry, so every declarative one (grammars, themes, icons) is staged here and loaded as an additional built-in; - a marketplace is on by default and would install third-party code into the page's origin: the release sets extensionsGallery to null; - each webview needs its own subdomain, so a self-hosted webview origin has to be a wildcard; the default is Code-OSS's CDN at this build's own commit (pinned insider commit is a service worker version behind); - the Copilot status item is created before the web-only hide rule runs: hidden by CSS; - the scratch workspace is /scratch, so the window and Explorer are not called "/".
1 parent 29ceb87 commit 044d71a

15 files changed

Lines changed: 453 additions & 34 deletions

File tree

‎.gitignore‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,8 @@ node_modules/
3838
.DS_Store
3939
*.log
4040
out/
41+
42+
# LevelCode in your browser: the release directory scripts/build-web.mjs writes, and the bundle's
43+
# generated assets module (web/ai-extension/build.mjs removes it after bundling).
44+
/dist-web/
45+
/web/ai-extension/assets.generated.js

‎extensions/levelcode-ai/providers/anthropic.js‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ const { readLines } = require('./sse');
1313

1414
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
1515
function anthropicHeaders(apiKey) {
16-
return { 'content-type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' };
16+
const h = { 'content-type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' };
17+
// From a browser the call is cross-origin, and Anthropic answers CORS only to a request that says it
18+
// knows the key is being used from a page (web/ai-extension/entry.js sets the flag; the desktop never does).
19+
if (globalThis.__LEVELCODE_BROWSER_HOST__) { h['anthropic-dangerous-direct-browser-access'] = 'true'; }
20+
return h;
1721
}
1822

1923
/**

‎scripts/build-web.mjs‎

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
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));

‎web/boot.css‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,8 @@ body { background: #0b0d14; }
2424
.lc-boot-failed .lc-msg { opacity: 1; }
2525
@keyframes lc-slide { 0% { transform: translateX(-110%); } 100% { transform: translateX(260%); } }
2626
@media (prefers-reduced-motion: reduce) { .lc-bar i { animation: none; width: 100%; opacity: .6; } }
27+
28+
/* LevelCode is the assistant here. Code-OSS still adds a Copilot status item to the status bar on the web
29+
(its own "hidden on the web" rule runs after the item is created); it opens a Copilot dashboard that
30+
is of no use without a Copilot account, so it is not shown. */
31+
#chat\.statusBarEntry { display: none !important; }

‎web/icon-192.png‎

30.8 KB
Loading

‎web/icon-512.png‎

156 KB
Loading

‎web/index.html‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
<meta name="theme-color" content="#0b0d14">
99
<meta name="description" content="LevelCode, the AI code editor, in your browser tab. Sign in with your LevelCode account and start.">
1010
<link rel="icon" type="image/svg+xml" href="/icon.svg">
11-
<link rel="stylesheet" href="/boot.css">
11+
<link rel="apple-touch-icon" href="/icon-192.png">
12+
<link rel="manifest" href="/manifest.webmanifest">
13+
<link rel="stylesheet" href="{{BASE}}/boot.css">
1214
<link rel="stylesheet" href="{{STATIC}}/out/vs/workbench/workbench.web.main.internal.css">
1315
<script type="application/json" id="levelcode-web-config">{{CONFIG}}</script>
1416
</head>
@@ -32,6 +34,6 @@
3234
</div>
3335
<noscript><p class="lc-msg">LevelCode needs JavaScript.</p></noscript>
3436
</div>
35-
<script type="module" src="/main.js"></script>
37+
<script type="module" src="{{BASE}}/main.js"></script>
3638
</body>
3739
</html>

‎web/lib/config.mjs‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,30 @@ export const WEB_DIR = path.dirname(path.dirname(fileURLToPath(import.meta.url))
1818
export function webConfig(o = {}) {
1919
const account = trimSlash(o.account || 'https://levelcode.ai');
2020
return {
21+
// Where the page's own scripts and styles, the Code-OSS web build, and the extensions are mounted.
22+
// A release puts all three under one build-addressed prefix (/_/<id>) so they can be cached for
23+
// a year; the development server mounts them at the root, /static and /extensions.
24+
base: o.base || '',
2125
staticBase: o.staticBase || '/static',
26+
extensionsBase: o.extensionsBase || '/extensions',
2227
callbackRoute: '/callback.html',
2328
extensions: o.extensions || ['levelcode-ai'],
24-
scratch: { scheme: 'levelcode-scratch', path: '/' },
29+
// The folder is /scratch, not the provider's root: a root named "/" is what the title bar and the
30+
// Explorer would show, and "scratch" is what it is.
31+
scratch: { scheme: 'levelcode-scratch', path: '/scratch' },
2532
trustedDomains: [account],
26-
productConfiguration: Object.assign({}, o.productConfiguration),
33+
productConfiguration: Object.assign({
34+
// The scratch workspace implements file and text search for its own scheme. Spelling the grant
35+
// out here (rather than relying on "built-in extensions may declare proposals") is the
36+
// sanctioned route and keeps the workbench from logging an error about it at start.
37+
extensionEnabledApiProposals: { 'levelcode.levelcode-web': ['fileSearchProvider', 'textSearchProvider'] },
38+
}, o.productConfiguration),
2739
configurationDefaults: Object.assign({
2840
'levelcode.cloud.endpoint': account,
41+
// LevelCode is the assistant here; the stock Copilot surfaces (status item, chat view, command
42+
// center) are for an account this editor does not use. The desktop app sets the same.
43+
'chat.disableAIFeatures': true,
44+
'chat.commandCenter.enabled': false,
2945
'workbench.startupEditor': 'none',
3046
'telemetry.telemetryLevel': 'off',
3147
'update.mode': 'none',
@@ -40,6 +56,7 @@ export function webConfig(o = {}) {
4056
export function renderIndex(config, htmlPath = path.join(WEB_DIR, 'index.html')) {
4157
const json = JSON.stringify(config).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');
4258
return fs.readFileSync(htmlPath, 'utf8')
59+
.replaceAll('{{BASE}}', config.base)
4360
.replaceAll('{{STATIC}}', config.staticBase)
4461
.replace('{{CONFIG}}', () => json);
4562
}

‎web/lib/extensions.mjs‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Which of Code-OSS's built-in extensions the browser edition carries, and how they are staged.
2+
//
3+
// The web build bakes into its bundle only the extensions that have a browser entry point (about
4+
// twenty: JSON, CSS and HTML language features, Markdown, the TypeScript language service...). The
5+
// declarative ones that give an editor its colour — TextMate grammars, language configurations,
6+
// themes, file icons — are not packaged for the web by this checkout's tooling, so without them a
7+
// file is plain text. Declaring them is all they need, so they are staged beside LevelCode's own
8+
// extensions and loaded as additional built-ins (the workbench reads each package.json).
9+
import fs from 'node:fs';
10+
import path from 'node:path';
11+
12+
/** Directories never copied: tests, sources, and package managers' droppings. */
13+
const SKIP = new Set(['node_modules', 'test', 'tests', 'src', '.vscode', '.git', 'build']);
14+
/** Extensions that exist in a Code-OSS checkout for its own development. */
15+
const DEV_ONLY = new Set(['vscode-api-tests', 'vscode-colorize-tests', 'vscode-colorize-perf-tests', 'vscode-test-resolver', 'copilot']);
16+
17+
/** Mirrors build/lib/extensions.ts isWebExtension() for a manifest with neither main nor browser. */
18+
export function isDeclarativeWebExtension(manifest) {
19+
if (manifest.main || manifest.browser) { return false; }
20+
const c = manifest.contributes || {};
21+
for (const id of ['debuggers', 'terminal', 'typescriptServerPlugins']) { if (Object.prototype.hasOwnProperty.call(c, id)) { return false; } }
22+
return true;
23+
}
24+
25+
/**
26+
* @param {string} extensionsDir Code-OSS's extensions/ directory
27+
* @param {string} outDir where to stage
28+
* @param {{ exclude?: Iterable<string> }} [opts] names to leave out (already baked into the bundle)
29+
* @returns {string[]} the staged extension names, sorted
30+
*/
31+
export function stageDeclarativeExtensions(extensionsDir, outDir, opts = {}) {
32+
const exclude = new Set(opts.exclude || []);
33+
const staged = [];
34+
for (const name of fs.readdirSync(extensionsDir).sort()) {
35+
if (exclude.has(name) || DEV_ONLY.has(name)) { continue; }
36+
const dir = path.join(extensionsDir, name);
37+
const pkgPath = path.join(dir, 'package.json');
38+
if (!fs.statSync(dir).isDirectory() || !fs.existsSync(pkgPath)) { continue; }
39+
let manifest;
40+
try { manifest = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); } catch { continue; }
41+
if (!isDeclarativeWebExtension(manifest)) { continue; }
42+
copyExtension(dir, path.join(outDir, name));
43+
// The workbench asks every extension for a package.nls.json; an absent one is a 404 in the console.
44+
const nls = path.join(outDir, name, 'package.nls.json');
45+
if (!fs.existsSync(nls)) { fs.writeFileSync(nls, '{}\n'); }
46+
staged.push(name);
47+
}
48+
return staged;
49+
}
50+
51+
/** Copy an extension directory without its development files and its translations (English ships). */
52+
export function copyExtension(from, to) {
53+
fs.mkdirSync(to, { recursive: true });
54+
for (const e of fs.readdirSync(from, { withFileTypes: true })) {
55+
if (SKIP.has(e.name)) { continue; }
56+
if (/^package\.nls\..+\.json$/.test(e.name)) { continue; } // localisations: the build is English-only
57+
const src = path.join(from, e.name);
58+
const dst = path.join(to, e.name);
59+
if (e.isDirectory()) { copyExtension(src, dst); }
60+
else if (e.isFile()) { fs.copyFileSync(src, dst); }
61+
}
62+
}

0 commit comments

Comments
 (0)