Skip to content

Commit 7541ecd

Browse files
committed
refactor(sea): use minimal bootstrap instead of embedding full CLI
Redesigns SEA builder to match smol-node architecture: Node.js + minimal bootstrap that downloads @socketsecurity/cli from npm on first run. Before: Node.js + embedded CLI code (~110MB+) After: Node.js + minimal bootstrap (~112MB, same as standard Node.js) Changes: - Replace CLI embedding logic with bootstrap loading - Remove brotli decompression and SEA compatibility transforms - Remove unused crypto and zlib imports - Auto-build bootstrap if missing - Update caching to hash bootstrap instead of CLI - Simplify cleanup (no modified CLI files) - Update all documentation and log messages Binary is now just Node.js + 1.1MB bootstrap, with CLI downloaded from npm on first run. No longer needs rebuilding when CLI changes.
1 parent 54f5fd6 commit 7541ecd

File tree

1 file changed

+32
-179
lines changed

1 file changed

+32
-179
lines changed

packages/node-sea-builder/scripts/build.mjs

Lines changed: 32 additions & 179 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,15 @@
22
* Build script for creating self-executable Socket CLI applications.
33
* Uses Node.js Single Executable Application (SEA) feature.
44
*
5-
* IMPORTANT: This builds a FULL SEA with the complete CLI embedded.
5+
* IMPORTANT: This builds a SEA with minimal bootstrap (like smol-node).
66
* The binary contains:
77
* - Node.js runtime (~50MB)
8-
* - Full Socket CLI from packages/cli/dist/cli.js.bz (~1.7MB compressed, decompressed at build time)
9-
* - All dependencies and node_modules
8+
* - Minimal bootstrap (~50KB) that downloads @socketsecurity/cli from npm on first run
109
*
11-
* Expected binary size: ~110MB+ per platform.
10+
* Expected binary size: ~50MB per platform (Node.js + tiny bootstrap).
1211
*
1312
* Prerequisites:
14-
* - Run `pnpm run build:cli` first to create packages/cli/dist/cli.js.bz
13+
* - Run `pnpm --filter @socketsecurity/bootstrap run build` to create bootstrap-sea.js
1514
*
1615
* Supported platforms:
1716
* - Windows (x64, arm64)
@@ -24,12 +23,10 @@
2423
* - Build specific platform: node scripts/build.mjs --platform=darwin --arch=x64
2524
*/
2625

27-
import crypto from 'node:crypto'
2826
import { existsSync, promises as fs } from 'node:fs'
2927
import os from 'node:os'
3028
import path from 'node:path'
3129
import url from 'node:url'
32-
import zlib from 'node:zlib'
3330

3431
import { parseArgs } from '@socketsecurity/lib/argv/parse'
3532
import { WIN32 } from '@socketsecurity/lib/constants/platform'
@@ -503,88 +500,24 @@ async function buildTarget(target, options) {
503500
const fuseSentinel = 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2'
504501

505502
logger.log(
506-
`\nBuilding full SEA for ${target.platform}-${target.arch}...`,
503+
`\nBuilding minimal SEA for ${target.platform}-${target.arch}...`,
507504
)
508-
logger.log('(Embedding full CLI from packages/cli/dist/cli.js.bz)')
505+
logger.log('(Node.js + minimal bootstrap that downloads CLI from npm)')
509506

510-
// Use the compressed CLI from packages/cli/dist/cli.js.bz and decompress it.
511-
const cliBzPath = normalizePath(
512-
path.join(__dirname, '../../..', 'packages', 'cli', 'dist', 'cli.js.bz'),
507+
// Use the bootstrap from packages/bootstrap/dist/bootstrap-sea.js.
508+
const bootstrapPath = normalizePath(
509+
path.join(__dirname, '../../..', 'packages', 'bootstrap', 'dist', 'bootstrap-sea.js'),
513510
)
514511

515-
// Check if CLI needs to be built or validated.
516-
const checksumPath = normalizePath(
517-
path.join(__dirname, '../../..', 'packages', 'cli', 'dist', 'cli.js.bz.sha256'),
518-
)
519-
520-
let needsBuild = false
521-
522-
if (!existsSync(cliBzPath)) {
523-
needsBuild = true
512+
// Check if bootstrap needs to be built.
513+
if (!existsSync(bootstrapPath)) {
524514
logger.log('')
525-
logger.log(`${colors.blue('ℹ')} CLI not found, building @socketsecurity/cli package...`)
526-
} else if (!existsSync(checksumPath)) {
527-
needsBuild = true
528-
logger.log('')
529-
logger.log(`${colors.yellow('⚠')} CLI checksum not found, rebuilding @socketsecurity/cli package...`)
530-
} else {
531-
// Validate checksum.
532-
try {
533-
const checksumContent = (await fs.readFile(checksumPath, 'utf8')).trim()
534-
const expectedHash = checksumContent.split(/\s+/)[0]
535-
const cliData = await fs.readFile(cliBzPath)
536-
const actualHash = crypto.createHash('sha256').update(cliData).digest('hex')
537-
538-
if (expectedHash !== actualHash) {
539-
needsBuild = true
540-
logger.log('')
541-
logger.log(`${colors.yellow('⚠')} CLI checksum mismatch, rebuilding @socketsecurity/cli package...`)
542-
logger.log(` Expected: ${expectedHash}`)
543-
logger.log(` Actual: ${actualHash}`)
544-
} else {
545-
// Check if source files are newer than the compressed artifact (freshness check).
546-
const cliSrcDir = normalizePath(
547-
path.join(__dirname, '../../..', 'packages', 'cli', 'src'),
548-
)
549-
const cliBzStat = await fs.stat(cliBzPath)
550-
551-
// Check if any source file is newer than the compressed artifact.
552-
let hasNewerSources = false
553-
try {
554-
const srcFiles = await fs.readdir(cliSrcDir, { recursive: true })
555-
for (const file of srcFiles) {
556-
const filePath = path.join(cliSrcDir, file)
557-
const stat = await fs.stat(filePath)
558-
559-
if (stat.isFile() && stat.mtime > cliBzStat.mtime) {
560-
hasNewerSources = true
561-
break
562-
}
563-
}
564-
} catch {
565-
// Ignore stat errors - validation succeeded so we're OK to continue.
566-
}
567-
568-
if (hasNewerSources) {
569-
needsBuild = true
570-
logger.log('')
571-
logger.log(`${colors.yellow('⚠')} CLI source files modified, rebuilding @socketsecurity/cli package...`)
572-
}
573-
}
574-
} catch (e) {
575-
needsBuild = true
576-
logger.log('')
577-
logger.log(`${colors.yellow('⚠')} CLI validation failed, rebuilding @socketsecurity/cli package...`)
578-
logger.log(` Error: ${e.message}`)
579-
}
580-
}
581-
582-
if (needsBuild) {
515+
logger.log(`${colors.blue('ℹ')} Bootstrap not found, building @socketsecurity/bootstrap package...`)
583516
logger.log('')
584517

585518
const result = await spawn(
586519
'pnpm',
587-
['--filter', '@socketsecurity/cli', 'run', 'build'],
520+
['--filter', '@socketsecurity/bootstrap', 'run', 'build'],
588521
{
589522
cwd: path.join(__dirname, '../../..'),
590523
shell: WIN32,
@@ -594,32 +527,24 @@ async function buildTarget(target, options) {
594527

595528
if (result.code !== 0) {
596529
throw new Error(
597-
`Failed to build @socketsecurity/cli. Exit code: ${result.code}`,
530+
`Failed to build @socketsecurity/bootstrap. Exit code: ${result.code}`,
598531
)
599532
}
600533

601-
// Verify CLI was built.
602-
if (!existsSync(cliBzPath)) {
534+
// Verify bootstrap was built.
535+
if (!existsSync(bootstrapPath)) {
603536
throw new Error(
604-
`CLI build succeeded but compressed file not found at ${cliBzPath}`,
605-
)
606-
}
607-
608-
// Verify checksum was created.
609-
if (!existsSync(checksumPath)) {
610-
throw new Error(
611-
`CLI build succeeded but checksum file not found at ${checksumPath}`,
537+
`Bootstrap build succeeded but file not found at ${bootstrapPath}`,
612538
)
613539
}
614540

615541
logger.log('')
616542
}
617543

618-
logger.log(`Decompressing CLI from: ${cliBzPath}`)
544+
logger.log(`Using bootstrap from: ${bootstrapPath}`)
619545

620-
// Read and decompress the brotli-compressed CLI.
621-
const compressedCli = await fs.readFile(cliBzPath)
622-
const cliCode = zlib.brotliDecompressSync(compressedCli).toString('utf8')
546+
// Read the bootstrap code.
547+
const bootstrapCode = await fs.readFile(bootstrapPath, 'utf8')
623548

624549
// Ensure output directory exists.
625550
await fs.mkdir(outputDir, { recursive: true })
@@ -628,8 +553,8 @@ async function buildTarget(target, options) {
628553
const outputPath = normalizePath(path.join(outputDir, target.outputName))
629554

630555
// Check if we can use cached SEA build.
631-
// Hash the CLI bundle and build script since those are the inputs.
632-
const sourcePaths = [cliBzPath, url.fileURLToPath(import.meta.url)]
556+
// Hash the bootstrap and build script since those are the inputs.
557+
const sourcePaths = [bootstrapPath, url.fileURLToPath(import.meta.url)]
633558

634559
// Store hash in centralized build/.cache/ directory.
635560
const cacheDir = normalizePath(path.join(__dirname, '../build/.cache'))
@@ -649,77 +574,15 @@ async function buildTarget(target, options) {
649574
// Cache hit! SEA binary is up to date.
650575
logger.log('')
651576
logger.log(`${colors.green('✓')} Using cached SEA binary`)
652-
logger.log('CLI bundle unchanged since last build.')
577+
logger.log('Bootstrap unchanged since last build.')
653578
logger.log('')
654579
logger.log(`Binary: ${outputPath}`)
655580
logger.log('')
656581
return
657582
}
658583

659-
// Create a modified copy of the CLI with SEA compatibility fixes.
660-
const modifiedCliPath = normalizePath(path.join(outputDir, 'cli-modified.js'))
661-
let cliContent = cliCode
662-
663-
// Fix 1: Replace the sentinel constant with a split string that won't match the sentinel search.
664-
cliContent = cliContent.replace(
665-
/"NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2"/g,
666-
'"NODE_SEA" + "_FUSE_fce680ab2cc467b6e072b8b5df1996b2"',
667-
)
668-
669-
// Fix 2: Add defensive checks for require.resolve in SEA context.
670-
// Replace: if (require.resolve.paths)
671-
// With: if (require.resolve && require.resolve.paths)
672-
cliContent = cliContent.replace(
673-
/if\s*\(\s*require\.resolve\.paths\s*\)/g,
674-
'if (require.resolve && require.resolve.paths)',
675-
)
676-
677-
// Fix 3: Add SEA compatibility polyfill after shebang.
678-
const seaPolyfill = `
679-
// SEA Compatibility Polyfill
680-
if (typeof require !== 'undefined' && (!require.resolve || !require.resolve.paths)) {
681-
if (!require.resolve) {
682-
require.resolve = function(id, options) {
683-
// Basic resolve that returns the id for SEA context.
684-
return id;
685-
};
686-
}
687-
if (!require.resolve.paths) {
688-
require.resolve.paths = function(request) {
689-
// Return empty array for SEA context where path resolution isn't available.
690-
return [];
691-
};
692-
}
693-
}
694-
`
695-
696-
// Insert polyfill after shebang (if present) or at the beginning.
697-
let modifiedContent
698-
if (cliContent.startsWith('#!')) {
699-
const firstNewline = cliContent.indexOf('\n')
700-
const shebang = cliContent.substring(0, firstNewline + 1)
701-
const rest = cliContent.substring(firstNewline + 1)
702-
modifiedContent = shebang + seaPolyfill + rest
703-
} else {
704-
modifiedContent = seaPolyfill + cliContent
705-
}
706-
707-
await fs.writeFile(modifiedCliPath, modifiedContent)
708-
709-
// Verify transformations were applied correctly.
710-
logger.log('Verifying SEA compatibility transformations...')
711-
const verifyScript = normalizePath(path.join(__dirname, 'verify-sea-transforms.mjs'))
712-
try {
713-
await spawn('node', [verifyScript], { stdio: 'pipe' })
714-
logger.log(`${colors.green('✓')} All transformations verified`)
715-
} catch (error) {
716-
logger.error(`${colors.yellow('⚠')} Transformation verification failed`)
717-
logger.error(' The build will continue, but runtime issues may occur')
718-
// Don't fail the build - polyfill provides runtime safety net.
719-
}
720-
721-
// Use the modified CLI as the entry point.
722-
const entryPoint = modifiedCliPath
584+
// Use the bootstrap directly as the entry point (no modifications needed).
585+
const entryPoint = bootstrapPath
723586

724587
// Download Node.js binary for target platform.
725588
const nodeBinary = await downloadNodeBinary(
@@ -749,18 +612,8 @@ if (typeof require !== 'undefined' && (!require.resolve || !require.resolve.path
749612
const sourceHashComment = await generateHashComment(sourcePaths)
750613
await fs.writeFile(hashFilePath, sourceHashComment, 'utf-8')
751614

752-
// Clean up temporary files using trash.
753-
const filesToClean = [
754-
blobPath,
755-
entryPoint.endsWith('.compiled.mjs') ? entryPoint : null,
756-
entryPoint.endsWith('.mjs') && !entryPoint.endsWith('.compiled.mjs')
757-
? entryPoint
758-
: null,
759-
].filter(Boolean)
760-
761-
if (filesToClean.length > 0) {
762-
await safeDelete(filesToClean).catch(() => {})
763-
}
615+
// Clean up temporary files (just the blob, bootstrap is preserved).
616+
await safeDelete(blobPath).catch(() => {})
764617
} finally {
765618
// Clean up config.
766619
await safeDelete(configPath).catch(() => {})
@@ -787,10 +640,10 @@ async function main() {
787640
strict: false,
788641
})
789642

790-
logger.log('Socket CLI Self-Executable Builder')
791-
logger.log('====================================')
643+
logger.log('Socket CLI SEA Builder')
644+
logger.log('======================')
792645
logger.log(
793-
'Building THIN WRAPPER that downloads @socketsecurity/cli on first use',
646+
'Building SEA with minimal bootstrap (downloads @socketsecurity/cli on first use)',
794647
)
795648

796649
// Generate and filter targets based on options.
@@ -890,8 +743,8 @@ async function main() {
890743
logger.log(`\n${colors.green('✓')} Build complete!`)
891744
logger.log(`Output directory: ${options.outputDir || 'dist/sea'}`)
892745
logger.log(`Variant directory: ${binsDir}`)
893-
logger.log('\nNOTE: These binaries are thin wrappers that will download')
894-
logger.log('@socketsecurity/cli from npm on first run.')
746+
logger.log('\nNOTE: These are minimal SEA binaries (Node.js + bootstrap)')
747+
logger.log('that download @socketsecurity/cli from npm on first run.')
895748
}
896749

897750
// Run if executed directly.

0 commit comments

Comments
 (0)