Skip to content

Commit 8217706

Browse files
committed
Add unified WASM bundle infrastructure with INT4 quantization
Implements INT4 quantized CodeT5 models for 50% size reduction (1-2% quality loss vs INT8). Includes unified manager script (--build/ --download/--help), auto-prerequisite installation (Python packages, Rust, Homebrew, binaryen), and GitHub Action for manual WASM builds with date-based release tagging.
1 parent 1e27d8e commit 8217706

File tree

13 files changed

+2411
-6
lines changed

13 files changed

+2411
-6
lines changed

.config/esbuild.cli.build.mjs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
* esbuild build script for Socket CLI.
33
*/
44

5+
import { brotliCompressSync } from 'node:zlib'
6+
import { readFileSync, writeFileSync } from 'node:fs'
57
import { build } from 'esbuild'
68

79
import config from './esbuild.cli.config.mjs'
@@ -20,6 +22,32 @@ try {
2022
console.log(`✓ Bundle size: ${(outputSize / 1024 / 1024).toFixed(2)} MB`)
2123
}
2224
}
25+
26+
// Compress with brotli.
27+
console.log('\n🗜️ Compressing with brotli...')
28+
const jsCode = readFileSync(config.outfile)
29+
const compressed = brotliCompressSync(jsCode, {
30+
params: {
31+
// eslint-disable-next-line n/prefer-global/buffer
32+
[require('node:zlib').constants.BROTLI_PARAM_QUALITY]: 11,
33+
// eslint-disable-next-line n/prefer-global/buffer
34+
[require('node:zlib').constants.BROTLI_PARAM_SIZE_HINT]: jsCode.length,
35+
},
36+
})
37+
38+
const bzPath = `${config.outfile}.bz`
39+
writeFileSync(bzPath, compressed)
40+
41+
const originalSize = jsCode.length / 1024 / 1024
42+
const compressedSize = compressed.length / 1024 / 1024
43+
const compressionRatio = ((compressed.length / jsCode.length) * 100).toFixed(
44+
1,
45+
)
46+
47+
console.log(`✓ Compressed: ${bzPath}`)
48+
console.log(`✓ Original size: ${originalSize.toFixed(2)} MB`)
49+
console.log(`✓ Compressed size: ${compressedSize.toFixed(2)} MB`)
50+
console.log(`✓ Compression ratio: ${compressionRatio}%`)
2351
} catch (error) {
2452
console.error('Build failed:', error)
2553
// eslint-disable-next-line n/no-process-exit

.github/workflows/build-wasm.yml

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
name: 🔧 Build WASM Bundle
2+
3+
# Manual workflow for building the unified WASM bundle with AI models.
4+
# This creates a separate release (wasm-build-*) to keep WASM bundles
5+
# separate from regular Socket CLI releases.
6+
7+
on:
8+
workflow_dispatch:
9+
inputs:
10+
force-rebuild:
11+
description: 'Force rebuild even if build already exists for this commit'
12+
required: false
13+
type: boolean
14+
default: false
15+
16+
permissions:
17+
contents: write # Required for creating releases and uploading assets
18+
19+
jobs:
20+
build-wasm:
21+
name: Build WASM Bundle
22+
runs-on: ubuntu-latest
23+
timeout-minutes: 60
24+
25+
steps:
26+
- name: Checkout repository
27+
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
28+
29+
- name: Setup Node.js
30+
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
31+
with:
32+
node-version: '22'
33+
34+
- name: Setup pnpm
35+
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
36+
37+
- name: Install dependencies
38+
run: pnpm install --frozen-lockfile
39+
40+
- name: Setup Python
41+
uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
42+
with:
43+
python-version: '3.11'
44+
45+
- name: Setup Rust
46+
uses: dtolnay/rust-toolchain@7b1c307e0dcbda6122208f10795a713336a9b35a # stable
47+
with:
48+
toolchain: stable
49+
targets: wasm32-unknown-unknown
50+
51+
- name: Install wasm-pack
52+
run: cargo install wasm-pack
53+
54+
- name: Install wasm-opt (binaryen)
55+
run: |
56+
sudo apt-get update
57+
sudo apt-get install -y binaryen
58+
59+
- name: Generate build tag
60+
id: build-tag
61+
run: |
62+
BUILD_DATE=$(date +'%Y%m%d')
63+
SHORT_SHA=$(git rev-parse --short HEAD)
64+
BUILD_TAG="wasm-build-${BUILD_DATE}-${SHORT_SHA}"
65+
echo "tag=${BUILD_TAG}" >> $GITHUB_OUTPUT
66+
echo "date=${BUILD_DATE}" >> $GITHUB_OUTPUT
67+
echo "sha=${SHORT_SHA}" >> $GITHUB_OUTPUT
68+
69+
- name: Check if release exists
70+
id: check-release
71+
run: |
72+
TAG="${{ steps.build-tag.outputs.tag }}"
73+
if gh release view "$TAG" >/dev/null 2>&1; then
74+
echo "exists=true" >> $GITHUB_OUTPUT
75+
echo "Release $TAG already exists"
76+
else
77+
echo "exists=false" >> $GITHUB_OUTPUT
78+
echo "Release $TAG does not exist"
79+
fi
80+
env:
81+
GH_TOKEN: ${{ github.token }}
82+
83+
- name: Exit if release exists and not force rebuild
84+
if: steps.check-release.outputs.exists == 'true' && !inputs.force-rebuild
85+
run: |
86+
echo "❌ Release ${{ steps.build-tag.outputs.tag }} already exists"
87+
echo " Use 'force-rebuild' option to rebuild anyway"
88+
exit 1
89+
90+
- name: Build WASM bundle
91+
run: node scripts/wasm.mjs --build
92+
env:
93+
CI: true
94+
95+
- name: Verify build output
96+
run: |
97+
if [ ! -f external/socket-ai-sync.mjs ]; then
98+
echo "❌ Build output not found: external/socket-ai-sync.mjs"
99+
exit 1
100+
fi
101+
102+
FILE_SIZE=$(stat -f%z external/socket-ai-sync.mjs 2>/dev/null || stat -c%s external/socket-ai-sync.mjs)
103+
FILE_SIZE_MB=$((FILE_SIZE / 1024 / 1024))
104+
105+
echo "✓ Build output verified"
106+
echo " Size: ${FILE_SIZE_MB} MB"
107+
108+
if [ "$FILE_SIZE_MB" -lt 50 ]; then
109+
echo "❌ Build output too small (expected ~115MB)"
110+
exit 1
111+
fi
112+
113+
- name: Create release
114+
if: steps.check-release.outputs.exists == 'false' || inputs.force-rebuild
115+
run: |
116+
TAG="${{ steps.build-tag.outputs.tag }}"
117+
DATE="${{ steps.build-tag.outputs.date }}"
118+
SHA="${{ steps.build-tag.outputs.sha }}"
119+
120+
gh release create "$TAG" \
121+
--title "WASM Bundle Build ${DATE}" \
122+
--notes "## WASM Bundle Build
123+
124+
**Automated build of unified WASM bundle for Socket CLI AI features.**
125+
126+
### Build Information
127+
- **Date**: $(date +'%Y-%m-%d %H:%M:%S UTC')
128+
- **Commit**: ${SHA}
129+
- **Tag**: ${TAG}
130+
131+
### Contents
132+
- ONNX Runtime (~2-5MB)
133+
- MiniLM model (~17MB int8)
134+
- CodeT5 encoder (~30MB int4)
135+
- CodeT5 decoder (~60MB int4)
136+
- Tokenizers (~1MB)
137+
- Yoga Layout (~95KB)
138+
139+
### INT4 Quantization
140+
- CodeT5 models use INT4 (4-bit weights) for 50% size reduction
141+
- Only 1-2% quality loss compared to INT8
142+
- Total bundle size: ~115MB (vs ~145MB with INT8)
143+
144+
### Download
145+
\`\`\`bash
146+
node scripts/wasm.mjs --download
147+
\`\`\`
148+
149+
### Build from Source
150+
\`\`\`bash
151+
node scripts/wasm.mjs --build
152+
\`\`\`
153+
154+
---
155+
156+
This is a pre-release for internal development use." \
157+
--prerelease \
158+
external/socket-ai-sync.mjs
159+
env:
160+
GH_TOKEN: ${{ github.token }}
161+
162+
- name: Upload artifact (for debugging)
163+
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
164+
with:
165+
name: wasm-bundle-${{ steps.build-tag.outputs.tag }}
166+
path: external/socket-ai-sync.mjs
167+
retention-days: 7
168+
169+
- name: Build summary
170+
run: |
171+
FILE_SIZE=$(stat -f%z external/socket-ai-sync.mjs 2>/dev/null || stat -c%s external/socket-ai-sync.mjs)
172+
FILE_SIZE_MB=$((FILE_SIZE / 1024 / 1024))
173+
174+
echo "## ✅ WASM Bundle Build Complete" >> $GITHUB_STEP_SUMMARY
175+
echo "" >> $GITHUB_STEP_SUMMARY
176+
echo "**Tag**: \`${{ steps.build-tag.outputs.tag }}\`" >> $GITHUB_STEP_SUMMARY
177+
echo "**Size**: ${FILE_SIZE_MB} MB" >> $GITHUB_STEP_SUMMARY
178+
echo "**Date**: $(date +'%Y-%m-%d %H:%M:%S UTC')" >> $GITHUB_STEP_SUMMARY
179+
echo "" >> $GITHUB_STEP_SUMMARY
180+
echo "### Download" >> $GITHUB_STEP_SUMMARY
181+
echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY
182+
echo "node scripts/wasm.mjs --download" >> $GITHUB_STEP_SUMMARY
183+
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY

bin/bootstrap.js

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,57 @@
11
// eslint-disable-next-line no-restricted-syntax, n/prefer-global/process
2-
const { existsSync } = require('node:fs')
2+
const { existsSync, readFileSync, writeFileSync } = require('node:fs')
33
// eslint-disable-next-line no-restricted-syntax, n/prefer-global/process
4-
const { homedir } = require('node:os')
4+
const { homedir, tmpdir } = require('node:os')
55
// eslint-disable-next-line no-restricted-syntax, n/prefer-global/process
66
const { join } = require('node:path')
77
// eslint-disable-next-line no-restricted-syntax, n/prefer-global/process
88
const { spawnSync } = require('node:child_process')
9+
// eslint-disable-next-line no-restricted-syntax, n/prefer-global/process
10+
const { brotliDecompressSync } = require('node:zlib')
911

1012
const SOCKET_DLX_DIR = join(homedir(), '.socket', '_dlx')
1113
const CLI_PACKAGE_DIR = join(SOCKET_DLX_DIR, 'cli')
1214
const CLI_ENTRY = join(CLI_PACKAGE_DIR, 'dist', 'cli.js')
15+
const CLI_ENTRY_BZ = join(CLI_PACKAGE_DIR, 'dist', 'cli.js.bz')
16+
17+
// Check for brotli-compressed CLI first.
18+
if (existsSync(CLI_ENTRY_BZ)) {
19+
// Read compressed file.
20+
const compressed = readFileSync(CLI_ENTRY_BZ)
21+
22+
// Decompress with brotli.
23+
const decompressed = brotliDecompressSync(compressed)
1324

14-
// Check if CLI exists.
15-
if (existsSync(CLI_ENTRY)) {
16-
// Delegate to ~/.socket/_dlx/cli/dist/cli.js.
25+
// Write to temporary file and execute.
26+
// Using a temp file allows us to maintain spawn behavior for proper stdio handling.
27+
const tempCliPath = join(tmpdir(), `socket-cli-${process.pid}.js`)
28+
writeFileSync(tempCliPath, decompressed)
29+
30+
// Delegate to decompressed CLI.
31+
const result = spawnSync(
32+
process.execPath,
33+
[tempCliPath, ...process.argv.slice(2)],
34+
{
35+
stdio: 'inherit',
36+
env: {
37+
...process.env,
38+
PKG_EXECPATH: process.env.PKG_EXECPATH || 'PKG_INVOKE_NODEJS',
39+
},
40+
},
41+
)
42+
43+
// Clean up temp file.
44+
try {
45+
// eslint-disable-next-line no-restricted-syntax, n/prefer-global/process
46+
const { unlinkSync } = require('node:fs')
47+
unlinkSync(tempCliPath)
48+
} catch {
49+
// Ignore cleanup errors.
50+
}
51+
52+
process.exit(result.status || 0)
53+
} else if (existsSync(CLI_ENTRY)) {
54+
// Fallback to uncompressed CLI.
1755
const result = spawnSync(
1856
process.execPath,
1957
[CLI_ENTRY, ...process.argv.slice(2)],

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,10 @@
6262
"test:wrapper": "node --loader=./scripts/load.mjs scripts/test-wrapper.mjs",
6363
"test-ci": "run-s test:*",
6464
"test-pre-commit": "dotenvx -q run -f .env.precommit -- pnpm test",
65-
"update": "node --loader=./scripts/load.mjs scripts/update.mjs"
65+
"update": "node --loader=./scripts/load.mjs scripts/update.mjs",
66+
"wasm": "node scripts/wasm.mjs",
67+
"wasm:build": "node scripts/wasm.mjs --build",
68+
"wasm:download": "node scripts/wasm.mjs --download"
6669
},
6770
"devDependencies": {
6871
"@babel/core": "7.28.4",

0 commit comments

Comments
 (0)