Skip to content

Commit ba5f0cb

Browse files
iam-rohidclaude
andcommitted
feat: add GitHub Action to build and upload WebContainer snapshots to R2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e67c9b9 commit ba5f0cb

2 files changed

Lines changed: 270 additions & 0 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
name: Build WebContainer Snapshot
2+
3+
on:
4+
push:
5+
branches: [main]
6+
workflow_dispatch:
7+
8+
jobs:
9+
build:
10+
runs-on: ubuntu-latest
11+
12+
steps:
13+
- uses: actions/checkout@v4
14+
15+
- uses: actions/setup-node@v4
16+
with:
17+
node-version: "20"
18+
cache: "npm"
19+
20+
- name: Install dependencies
21+
run: npm ci
22+
23+
- name: Build snapshots
24+
run: node scripts/build-snapshot.mjs
25+
# GITHUB_SHA is provided automatically by the runner
26+
27+
- name: Upload snapshots to R2
28+
env:
29+
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
30+
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
31+
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
32+
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
33+
R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }}
34+
run: |
35+
source /tmp/wc-snapshots/snapshot.env
36+
ENDPOINT="https://${CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com"
37+
38+
echo "Uploading files-${SHORT_SHA}.json.gz…"
39+
aws s3 cp /tmp/wc-snapshots/files-${SHORT_SHA}.json.gz \
40+
s3://${R2_BUCKET_NAME}/snapshots/files-${SHORT_SHA}.json.gz \
41+
--endpoint-url "${ENDPOINT}" \
42+
--region auto \
43+
--content-type "application/json" \
44+
--content-encoding "gzip" \
45+
--cache-control "public, max-age=31536000, immutable"
46+
47+
echo "Uploading node_modules-${PACKAGE_HASH}.json.gz…"
48+
aws s3 cp /tmp/wc-snapshots/node_modules-${PACKAGE_HASH}.json.gz \
49+
s3://${R2_BUCKET_NAME}/snapshots/node_modules-${PACKAGE_HASH}.json.gz \
50+
--endpoint-url "${ENDPOINT}" \
51+
--region auto \
52+
--content-type "application/json" \
53+
--content-encoding "gzip" \
54+
--cache-control "public, max-age=31536000, immutable"
55+
56+
echo "Uploading latest.json…"
57+
cat > /tmp/wc-snapshots/latest.json << EOF
58+
{
59+
"commitSha": "${SHORT_SHA}",
60+
"packageHash": "${PACKAGE_HASH}",
61+
"filesUrl": "${R2_PUBLIC_URL}/snapshots/files-${SHORT_SHA}.json.gz",
62+
"nodeModulesUrl": "${R2_PUBLIC_URL}/snapshots/node_modules-${PACKAGE_HASH}.json.gz",
63+
"builtAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
64+
}
65+
EOF
66+
67+
aws s3 cp /tmp/wc-snapshots/latest.json \
68+
s3://${R2_BUCKET_NAME}/snapshots/latest.json \
69+
--endpoint-url "${ENDPOINT}" \
70+
--region auto \
71+
--content-type "application/json" \
72+
--cache-control "no-cache, no-store, must-revalidate"
73+
74+
echo "All snapshots uploaded."
75+
echo " files: ${R2_PUBLIC_URL}/snapshots/files-${SHORT_SHA}.json.gz"
76+
echo " node_modules: ${R2_PUBLIC_URL}/snapshots/node_modules-${PACKAGE_HASH}.json.gz"
77+
echo " latest: ${R2_PUBLIC_URL}/snapshots/latest.json"

scripts/build-snapshot.mjs

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Builds two WebContainer filesystem snapshots and writes them to /tmp/wc-snapshots/:
4+
* files-{commitSha}.json.gz — source files (excluding node_modules)
5+
* node_modules-{packageHash}.json.gz — pre-installed node_modules
6+
* snapshot.env — shell-sourceable vars for the upload step
7+
*
8+
* Snapshot JSON format (compatible with WebContainer mount() API):
9+
* Text files: { file: { contents: string } }
10+
* Binary files: { file: { contents: string (base64), binary: true } }
11+
* Symlinks: { symlink: { target: string } }
12+
* Directories: { directory: { ...children } }
13+
*/
14+
15+
import { readdir, readFile, lstat, readlink, mkdir, writeFile } from "fs/promises";
16+
import { join } from "path";
17+
import { createHash } from "crypto";
18+
import { gzip } from "zlib";
19+
import { promisify } from "util";
20+
21+
const gzipAsync = promisify(gzip);
22+
23+
const ROOT = process.cwd();
24+
const OUTPUT = "/tmp/wc-snapshots";
25+
26+
// ── Source file exclusions ────────────────────────────────────────────────────
27+
const EXCLUDE_SOURCE = new Set([
28+
"node_modules",
29+
".git",
30+
"build",
31+
"out",
32+
"dist",
33+
"scripts",
34+
".github",
35+
]);
36+
37+
// ── node_modules exclusions (reduce snapshot size) ───────────────────────────
38+
// Directory names to skip anywhere inside node_modules
39+
const EXCLUDE_NM_DIRS = new Set([
40+
"test",
41+
"tests",
42+
"__tests__",
43+
"spec",
44+
"specs",
45+
"__mocks__",
46+
"docs",
47+
"doc",
48+
"documentation",
49+
"example",
50+
"examples",
51+
"demo",
52+
"demos",
53+
"benchmark",
54+
"benchmarks",
55+
"bench",
56+
".github",
57+
".vscode",
58+
]);
59+
60+
// File extensions to skip inside node_modules
61+
const EXCLUDE_NM_EXTS = new Set([".map"]); // source maps – large, not needed at runtime
62+
63+
// ── Helpers ───────────────────────────────────────────────────────────────────
64+
function isBinary(buf) {
65+
const len = Math.min(buf.length, 8192);
66+
for (let i = 0; i < len; i++) {
67+
if (buf[i] === 0) return true;
68+
}
69+
return false;
70+
}
71+
72+
function ext(name) {
73+
const i = name.lastIndexOf(".");
74+
return i === -1 ? "" : name.slice(i);
75+
}
76+
77+
// ── Tree builder ─────────────────────────────────────────────────────────────
78+
async function buildTree(dir, opts = {}) {
79+
const {
80+
excludeNames = new Set(),
81+
excludeDirs = new Set(),
82+
excludeExts = new Set(),
83+
visitedInodes = new Set(),
84+
} = opts;
85+
86+
let entries;
87+
try {
88+
entries = await readdir(dir, { withFileTypes: true });
89+
} catch {
90+
return {};
91+
}
92+
93+
const tree = {};
94+
95+
for (const entry of entries) {
96+
if (excludeNames.has(entry.name)) continue;
97+
98+
const fullPath = join(dir, entry.name);
99+
100+
try {
101+
if (entry.isSymbolicLink()) {
102+
const target = await readlink(fullPath);
103+
tree[entry.name] = { file: { symlink: target } };
104+
} else if (entry.isDirectory()) {
105+
if (excludeDirs.has(entry.name)) continue;
106+
const stat = await lstat(fullPath);
107+
if (visitedInodes.has(stat.ino)) continue; // guard against circular symlinks
108+
const subtree = await buildTree(fullPath, {
109+
excludeNames,
110+
excludeDirs,
111+
excludeExts,
112+
visitedInodes: new Set([...visitedInodes, stat.ino]),
113+
});
114+
tree[entry.name] = { directory: subtree };
115+
} else if (entry.isFile()) {
116+
if (excludeExts.has(ext(entry.name))) continue;
117+
const buf = await readFile(fullPath);
118+
if (isBinary(buf)) {
119+
tree[entry.name] = {
120+
file: { contents: buf.toString("base64"), binary: true },
121+
};
122+
} else {
123+
tree[entry.name] = { file: { contents: buf.toString("utf-8") } };
124+
}
125+
}
126+
} catch (err) {
127+
process.stderr.write(` Skipping ${fullPath}: ${err.message}\n`);
128+
}
129+
}
130+
131+
return tree;
132+
}
133+
134+
// ── Compress & write ──────────────────────────────────────────────────────────
135+
async function compress(label, tree, outPath) {
136+
process.stdout.write(` Serializing ${label}…\n`);
137+
const json = JSON.stringify(tree);
138+
process.stdout.write(` JSON size: ${(json.length / 1024 / 1024).toFixed(1)} MB\n`);
139+
140+
process.stdout.write(` Compressing…\n`);
141+
const gz = await gzipAsync(Buffer.from(json), { level: 9 });
142+
process.stdout.write(` Gzipped: ${(gz.length / 1024 / 1024).toFixed(1)} MB\n`);
143+
144+
await writeFile(outPath, gz);
145+
process.stdout.write(` Written → ${outPath}\n`);
146+
}
147+
148+
// ── Main ──────────────────────────────────────────────────────────────────────
149+
async function main() {
150+
await mkdir(OUTPUT, { recursive: true });
151+
152+
const packageJson = await readFile(join(ROOT, "package.json"), "utf-8");
153+
const packageHash = createHash("sha256")
154+
.update(packageJson)
155+
.digest("hex")
156+
.slice(0, 12);
157+
const commitSha = (process.env.GITHUB_SHA ?? "local").slice(0, 12);
158+
159+
console.log(`Commit: ${commitSha}`);
160+
console.log(`PackageHash: ${packageHash}`);
161+
162+
// 1. Source files
163+
console.log("\n[1/2] Building source files snapshot…");
164+
const filesTree = await buildTree(ROOT, { excludeNames: EXCLUDE_SOURCE });
165+
await compress(
166+
"files",
167+
filesTree,
168+
join(OUTPUT, `files-${commitSha}.json.gz`),
169+
);
170+
171+
// 2. node_modules
172+
console.log("\n[2/2] Building node_modules snapshot…");
173+
const nmTree = await buildTree(join(ROOT, "node_modules"), {
174+
excludeDirs: EXCLUDE_NM_DIRS,
175+
excludeExts: EXCLUDE_NM_EXTS,
176+
});
177+
await compress(
178+
"node_modules",
179+
nmTree,
180+
join(OUTPUT, `node_modules-${packageHash}.json.gz`),
181+
);
182+
183+
// 3. Shell env file for the upload step
184+
const envContent = `SHORT_SHA=${commitSha}\nPACKAGE_HASH=${packageHash}\n`;
185+
await writeFile(join(OUTPUT, "snapshot.env"), envContent);
186+
187+
console.log("\nDone.");
188+
}
189+
190+
main().catch((err) => {
191+
console.error(err);
192+
process.exit(1);
193+
});

0 commit comments

Comments
 (0)