Skip to content

Bundling skein

Most people never read this page: if you deploy the skein build image, or run next start with skein mounted as a route handler, bundling is already handled. You need this page when you own the bundler — an rspack/webpack server build, an esbuild bundle for Lambda, a Next.js app with an unusual config — and skein is inside its module graph.

The short version: skein is ESM-only, and everything except the CLI bundles cleanly. Two packages must stay external, and only if you use the langgraph.json on-ramp.

skein is ESM-only

Every @skein-js/* package is "type": "module" and ships a single ESM entry. There is no CommonJS build and there won't be one.

That does not mean you can't require() it. Each library package exposes a default export condition, so Node's require(esm) resolves it:

js
const { embedPostgresGraphs } = require("@skein-js/runtime"); // works on Node 20.19+ / 22.12+

require(esm) landed unflagged in Node 20.19 and 22.12. On an older Node you get ERR_REQUIRE_ESM telling you to use import() instead — which is the honest answer, and what a CJS-emitting bundler should be configured to do. Plain import works on any Node ≥ 20.

Before 0.10.0 the packages declared only types + import conditions, so require() failed with ERR_PACKAGE_PATH_NOT_EXPORTED — which reads like the package is broken rather than like a module format mismatch. If you're pinned below 0.10.0, that's the fix.

The skein-js CLI keeps the old shape deliberately: it has no exports, and its entry point runs a command as a side effect. require()-ing it should fail.

require() does not tree-shake. require("@skein-js/runtime") eagerly loads @skein-js/config and therefore @langchain/langgraph-api, even if you only use embedPostgresGraphs and never touch a langgraph.json. It works, it just costs ~0.5s of cold start. Prefer import where your toolchain allows it.

What must stay external

PackageBundle it?Why
@langchain/langgraph-apiNo — mark externalskein's graph loader import()s a path computed at runtime; no bundler can follow that
@typescript/vfsNo — mark externalpulled in by the same loader
skein-js (the CLI)Nevera bin with no exports and top-level await; importing it runs a command
every other @skein-js/*Yes — bundles cleanlyincluding @skein-js/storage-postgres as of 0.10.0

The first two only matter if you point skein at a langgraph.json (buildRuntime, the CLI, the { config } form of any adapter). If you embed graphs in code — embedPostgresGraphs, embedInMemoryGraphs, the { deps } form — nothing reaches them: @langchain/langgraph-api is loaded with await import() at the two points that genuinely need it (analysing a graph's schema, and adapting a user's Auth instance), and the in-memory runtime loader is behind a dynamic import on the { config } branch. So on an embedded path they are never in the module graph at all, rather than being present and merely tree-shakeable.

That is asserted, not asserted-by-comment: packages/test-support/src/static-imports.test.ts walks each adapter's built output — following @skein-js/* edges into their own dist — and fails if @langchain/langgraph-api, @typescript/vfs, or superjson is statically reachable. The walk is transitive because the regression it caught was: no adapter imported @langchain/langgraph-api, but every adapter imported @skein-js/server-kit, which imported the @skein-js/config barrel for one error class, and that barrel imported @langchain/langgraph-api.

One trade-off worth knowing: because @langchain/langgraph-api is now loaded on demand, a bundling mistake around it (the serverExternalPackages config below) surfaces when something first asks for a graph schema rather than at startup. The container boots and passes its probes, and GET /assistants/{id}/schemas returns a 500. Bake your schemas at build time (skein build does) and the path is never taken at all.

Two consequences for the public API, both of which exist to keep that graph clean:

  • SkeinConfigError is importable from @skein-js/config/errors as well as the root, and internal code uses the subpath. The root barrel is the langgraph.json loader.
  • readLanggraphDevState / loadSnapshotIntoStore / describeSnapshot live at @skein-js/server-kit/dev, not on the root barrel — they carry superjson and node:fs/promises, and only skein dev / skein import call them. They are deliberately not re-exported from the root or from @skein-js/express: a re-export is still a static import, which would undo the split.

Copy-paste configs

Next.js (next.config.mjs):

js
export default {
  serverExternalPackages: ["@langchain/langgraph-api", "@typescript/vfs"],
};

webpack / rspack (server build):

js
export default {
  target: "node",
  externals: [
    { "@langchain/langgraph-api": "commonjs @langchain/langgraph-api" },
    { "@typescript/vfs": "commonjs @typescript/vfs" },
  ],
};

esbuild:

bash
esbuild server.ts --bundle --platform=node --format=esm \
  --external:@langchain/langgraph-api --external:@typescript/vfs

Prefer --format=esm if you can. With --format=cjs, anything the bundle require()s at runtime still needs Node 20.19+, per above.

The "Critical dependency" warning

webpack and rspack emit this when they meet skein's graph loader:

text
Critical dependency: the request of a dependency is an expression

It's expected and harmless — that expression is the import() of your graph module, resolved from langgraph.json at runtime. Externalizing @langchain/langgraph-api removes most of it; to silence the rest:

js
// next.config.mjs
export default {
  serverExternalPackages: ["@langchain/langgraph-api", "@typescript/vfs"],
  webpack: (config) => {
    config.ignoreWarnings = [
      ...(config.ignoreWarnings ?? []),
      { message: /Critical dependency: the request of a dependency is an expression/ },
    ];
    return config;
  },
};

Postgres migrations are compiled in

@skein-js/storage-postgres needs no skein-side externals. Its schema migrations ship as string constants inside dist/index.js, so the package makes no filesystem access at runtime — it imports only node:crypto, pg, and @skein-js/core. (pg itself has one wrinkle — see below.)

Before 0.10.0 it located its migrations/ directory with new URL("../migrations", import.meta.url) and handed it to node-pg-migrate. Bundlers rewrite import.meta.url to the output location, so a bundled build looked fine until boot, then failed to find its own SQL against a real database. If you hit that on an older version, externalize @skein-js/storage-postgres (which then has to be present in node_modules at runtime) or upgrade.

Migrations still run automatically on boot, tracked in a skein_migrations table and serialized by a Postgres advisory lock — see deploy.md and storage.md.

One caveat, from pg rather than skein

pg has an optional native binding it reaches for at runtime: pg/lib/native/client.js does require('pg-native'), and pg-native is an optional peer that is normally not installed. pg wraps that call in a try/catch specifically so bundlers tolerate it, and esbuild does — it leaves a runtime require and emits nothing. webpack and rspack are stricter and report:

text
Module not found: Can't resolve 'pg-native'

You do not want the native binding; tell the bundler to ignore it:

js
// webpack / rspack
import webpack from "webpack";
export default {
  plugins: [new webpack.IgnorePlugin({ resourceRegExp: /^pg-native$/ })],
};

Next.js keeps pg on its built-in server-externals list, so this never surfaces there.

What skein build inlines vs. externalizes

The section above is about bundling skein. This one is about the bundler skein itself runs: skein build compiles your graphs (plus auth, custom embed, custom telemetry sinks) into .skein/build, and the split it makes there is the reason the production image is small and the monorepo case works at all.

Inlined into the artifact — your own source, including anything reached through a tsconfigpaths alias or a workspace link (@myorg/js, the Nx/Turborepo/pnpm-workspace pattern). Those files exist nowhere a package manager could install them from, so resolving them once on the build host is what dissolves the "my Docker build context doesn't contain my monorepo" problem.

Left external and pinned — every published node_modules package. skein build records each one at the exact version installed on the build host and writes it into the artifact's package.json, which the image installs with npm install --omit=dev. Externalizing is not a limitation to work around; it is load-bearing:

  • One copy of each library. The image installs skein-js, which brings @langchain/langgraph and @langchain/core, and that runtime is what imports your graph bundle. Inline @langchain/core into the graph and there are two copies: instanceof BaseMessage starts failing, and config/callbacks propagate through a different AsyncLocalStorage than the one the runtime reads.
  • Native addons can't be inlined. pg-native, sharp, better-sqlite3 and friends are platform binaries; they have to be installed for the image's platform.
  • Package-relative asset reads survive. A bundler rewrites import.meta.url/__dirname to the output location, which breaks packages that load workers, wasm, or data files from beside themselves (pdfjs, tiktoken, …) — the same hazard that made skein compile its own SQL in.
  • Cheaper rebuilds. COPY package.json + install is a cached Docker layer; a graph edit re-ships only the bundle.

The one thing a bundler structurally cannot see is a package imported by name at runtimeinitChatModel doing import("@langchain/" + provider), a plugin loaded from config. Those never appear in the module graph, so declare them under dependencies in langgraph.json:

json
{
  "graphs": { "agent": "./src/graph.ts:graph" },
  "dependencies": ["@langchain/openai"]
}

(skein pins the packages behind a declared store.index.embed provider and a declared telemetry provider for you — the field is for the ones only your code knows about.)

skein build fails on the host if the artifact would ship an import it does not install, so a missing pin is a build-time error with a package name in it rather than an ERR_MODULE_NOT_FOUND from inside docker build.

See also

  • deploy.md — deploying the built image, env vars, probes, scaling
  • embedding.md — the in-code on-ramp, which avoids the graph loader entirely
  • storage.md — the Postgres driver and its schema