Heximon Logo
Deploy

Mixed-runtime split

Place one app's controllers across edge and node runtimes in a single deploy — the compiler partitions the DI graph by runtime, proves each edge tier is edge-safe before deploy, and emits separate functions routed by prefix. Vercel Functions and Netlify Functions.

A single Heximon app can deploy its controllers across two runtimes in one deployment: a read-heavy endpoint on a fast, global edge function and a write endpoint on a node function where the database lives. You mark one controller runtime: "edge"; the compiler does the rest.

This is something a route-rules-on-a-shared-bundle model cannot do. Nitro's vercel.functionRules split per-route function config (memory, regions) but symlink one shared bundle — every route has the same dependency closure by construction, so the runtime can't differ. Heximon's compile-time DI graph lets each tier carry its own closure, and — the real payoff — proves the edge tier's closure is edge-safe before you deploy.

Opt in on the controller

runtime is a field on the controller config — the same type argument that already carries prefix / middlewares / errorFilters / scopes. The default is node; one field opts a controller, and its whole transitive dependency closure, onto the edge tier:

feed.controller.ts
import type { Controller, Get } from "@heximon/http";
import { IdService } from "../shared/id-service";

export class FeedController implements Controller<{ prefix: "/feed"; runtime: "edge" }> {
  public constructor(private readonly ids: IdService) {}

  public async list(_action: Get<"/">): Promise<readonly FeedItem[]> {
    // … edge-safe, read-only work …
  }
}

A controller with no runtime (or runtime: "node") stays on the node tier — the existing behavior, unchanged.

A hybrid deploy target

The split lights up only on a hybrid target — one that owns both a node and an edge tier in one deployment. Set it on heximon.platform:

heximon.config.ts
import { defineHeximonConfig } from "@heximon/build";
import { HttpPlugin } from "@heximon/http/compiler";
import { VercelFunctionsStrategy } from "@heximon/vercel";

export default defineHeximonConfig({
  platform: new VercelFunctionsStrategy(),
  plugins: [new HttpPlugin()],
});

VercelFunctionsStrategy (Vercel Functions + Edge Functions) and NetlifyFunctionsStrategy (Netlify Functions + Edge Functions) are the hybrid targets. The placement is a build check against the resolved deploy target:

  • a hybrid target honors both runtime values;
  • an edge-only target (Cloudflare, Deno, Vercel Edge, Netlify Edge) already runs the whole app on edge — runtime: "edge" is redundant and allowed, runtime: "node" is a build error (no node tier);
  • a node-only target (a plain Node server, AWS Lambda) has no edge tier — runtime: "edge" is a build error.

What vp build emits

On vp build against a hybrid target, the compiler:

  1. Partitions the DI graph by runtime — it groups controllers by their runtime and computes each group's transitive provider closure (the providers each tier's controllers reach through their constructor signatures). A service injected by controllers on both tiers lands in both closures, instantiated per function (the two functions share no memory — a stateful shared service needs an external store).
  2. Bundles each tier separately. Two bundler passes are mandatory: the edge function is self-contained (everything inlined, edge resolve conditions), the node function keeps its dependencies external (resolved from node_modules). A shared dependency can't be both, so the tiers bundle independently — dist/<output>.js (node) + a self-contained dist/edge.js.
  3. Emits one deploy tree, routed by prefix. For Vercel, a single .vercel/output/ tree with both functions:
dist/.vercel/output/
  config.json                          # routes: /feed → /edge, catch-all → /__server
  functions/__server.func/index.js     # the node bundle
  functions/edge.func/index.js         # the self-contained edge bundle (.vc-config: { runtime: "edge" })
config.json
"routes": [
  { "handle": "filesystem" },
  { "src": "/feed(/.*)?", "dest": "/edge" },
  { "src": "/(.*)", "dest": "/__server" }
]

/feed routes to the edge function; everything else falls through to the node catch-all. One vercel deploy --prebuilt. Netlify is the same idea with a different shape: the node Functions catch-all plus a prefix-routed Netlify Edge Function in .netlify/edge-functions/.

The guarantee: provably edge-safe before deploy

The edge tier bundles under an unenv-backed resolve policy (the unjs node:* compatibility layer wrangler uses). Two effects:

  • A polyfillable node: builtin works on edge. node:cryptoglobalThis.crypto, node:buffer, parts of node:stream — the bundle inlines the polyfill so the import bundles and runs, instead of being left external and breaking at runtime. More controllers can legitimately go edge.
  • A node-only builtin in an edge closure fails the build. node:fs, node:net, a socket-level driver — the edge runtime can't run it, so an edge-closure import of one is a hard build error that names the offending module and the edge tier, attributed to the importing file. "the edge tier imports node:fs, which this edge runtime cannot run (no working polyfill)" is a compile diagnostic, not a 3am crash.

(Netlify Edge runs on Deno, whose broad native node: compatibility means its edge tier keeps the simpler node:-external policy.)

Dev, test, and the constraints

There is no in-process edge emulator, so vp dev and createTestApp run the whole app on Node — the edge partition is a strict subset of what Node runs, so it is faithful. Author and test exactly as for any other app; the split is a vp build concern.

Two v1 constraints:

  • Edge controllers are authored inline, not contract-mode — a contract-mode controller carries its prefix in the contract value (which the compiler does not read), so it has no static prefix to route on.
  • Cross-runtime calls use ClientTransport.fetch(baseUrl), not the in-process InternalClient. On a split, the edge function's router carries only the edge tier's routes, so an in-process call to a node route can't reach it — the compiler rejects an edge closure that reaches the InternalClient seam, and steers you to an explicit fetch (a real HTTP request the platform routes to the other function).

See also

  • Deploy overviewvp build and the deploy strategies this split builds on.
  • Controllers — the base Controller config (prefix, middlewares, errorFilters, scopes) that runtime sits alongside.
  • Mixed-runtime split (Vercel) — the /feed (edge) + /posts (node) split end to end, with the vercel deploy --prebuilt output tree and a test proving the polyfillable-node:crypto-works / node-only-builtin-fails-the-build guarantee.
  • Mixed-runtime split (Netlify) — the same split on NetlifyFunctionsStrategy.
Copyright © 2026