Skip to content

Commit f2fc8bc

Browse files
kinlaneclaude
andcommitted
Run Locally: ship a self-contained single-file build (works via file://)
External ES-module scripts can't load over file:// (CORS/null origin), so the unzipped app never booted on double-click. The "Run Locally" download is now a single self-contained index.html: - New SINGLEFILE=1 build target (vite-plugin-singlefile) -> dist-local/, inlining all JS + CSS into one file; Monaco workers inlined via ?worker&inline as blob workers; worker.format=iife for file:// compatibility. - zip-dist packages dist-local/ into dist/api-validator.zip; RUN-LOCALLY.txt now just says "open index.html". - getWorker wrapped in try/catch with a no-op fallback so a locked-down file:// context can never break app boot (editor + Spectral lint still work). The live site keeps the normal multi-chunk dist/ build. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ac909bb commit f2fc8bc

6 files changed

Lines changed: 161 additions & 80 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ node_modules/
22
dist/
33
.DS_Store
44
*.log
5+
dist-local/

package-lock.json

Lines changed: 102 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"scripts": {
99
"compile-rules": "node tools/compile-rules.mjs",
1010
"dev": "vite",
11-
"build": "vite build && node tools/zip-dist.mjs",
11+
"build": "vite build && SINGLEFILE=1 vite build && node tools/zip-dist.mjs",
1212
"preview": "vite preview",
1313
"check:rulesets": "tsx tools/check-rulesets.ts",
1414
"check": "npm run check:rulesets"
@@ -23,9 +23,10 @@
2323
"yaml": "^2.6.1"
2424
},
2525
"devDependencies": {
26+
"tsx": "^4.22.4",
27+
"typescript": "^5.7.2",
2628
"vite": "^5.4.11",
2729
"vite-plugin-node-polyfills": "^0.22.0",
28-
"typescript": "^5.7.2",
29-
"tsx": "^4.22.4"
30+
"vite-plugin-singlefile": "^2.3.3"
3031
}
3132
}

src/main.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import * as monaco from 'monaco-editor';
2-
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
3-
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
2+
// Inline the workers (base64 blob) so the single-file "Run Locally" build has no
3+
// external worker files — it must run from a double-clicked file:// index.html.
4+
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker&inline';
5+
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker&inline';
46
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
57
import { lint, builtinDescriptions, builtinRulesByFormat, builtinRecommendedByFormat } from './spectral';
68
import { ARTIFACTS, SAMPLES, artifactById, type ArtifactType } from './artifacts';
@@ -20,7 +22,14 @@ const BUILTIN_META = builtinMetaRaw as Record<string, { format: string; spec: st
2022

2123
self.MonacoEnvironment = {
2224
getWorker(_id, label) {
23-
return label === 'json' ? new JsonWorker() : new EditorWorker();
25+
try {
26+
return label === 'json' ? new JsonWorker() : new EditorWorker();
27+
} catch {
28+
// A locked-down context (e.g. a double-clicked file:// page) may refuse to
29+
// spawn workers. Fall back to a no-op worker so the editor still boots —
30+
// background language services degrade, but editing + Spectral linting work.
31+
return new Worker(URL.createObjectURL(new Blob([''], { type: 'text/javascript' })));
32+
}
2433
},
2534
};
2635

tools/zip-dist.mjs

Lines changed: 21 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,87 +1,43 @@
1-
// Post-build step: package the built site (dist/) into dist/api-validator.zip so
2-
// the "Run Locally" button can hand a user the whole app as a static bundle they
3-
// serve themselves. Runs after `vite build`; uses the system `zip` (present on
4-
// macOS and the ubuntu CI runner).
1+
// Post-build step: package the SINGLE-FILE build (dist-local/, a self-contained
2+
// index.html produced by `SINGLEFILE=1 vite build`) into dist/api-validator.zip,
3+
// which the live site serves for the "Run Locally" button. Because everything —
4+
// JS, CSS, and the Monaco workers — is inlined, a user can just unzip and open
5+
// index.html directly (file://); no server or external fetches required.
56
//
6-
// The build uses a relative base ('./'), so assets resolve from any folder. The
7-
// app still needs a real HTTP server — browsers block ES modules and web workers
8-
// over file:// — so we include double-clickable launchers + a how-to.
7+
// Uses the system `zip` (present on macOS and the ubuntu CI runner). Never fails
8+
// the build over the optional archive — the site still deploys.
99
import { execSync } from 'node:child_process';
10-
import { existsSync, writeFileSync, chmodSync } from 'node:fs';
10+
import { existsSync, writeFileSync } from 'node:fs';
1111

12+
const SRC = 'dist-local'; // single-file build output
13+
const OUTDIR = 'dist'; // where the servable zip must land
1214
const OUT = 'api-validator.zip';
1315

14-
if (!existsSync('dist')) {
15-
console.error('zip-dist: dist/ not found run `vite build` first.');
16-
process.exit(1);
16+
if (!existsSync(SRC)) {
17+
console.warn(`zip-dist: ${SRC}/ not found (run \`SINGLEFILE=1 vite build\`) — skipping zip.`);
18+
process.exit(0);
1719
}
1820

19-
// How-to that travels inside the archive.
21+
// Short how-to that travels inside the archive.
2022
writeFileSync(
21-
'dist/RUN-LOCALLY.txt',
23+
`${SRC}/RUN-LOCALLY.txt`,
2224
[
2325
'API Validator — run locally',
2426
'',
25-
'This is the fully built, static app. It runs entirely in your browser; there',
26-
'is no backend and no keys are required.',
27+
'index.html is fully self-contained (all JavaScript, CSS, and workers are',
28+
'inlined). Just unzip and open index.html in your browser — double-click it,',
29+
'or drag it into a browser window. No server, no internet, no keys.',
2730
'',
28-
'IMPORTANT: you must serve the folder over http — you cannot just double-click',
29-
'index.html. Browsers block JavaScript modules and web workers over file://,',
30-
'so the page would load but the app would not run.',
31-
'',
32-
'Easiest: double-click the launcher for your OS (it starts a local server and',
33-
'opens your browser):',
34-
' • macOS: start.command',
35-
' • Windows: start.bat',
36-
' • Linux: start.sh (or run it in a terminal)',
37-
'',
38-
'Or do it by hand, from inside this folder:',
39-
' python3 -m http.server 8000 # then open http://localhost:8000/',
40-
' # or: npx serve .',
31+
'Everything runs locally in your browser.',
4132
'',
4233
'Source: https://github.com/api-commons/api-validator',
4334
'',
4435
].join('\n'),
4536
);
4637

47-
// macOS / Linux launcher — serve this folder and open the browser.
48-
writeFileSync(
49-
'dist/start.command',
50-
[
51-
'#!/bin/bash',
52-
'cd "$(dirname "$0")"',
53-
'PORT=8000',
54-
'URL="http://localhost:$PORT/"',
55-
'echo "Serving API Validator at $URL (press Ctrl+C to stop)"',
56-
'( sleep 1; (command -v open >/dev/null && open "$URL") || (command -v xdg-open >/dev/null && xdg-open "$URL") ) &',
57-
'python3 -m http.server "$PORT" 2>/dev/null || python -m http.server "$PORT"',
58-
'',
59-
].join('\n'),
60-
);
61-
// A plain .sh alias for Linux users who prefer it.
62-
writeFileSync('dist/start.sh', 'exec "$(dirname "$0")/start.command"\n');
63-
chmodSync('dist/start.command', 0o755);
64-
chmodSync('dist/start.sh', 0o755);
65-
66-
// Windows launcher.
67-
writeFileSync(
68-
'dist/start.bat',
69-
[
70-
'@echo off',
71-
'cd /d "%~dp0"',
72-
'set PORT=8000',
73-
'start "" "http://localhost:%PORT%/"',
74-
'py -m http.server %PORT% 2>nul || python -m http.server %PORT%',
75-
'',
76-
].join('\r\n'),
77-
);
78-
79-
// Build the archive from inside dist/ (so paths are relative), excluding any
80-
// pre-existing zip. -X drops extra macOS metadata for a clean cross-platform zip.
81-
// Never fail the build over the optional archive — the site still deploys.
8238
try {
83-
execSync(`cd dist && rm -f ${OUT} && zip -r -q -X ${OUT} . -x '*.zip'`, { stdio: 'inherit' });
84-
console.log(`zip-dist: wrote dist/${OUT}`);
39+
execSync(`rm -f ${OUTDIR}/${OUT} && cd ${SRC} && zip -r -q -X ../${OUTDIR}/${OUT} . -x '*.zip'`, { stdio: 'inherit' });
40+
console.log(`zip-dist: wrote ${OUTDIR}/${OUT} from ${SRC}/`);
8541
} catch (e) {
8642
console.warn(`zip-dist: could not create ${OUT} (is \`zip\` installed?) — skipping. ${e?.message || e}`);
8743
}

vite.config.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import { defineConfig } from 'vite';
22
import { nodePolyfills } from 'vite-plugin-node-polyfills';
3+
import { viteSingleFile } from 'vite-plugin-singlefile';
4+
5+
// Two build targets:
6+
// default -> dist/ multi-chunk site for validator.apicommons.org
7+
// SINGLEFILE=1 -> dist-local/ ONE self-contained index.html for the
8+
// "Run Locally" download, so a double-clicked file:// page
9+
// works with no external module/worker fetches (which the
10+
// browser blocks over file://). Workers are inlined via
11+
// ?worker&inline in main.ts.
12+
const singleFile = process.env.SINGLEFILE === '1';
313

4-
// Static SPA. Use a RELATIVE base ('./') so the built assets resolve no matter
5-
// where the app is served from — the custom domain root (validator.apicommons.org)
6-
// AND an unzipped "Run Locally" copy served from any folder.
714
// Strip the `crossorigin` attribute Vite stamps on the injected <link>/<script>
8-
// tags. On the live (same-origin) site it's a harmless no-op, but when the
9-
// unzipped "Run Locally" copy is opened over file://, a crossorigin stylesheet is
10-
// a CORS request against a null origin with no CORS headers — so the browser
15+
// tags. On the live (same-origin) site it's a harmless no-op, but over file:// a
16+
// crossorigin stylesheet is a CORS request against a null origin — the browser
1117
// refuses to apply the CSS. Removing it lets the CSS load from file:// too.
1218
const stripCrossorigin = {
1319
name: 'strip-crossorigin',
@@ -25,7 +31,14 @@ export default defineConfig({
2531
globals: { process: true, Buffer: true },
2632
}),
2733
stripCrossorigin,
34+
...(singleFile ? [viteSingleFile()] : []),
2835
],
29-
worker: { format: 'es' },
30-
build: { target: 'es2020', sourcemap: false },
36+
// Classic (iife) workers inline as blob URLs that run over file://; ES-module
37+
// blob workers can be blocked there.
38+
worker: { format: 'iife' },
39+
build: {
40+
target: 'es2020',
41+
sourcemap: false,
42+
outDir: singleFile ? 'dist-local' : 'dist',
43+
},
3144
});

0 commit comments

Comments
 (0)