Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion docker/app/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ RUN mkdir -p packages/cli packages/landing packages/owletto-cli packages/owletto
done \
&& echo '{"name":"owletto","version":"0.0.0","private":true}' > packages/owletto-cli/package.json

RUN --mount=type=cache,target=/root/.bun/install/cache bun install
# Hoisted layout (npm-style flat node_modules) is required so the bundled
# server.bundle.mjs (built below) can resolve transitive npm deps via Node's
# resolver. Bun's default isolated linker uses .bun/<pkg>@<ver>/ paths that
# Node doesn't understand.
RUN --mount=type=cache,target=/root/.bun/install/cache bun install --linker=hoisted

# Force rebuild of source layers
ARG CACHEBUST=default
Expand Down Expand Up @@ -80,6 +84,11 @@ RUN cd packages/core && bunx tsc \
# Type check
RUN cd packages/owletto-backend && bunx tsc --noEmit

# Bundle the backend so prod runs under Node (so isolated-vm loads). See
# docker/app/start.sh and packages/owletto-backend/scripts/build-server-bundle.mjs
# for the full rationale.
RUN cd packages/owletto-backend && bun run build:server

# Build frontend static assets (production only)
ARG VITE_API_URL=
ENV VITE_API_URL=$VITE_API_URL
Expand Down
26 changes: 13 additions & 13 deletions docker/app/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ set -e

MODE="${1:-server}"

echo "Starting Owletto backend (Bun)"
echo "Starting Owletto backend (Node)"
echo "================================"

echo "Environment:"
Expand Down Expand Up @@ -41,15 +41,15 @@ else
run_migrations
fi

# NOTE: prod runs under Bun. The execute MCP tool requires V8
# (isolated-vm), which Bun's JSC ABI shim cannot load. PR #430 attempted
# to switch the runtime to `node --import tsx`, but exposed a CJS/ESM
# interop gap: Node's cjs-module-lexer doesn't detect new named exports
# of @lobu/core's CJS dist when the static import follows certain
# reachability paths (e.g. the full server.ts boot chain hits
# `SyntaxError: ... does not provide an export named 'createBuiltinSecretRef'`,
# even though a same-imports test entry resolves fine). Reverted on the
# understanding that fixing properly means making @lobu/core (and other
# workspace packages) ship dual ESM+CJS output instead of CJS-only with
# an `import` condition. Tracked separately.
exec bun /app/packages/owletto-backend/src/server.ts
# Run under Node so V8 native addons (isolated-vm) load. Bun uses
# JavaScriptCore and cannot link the V8 ABI surface that isolated-vm needs;
# the execute MCP tool silently degrades to RuntimeUnavailable under bun.
#
# We run a pre-bundled artifact (built by scripts/build-server-bundle.mjs
# in the builder stage) instead of TS source via tsx. Bundling at build
# time inlines workspace packages (@lobu/core et al.) so Node never has to
# bind named imports against their CJS dist barrels — that's what crashed
# #430. External npm deps are resolved by Node from node_modules, which
# is hoisted (see Dockerfile `bun install --linker=hoisted`) so the flat
# layout matches what Node expects.
exec node /app/packages/owletto-backend/dist/server.bundle.mjs
1 change: 1 addition & 0 deletions packages/owletto-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"scripts": {
"dev": "tsx watch --ignore=../owletto-web/** --ignore=../../node_modules/** src/server.ts",
"start": "tsx src/server.ts",
"build:server": "node ./scripts/build-server-bundle.mjs",
"test": "vitest",
"test:pglite": "OWLETTO_TEST_BACKEND=pglite vitest",
"test:sandbox-runtime": "SKIP_TEST_DB_SETUP=1 vitest run src/__tests__/integration/sandbox/run-script-runtime.test.ts",
Expand Down
71 changes: 71 additions & 0 deletions packages/owletto-backend/scripts/build-server-bundle.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
#!/usr/bin/env node
/**
* Bundle src/server.ts into a single ESM file for production runtime.
*
* Why: prod runs under Node so isolated-vm (V8 native addon) loads. Running
* the TS source through tsx exposes Node's CJS↔ESM lexer interop with
* @lobu/core's CJS dist (#430 history). Bundling resolves all workspace
* imports at build time, so Node only ever sees the bundle's own ESM
* surface plus a small set of npm externals it can load from node_modules.
*
* Resolution conditions: 'bun' first, so workspace packages resolve to
* their TS source (./src/index.ts via the package.json `bun` condition)
* instead of their CJS dist. esbuild compiles the TS inline.
*
* External: bare specifiers stay external (loaded from node_modules at
* runtime). The Docker build uses `bun install --linker=hoisted` so Node's
* resolver finds them at top-level node_modules. Native addons (isolated-vm)
* and packages with require-in-the-middle hooks (Sentry, OpenTelemetry,
* pino) MUST stay external to keep their runtime hooks working.
*/

import esbuild from 'esbuild';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const here = dirname(fileURLToPath(import.meta.url));
const pkgDir = join(here, '..');

const result = await esbuild.build({
absWorkingDir: pkgDir,
entryPoints: ['src/server.ts'],
bundle: true,
outfile: 'dist/server.bundle.mjs',
platform: 'node',
target: 'node22',
format: 'esm',
conditions: ['bun', 'import', 'module', 'default'],
// Bundle only @lobu/* workspace packages and relative imports. Everything
// else stays external.
plugins: [
{
name: 'external-non-workspace',
setup(build) {
build.onResolve({ filter: /.*/ }, (args) => {
if (args.kind === 'entry-point') return null;
const id = args.path;
if (id.startsWith('.') || id.startsWith('/')) return null;
if (id.startsWith('@lobu/')) return null;
return { external: true };
});
},
},
],
// CJS-style require() shim for any leftover require() calls in bundled
// code (esbuild emits these when it inlines CJS-flavoured workspace src).
// Don't inject __filename/__dirname here — esbuild emits per-module shims
// when bundled CJS source references them; top-level shims would collide.
banner: {
js: `import { createRequire as __createRequire } from 'module'; const require = __createRequire(import.meta.url);`,
},
sourcemap: true,
metafile: true,
logLevel: 'info',
});

const outPath = join(pkgDir, 'dist/server.bundle.mjs');
const bytes = result.metafile.outputs[outPath]?.bytes ?? 0;
console.log(
`\n=== bundle ready: dist/server.bundle.mjs (${(bytes / 1024 / 1024).toFixed(2)} MB)`,
);
console.log(` warnings: ${result.warnings.length}, errors: ${result.errors.length}`);
Loading