Skip to Content
Getting StartedMigrateMigration Guide

Migration Guide

This page pulls together what the per-tool and per-framework pages found: the order to migrate in, and every factor that decides whether a given part of your project can move now.

1. Take inventory

Answer these before touching anything:

QuestionWhy it matters
What runs where? (server, scripts, tests, build, frontend)Backends, scripts and tests move easily; build tooling doesn’t yet
Any file:, git:, github: or tarball-URL dependencies?3va install rejects them (Invalid version 'file:./x'). Publish them to a registry or move them into a workspace
Which package manager and lockfile?npm, Yarn v1, pnpm and bun.lock convert; Yarn Berry and bun.lockb don’t
Any native dependencies built by install scripts? (sharp, bcrypt, better-sqlite3, Prisma)Only allowlisted JS scripts run, so shell-script builds end up without their binary. 3va doctor --compat lists them
Which databases and auth methods?Postgres SCRAM, pg.Pool and mysql2 don’t work yet
Top-level await? deno.json? Bun.*? npm: specifiers?None of them are supported yet
Which env vars, files, hosts and child processes does the app use?Each one becomes an explicit grant

2. Migrate in layers

  1. Run on your current node_modules: 3va run app.js --allow-read=./node_modules …
  2. Test: 3va test
  3. Deploy: 3va start (instead of pm2) in the environment where you deploy
  4. Install: 3va migrate + 3va install, and 3va ci in CI, after checking Installing with 3va
  5. Build tooling: stays on Node until the frameworks you use are supported

Each layer can ship on its own. If one causes trouble, go back one layer without undoing the others.

3. Permissions

This is the part of the migration that doesn’t exist with Node, Bun or npm-installed code.

  • Discover and record: 3va permissions learn app.js --write runs the app with everything allowed and merges what it used into package.json, under the root "." scope. Existing per-package scopes are left alone. Exercise the code paths you care about while it runs, and review the diff: it also records a couple of variables the runtime itself reads (CLUSTER_WORKER, NODE_ENV). 3va permissions suggest only reads the source and is much broader.

  • Declare the result in package.json under "3va" so run, start and test share it and it’s reviewed like code:

    { "3va": { "no-prompt": true, "permissions": { ".": { "allow-read": ["./node_modules", "./public"], "allow-write": ["./uploads"], "allow-net": ["127.0.0.1", "api.stripe.com:443"], "allow-env": ["PORT", "DATABASE_URL", "STRIPE_KEY"] } } } }
  • Read the denial summary. When something was refused, 3va run lists it on exit with the flag to add, and --trace-denials prints each refusal as it happens:

    [!] 4 permissions were denied during this run: read ./secret.txt grant with: --allow-read=./secret.txt write ./output.txt grant with: --allow-write=./output.txt spawn a child process grant with: --allow-child-process connect to localhost grant with: --allow-net=localhost
  • Environment variables show up too. An ungranted process.env.X still reads as undefined, so defaults kick in, but the run ends with read environment variable DATABASE_URL — grant with: --allow-env=DATABASE_URL, and in a terminal 3va asks at the moment of the read. Enumerating the environment (Object.keys(process.env)) just returns {} and isn’t reported.

  • Network grants: host:port limits a grant to one port (--allow-net=api.stripe.com:443; a typo’d port matches nothing), for fetch, raw TCP and WebSocket alike. localhost and 127.0.0.1 are separate grants.

  • listen(port) stays on 127.0.0.1 unless --allow-net includes 0.0.0.0, :: or *.

  • "no-prompt": true makes local runs behave like CI (deny instead of asking), so you find missing grants before deploying.

4. Installing with 3va

3va install differs from npm on purpose: no install scripts, and a malware scan. The workflow and each point to check:

3va migrate --dry-run # what would be converted 3va migrate # package-lock / yarn.lock v1 / pnpm-lock / bun.lock → 3va-lock.json 3va install --allow-net=registry.npmjs.org 3va ci --allow-net=registry.npmjs.org # in CI: exact lockfile, integrity-checked
  • Lockfiles. 3va migrate converts package-lock.json, yarn.lock v1, pnpm-lock.yaml (v9) and the text bun.lock with versions, URLs and integrity hashes. 3va install then keeps the pinned versions. Yarn Berry lockfiles and the binary bun.lockb can’t be converted, and 3va says why.

  • Version resolution. Without a lockfile, ranges resolve to the highest matching version, as with npm. 3va install <pkg> saves ^x.y.z (respecting save-prefix/save-exact).

  • CI. 3va ci fails when package.json and 3va-lock.json disagree, and names the package, instead of silently re-resolving.

  • Malware scanner. It rejects packages whose code mentions risky things (child_process, .env, eval, curl | sh). Most mainstream tooling passes now (Vite, rolldown, TypeScript 6, @types/node, Expo, the Tauri CLI), but oxlint, commander and several Expo/Metro packages are still rejected. Review a rejected package, then trust that exact version:

    { "3va": { "trusted": ["oxlint@1.85.0"], "trustedIntegrity": { "oxlint@1.85.0": "sha512-…" } } }

    The pin expires by itself: when the version changes, the package is scanned again. trustedIntegrity also pins the bytes. A pin without a version never expires, and the install warns about it. --no-scan still exists, but it turns the scan off for every package.

  • The scanner is version-sensitive. A routine upgrade can suddenly fail to install (we saw typescript@5.9.3 rejected while 6.0.3 passed).

  • Install scripts don’t run unless you allow them, per package:

    { "3va": { "onlyBuiltDependencies": ["esbuild", "better-sqlite3"] } }

    (pnpm’s onlyBuiltDependencies and Bun’s trustedDependencies are read too.) Even then, a script that is a single JavaScript file runs inside the sandbox, and a shell command is refused:

    ⚠ better-sqlite3@12.2.0 install: "prebuild-install || node-gyp rebuild --release" is a shell command, not a single JavaScript entry point. 3va will not hand a package-authored string to a shell, even for an allowlisted package.

    So better-sqlite3, sharp, bcrypt and Prisma still end up without their binary. esbuild’s postinstall is a JS file, so it runs: ⚙ postinstall script ran sandboxed for esbuild@0.25.12. 3VA_ALLOW_SCRIPTS=1 no longer enables scripts.

    To see which installed packages ship install scripts, run 3va doctor --compat:

    1 installed package(s) ship install-time scripts. 3va never runs them unless the project lists the package in "3va"."onlyBuiltDependencies": core-js@2.6.12 postinstall (not run)
  • Prebuilt native binaries are installed. Platform-specific optional dependencies (@esbuild/*, @rollup/rollup-*, @next/swc-*, lightningcss-*, @tauri-apps/cli-*) are picked by os, cpu and libc, so a glibc Linux gets only the -gnu build.

  • node_modules/.bin is created, but several CLIs in it don’t run under 3va yet: tsc (SyntaxError: Unexpected token ':'), vite, next. Keep them on Node.

  • Registry dependencies only. file:, git+https:, github: and tarball-URL specifiers are rejected with Invalid version 'file:./fake'. Use name@version format. Publish such packages to a (private) registry, or move them into a workspace and use workspace:*.

  • Mixed registries. The registry comes from --allow-net. Install JSR (jsr.io) and npm (registry.npmjs.org) packages in separate commands. A combined command exits with code 1 for the npm ones, and only the packages that did install are added to package.json.

  • What carries over: overrides / resolutions, .npmrc registries and auth tokens, workspace:* links, and package.json’s key order and other fields.

5. Runtime compatibility

What we checked beyond the frameworks:

WorksDoesn’t work yet
CommonJS and ESM, require("node:*")Top-level await returned the Promise on 2.9.0 (fixed in 2.12.1)
TypeScript with no config (types stripped, not checked) — but not tsc itselfnpm:, jsr: and https:// import specifiers
http, fs, path, child_process, crypto hashes and HMACpbkdf2Sync().toString("hex") and WebCrypto importKey("raw") for HMAC/PBKDF2 returned wrong results on 2.9.0 (fixed in 2.12.1)
fetch, Request/Response, WebSocket, web streams, URLPattern, structuredClonefs.promises.opendir, Module._nodeModulePaths, module.enableCompileCache
node:sqliteDeno.*, Bun.* globals

If a dependency fails with a TypeError: … is not a function on a Node API, that API is probably missing.

6. Tests

  • 3va test covers Jest’s core API: describe/test/expect, common matchers, beforeEach, toMatchSnapshot.
  • Snapshots are stored as .snap.json, and existing Jest .snap files are not reused.
  • jest.config.js, presets (jest-expo, ts-jest), jest.mock of modules and custom environments (jsdom) aren’t part of 3va’s runner. Keep those suites on Jest.
  • Tests run with permissions too. In our checks, file reads and writes inside the project worked, but env vars and child processes were denied. Grant them in package.json.

7. CI and deployment

  • Install 3va in CI from a pinned release and check the binary’s SHA-256 (3va --version prints it).
  • Containers: use 3va start --attach as the entrypoint so the supervisor is PID 1.
  • Absolute paths: workspace symlinks are absolute, so run 3va install inside the image, not on the host.
  • Logs: 3va 2.9.0 printed debug lines to stderr ([3va-requireFrom], [3va-tcp], [heartbeat]). The first two are gone in 2.12.0 and the [heartbeat] line (every 10 s) is gone in 2.12.1; on older versions filter them out of alerting.

8. Rollback plan

Keep the way back cheap:

  • Keep your original lockfile (package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lock) committed until the migration is done.
  • Keep the "3va" permissions block. Node, npm, pnpm, Yarn and Bun ignore it.
  • Keep scripts that call node alongside the 3va ones ("start": "node server.js", "start:3va": "3va run server.js") until production has run on 3va for a while.