diff --git a/Dockerfile b/Dockerfile index 3096dfc..1cea961 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,16 +3,12 @@ FROM gcr.io/distroless/cc-debian12 WORKDIR /app -# Copy sibling dependencies referenced by package.json -COPY dashdbschema /dashdbschema -COPY llmorbiter /llmorbiter - -# Copy application runtime files and SEA binary -COPY dashback/node_modules ./node_modules -COPY dashback/package.json ./package.json -COPY dashback/src ./src +# The standalone SEA binary contains Node.js runtime, src/, package.json, and all app logic COPY dashback/dist/dashback ./dist/dashback +# Only native C++ addons and dependencies needed by the OS dynamic linker (dlopen) +COPY dashback/node_modules ./node_modules + # Set production defaults: port 4202 only, localhost only ENV NODE_ENV=production \ HOST=127.0.0.1 \ diff --git a/scripts/buildSea.js b/scripts/buildSea.js index abc1bfa..8ae4384 100644 --- a/scripts/buildSea.js +++ b/scripts/buildSea.js @@ -1,43 +1,74 @@ import fs from 'node:fs'; import path from 'node:path'; +import zlib from 'node:zlib'; import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const rootDir = path.resolve(__dirname, '..'); +const distDir = path.join(rootDir, 'dist'); +const srcDir = path.join(rootDir, 'src'); const configPath = path.join(rootDir, 'sea-config.json'); +const payloadPath = path.join(distDir, 'app-payload.bin'); -if (!fs.existsSync(configPath)) { - console.error(`Error: Configuration file not found at ${configPath}`); - process.exit(1); +// Ensure dist directory exists +if (!fs.existsSync(distDir)) { + fs.mkdirSync(distDir, { recursive: true }); } -const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); -const outputPath = path.resolve(rootDir, config.output || 'dist/dashback'); -const outputDir = path.dirname(outputPath); +console.log('Packaging application code into SEA payload...'); -// Ensure the target output directory exists -if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); +function packDirectory(dir, baseDir = dir) { + const result = {}; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + const rel = path.relative(baseDir, full); + if (entry.isDirectory()) { + Object.assign(result, packDirectory(full, baseDir)); + } else if (entry.isFile()) { + result[rel] = fs.readFileSync(full).toString('base64'); + } + } + return result; } -console.log('Building Node.js Single Executable Application (SEA)...'); -console.log(`Node version: ${process.version}`); -console.log(`Config: ${path.relative(rootDir, configPath)}`); -console.log(`Target: ${path.relative(rootDir, outputPath)}`); +// Pack src/ and package.json +const payload = packDirectory(srcDir); +const pkgPath = path.join(rootDir, 'package.json'); +if (fs.existsSync(pkgPath)) { + payload['package.json'] = fs.readFileSync(pkgPath).toString('base64'); +} +const compressedPayload = zlib.deflateSync(Buffer.from(JSON.stringify(payload))); +fs.writeFileSync(payloadPath, compressedPayload); + +console.log(`Packed ${Object.keys(payload).length} files (${(compressedPayload.length / 1024 / 1024).toFixed(2)} MB compressed)`); + +// Prepare sea-config.json with payload asset +const config = { + main: 'src/sea-entry.cjs', + output: 'dist/dashback', + assets: { + 'app-payload.bin': 'dist/app-payload.bin', + }, + disableExperimentalSEAWarning: true, +}; + +fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n'); + +console.log('Generating Single Executable Application (SEA)...'); try { - execFileSync(process.execPath, ['--build-sea', configPath], { + execFileSync(process.execPath, ['--build-sea', 'sea-config.json'], { cwd: rootDir, stdio: 'inherit', }); - // Ensure executable permissions on POSIX systems + const outputPath = path.join(distDir, 'dashback'); if (process.platform !== 'win32' && fs.existsSync(outputPath)) { fs.chmodSync(outputPath, 0o755); } - console.log(`\nSuccessfully built SEA executable: ${path.relative(rootDir, outputPath)}`); + console.log(`\nSuccessfully built standalone SEA: ${path.relative(rootDir, outputPath)}`); } catch (err) { console.error('\nFailed to build SEA:', err.message); process.exit(1); diff --git a/sea-config.json b/sea-config.json index c3230ac..0b3116c 100644 --- a/sea-config.json +++ b/sea-config.json @@ -1,5 +1,8 @@ { "main": "src/sea-entry.cjs", "output": "dist/dashback", + "assets": { + "app-payload.bin": "dist/app-payload.bin" + }, "disableExperimentalSEAWarning": true } diff --git a/src/sea-entry.cjs b/src/sea-entry.cjs index 6789003..15f4087 100644 --- a/src/sea-entry.cjs +++ b/src/sea-entry.cjs @@ -1,15 +1,64 @@ /** * SEA Entry point for dashback. - * Injected into the Node.js single executable binary via node --build-sea. + * Embedded inside the Node.js single executable binary. * - * When launched as a SEA binary, it resolves the project root, - * configures module resolution, and loads src/index.js. + * When built with embedded assets, it unpacks the bundled src/ and package.json + * to a runtime directory and links the host node_modules, so native C++ addons + * and dependencies resolve seamlessly without needing src/ in the filesystem. */ -const { createRequire } = require('node:module'); -const path = require('node:path'); +const sea = require('node:sea'); const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const zlib = require('node:zlib'); +const { createRequire } = require('node:module'); -function resolveProjectRoot() { +function getProjectRoot() { + // If embedded application payload is present in SEA + if (sea.isSea && sea.isSea() && sea.getAssetKeys && sea.getAssetKeys().includes('app-payload.bin')) { + const runtimeDir = process.env.DASHBACK_RUNTIME_DIR || path.join(os.tmpdir(), 'dashback-runtime'); + + const compressed = Buffer.from(sea.getRawAsset('app-payload.bin')); + const json = zlib.inflateSync(compressed).toString('utf8'); + const files = JSON.parse(json); + + for (const [relPath, b64] of Object.entries(files)) { + const targetFile = relPath === 'package.json' + ? path.join(runtimeDir, 'package.json') + : path.join(runtimeDir, 'src', relPath); + + fs.mkdirSync(path.dirname(targetFile), { recursive: true }); + fs.writeFileSync(targetFile, Buffer.from(b64, 'base64')); + } + + // Link host node_modules so dependencies & native addons resolve + const hostNodeModules = path.resolve(process.cwd(), 'node_modules'); + const runtimeNodeModules = path.join(runtimeDir, 'node_modules'); + if (fs.existsSync(hostNodeModules) && !fs.existsSync(runtimeNodeModules)) { + try { + fs.symlinkSync(hostNodeModules, runtimeNodeModules, 'junction'); + } catch { + // Ignore symlink failure if already exists + } + } + + // Link host .env if present so config/env.js can find it + const hostEnv = path.resolve(process.cwd(), '.env'); + const runtimeEnv = path.join(runtimeDir, '.env'); + if (fs.existsSync(hostEnv) && !fs.existsSync(runtimeEnv)) { + try { + fs.symlinkSync(hostEnv, runtimeEnv, 'file'); + } catch { + try { + fs.copyFileSync(hostEnv, runtimeEnv); + } catch {} + } + } + + return runtimeDir; + } + + // Fallback if not running as packed SEA (local development) if (process.env.DASHBACK_ROOT && fs.existsSync(path.join(process.env.DASHBACK_ROOT, 'src/index.js'))) { return path.resolve(process.env.DASHBACK_ROOT); } @@ -21,19 +70,14 @@ function resolveProjectRoot() { if (fs.existsSync(parentEntry)) { return path.resolve(__dirname, '..'); } - const dirEntry = path.join(__dirname, 'src/index.js'); - if (fs.existsSync(dirEntry)) { - return __dirname; - } return process.cwd(); } -const projectRoot = resolveProjectRoot(); -const entryFile = path.join(projectRoot, 'src/index.js'); +const projectRoot = getProjectRoot(); +const entryFile = path.join(projectRoot, 'src', 'index.js'); if (!fs.existsSync(entryFile)) { console.error(`[SEA] Could not find dashback entry point at ${entryFile}.`); - console.error(`[SEA] Please ensure you are running from the dashback repository root or set DASHBACK_ROOT.`); process.exit(1); }