feat(runtime): add experimental Node.js 24 and 26 task runtimes (#4085)
⚒️ Publish Worker (v4) / build (supervisor) (push) Has been cancelled

## Summary

Adds experimental Node.js 24 and 26 task runtimes through the
`experimental-node-24` and `experimental-node-26` config values.

Existing runtime defaults and the `node`, `node-22`, and `bun` behavior
remain unchanged. The unprefixed `node-24` and `node-26` config values
remain unavailable until the runtimes are ready for general use.

## Design

Experimental config values normalize to canonical runtime identifiers
before build manifests are created, keeping deployment metadata and
execution behavior consistent. Kubernetes task pods also use the
runtime-default seccomp profile so modern Node.js versions fall back
from io_uring to checkpoint-compatible system calls.
This commit is contained in:
Chris Arderne
2026-07-16 12:19:03 +01:00
committed by GitHub
parent 43250522a5
commit d7ec75d5ad
18 changed files with 337 additions and 39 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@trigger.dev/core": patch
"trigger.dev": patch
---
Add experimental Node.js 24 and 26 task runtimes. Set `runtime` to `experimental-node-24` or `experimental-node-26` in `trigger.config.ts`.
+2
View File
@@ -120,6 +120,7 @@ class ManagedSupervisor {
snapshotPollIntervalSeconds: env.RUNNER_SNAPSHOT_POLL_INTERVAL_SECONDS,
additionalEnvVars: env.RUNNER_ADDITIONAL_ENV_VARS,
dockerAutoremove: env.DOCKER_AUTOREMOVE_EXITED_CONTAINERS,
checkpointsEnabled: !!env.TRIGGER_CHECKPOINT_URL,
} satisfies WorkloadManagerOptions;
this.resourceMonitor = env.RESOURCE_MONITOR_ENABLED
@@ -615,6 +616,7 @@ class ManagedSupervisor {
projectId: message.project.id,
deploymentFriendlyId: message.deployment.friendlyId,
deploymentVersion: message.backgroundWorker.version,
runtime: message.backgroundWorker.runtime,
runId: message.run.id,
runFriendlyId: message.run.friendlyId,
version: message.version,
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import {
BLOCK_IO_URING_SECCOMP_PROFILE,
withBlockIoUringSeccompProfile,
} from "./kubernetesPodSpec.js";
const basePodSpec = {
restartPolicy: "Never" as const,
automountServiceAccountToken: false,
securityContext: {
runAsNonRoot: true,
runAsUser: 1000,
fsGroup: 1000,
},
};
describe("withBlockIoUringSeccompProfile", () => {
it("adds the Localhost io_uring profile for node-24 and above, preserving pod security defaults", () => {
for (const runtime of ["node-24", "node-26", "node-30", "experimental-node-24"]) {
const podSpec = withBlockIoUringSeccompProfile(basePodSpec, runtime);
expect(podSpec).toMatchObject({
...basePodSpec,
securityContext: {
...basePodSpec.securityContext,
seccompProfile: {
type: "Localhost",
localhostProfile: BLOCK_IO_URING_SECCOMP_PROFILE,
},
},
});
}
});
it("leaves the pod spec unchanged for runtimes that do not create io_uring fds", () => {
for (const runtime of ["node", "node-22", "bun", undefined, null, ""]) {
expect(withBlockIoUringSeccompProfile(basePodSpec, runtime)).toEqual(basePodSpec);
}
});
});
@@ -14,6 +14,7 @@ import { PlacementTagProcessor } from "@trigger.dev/core/v3/serverOnly";
import { env } from "../env.js";
import { type K8sApi, createK8sApi, type k8s } from "../clients/kubernetes.js";
import { getRunnerId } from "../util.js";
import { withBlockIoUringSeccompProfile } from "./kubernetesPodSpec.js";
type ResourceQuantities = {
[K in "cpu" | "memory" | "ephemeral-storage"]?: string;
@@ -105,6 +106,11 @@ export class KubernetesWorkloadManager implements WorkloadManager {
const runnerId = getRunnerId(opts.runFriendlyId, opts.nextAttemptNumber);
try {
const basePodSpec = this.addPlacementTags(this.#defaultPodSpec, opts.placementTags);
const podSpec = this.opts.checkpointsEnabled
? withBlockIoUringSeccompProfile(basePodSpec, opts.runtime)
: basePodSpec;
await this.k8s.core.createNamespacedPod({
namespace: this.namespace,
body: {
@@ -119,7 +125,7 @@ export class KubernetesWorkloadManager implements WorkloadManager {
},
},
spec: {
...this.addPlacementTags(this.#defaultPodSpec, opts.placementTags),
...podSpec,
affinity: this.#getAffinity(opts),
tolerations: this.#getScheduleTolerations(this.#isScheduledRun(opts)),
terminationGracePeriodSeconds: 60 * 60,
@@ -0,0 +1,33 @@
import type { k8s } from "../clients/kubernetes.js";
/**
* Relative path (kubelet seccomp root) of the profile blocking only io_uring
* syscalls. Must match the profile deployed to worker nodes.
*/
export const BLOCK_IO_URING_SECCOMP_PROFILE = "profiles/block-io-uring.json";
/**
* Node >= 24 always creates io_uring fds, which can't be checkpointed. Blocking
* io_uring_setup makes libuv fall back to epoll. Other runtimes don't need this,
* so the profile is only applied for node-24+. Tolerates an "experimental-" prefix.
*/
export function withBlockIoUringSeccompProfile(
podSpec: Omit<k8s.V1PodSpec, "containers">,
runtime: string | null | undefined
): Omit<k8s.V1PodSpec, "containers"> {
const match = runtime ? /^(?:experimental-)?node-(\d+)$/.exec(runtime) : null;
if (!match || Number(match[1]) < 24) {
return podSpec;
}
return {
...podSpec,
securityContext: {
...podSpec.securityContext,
seccompProfile: {
type: "Localhost",
localhostProfile: BLOCK_IO_URING_SECCOMP_PROFILE,
},
},
};
}
@@ -16,6 +16,8 @@ export interface WorkloadManagerOptions {
snapshotPollIntervalSeconds?: number;
additionalEnvVars?: Record<string, string>;
dockerAutoremove?: boolean;
// Whether CRIU checkpoint/restore is enabled for this deployment
checkpointsEnabled?: boolean;
}
export interface WorkloadManager {
@@ -40,6 +42,8 @@ export interface WorkloadManagerCreateOptions {
projectId: string;
deploymentFriendlyId: string;
deploymentVersion: string;
// Canonical runtime identifier (e.g. "node", "node-22", "node-24")
runtime?: string;
runId: string;
runFriendlyId: string;
snapshotId: string;
@@ -597,6 +597,7 @@ export class DequeueSystem {
id: result.worker.id,
friendlyId: result.worker.friendlyId,
version: result.worker.version,
runtime: result.worker.runtime ?? undefined,
},
// TODO: use a discriminated union schema to differentiate between dequeued runs in dev and in deployed environments.
// Would help make the typechecking stricter
+1 -1
View File
@@ -94,7 +94,7 @@ Examples:
)
.option(
"-r, --runtime <runtime>",
"Which runtime to use for the project. Currently only supports node and bun",
"Which runtime to use for the project. Supported: node, node-22, bun",
"node"
)
.option("--skip-package-install", "Skip installing the @trigger.dev/sdk package")
+60
View File
@@ -0,0 +1,60 @@
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { loadConfig } from "./config.js";
const projectDirs: string[] = [];
afterEach(async () => {
await Promise.all(projectDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
async function createProject(runtime?: string) {
const cwd = await mkdtemp(join(tmpdir(), "trigger-runtime-config-"));
projectDirs.push(cwd);
await mkdir(join(cwd, "trigger"));
await writeFile(join(cwd, "package.json"), JSON.stringify({ name: "runtime-config-test" }));
await writeFile(join(cwd, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n");
await writeFile(
join(cwd, "trigger.config.ts"),
`export default {
project: "proj_runtime_config_test",
maxDuration: 60,
dirs: ["./trigger"],
${runtime === undefined ? "" : `runtime: ${JSON.stringify(runtime)},`}
};
`
);
return cwd;
}
describe("loadConfig runtime", () => {
it.each([
["experimental-node-24", "node-24"],
["experimental-node-26", "node-26"],
] as const)("normalizes %s before returning the resolved config", async (runtime, expected) => {
const cwd = await createProject(runtime);
await expect(loadConfig({ cwd, warn: false })).resolves.toMatchObject({ runtime: expected });
});
it("keeps node as the default", async () => {
const cwd = await createProject();
await expect(loadConfig({ cwd, warn: false })).resolves.toMatchObject({ runtime: "node" });
});
it.each(["node-24", "node-26", "node-23"])(
"rejects unsupported public runtime %s while loading config",
async (runtime) => {
const cwd = await createProject(runtime);
await expect(loadConfig({ cwd, warn: false })).rejects.toThrowError(
new RegExp(`Unsupported runtime "${runtime}" in trigger\\.config`)
);
}
);
});
+25 -13
View File
@@ -6,7 +6,11 @@ import type {
TriggerConfig,
} from "@trigger.dev/core/v3";
import type { ResolvedConfig } from "@trigger.dev/core/v3/build";
import { DEFAULT_RUNTIME } from "@trigger.dev/core/v3/build";
import {
DEFAULT_RUNTIME,
isExperimentalConfigRuntime,
resolveBuildRuntime,
} from "@trigger.dev/core/v3/build";
import * as c12 from "c12";
import { defu } from "defu";
import type * as esbuild from "esbuild";
@@ -170,6 +174,24 @@ async function resolveConfig(
);
}
const config =
"config" in result.config ? (result.config.config as TriggerConfig) : result.config;
const features = featuresFromCompatibilityFlags(
["run_engine_v2" as const].concat(config.compatibilityFlags ?? [])
);
const defaultRuntime: BuildRuntime = features.run_engine_v2 ? "node" : DEFAULT_RUNTIME;
const configuredRuntime = overrides?.runtime ?? config.runtime ?? defaultRuntime;
const runtime = resolveBuildRuntime(configuredRuntime);
if (warn && isExperimentalConfigRuntime(configuredRuntime)) {
prettyWarning(
`The "${configuredRuntime}" runtime is experimental and may change before general availability.`
);
}
validateConfig(config, warn);
const packageJsonPath = await resolvePackageJSON(cwd);
const tsconfigPath = await safeResolveTsConfig(cwd);
const lockfilePath = await resolveLockfile(cwd);
@@ -181,24 +203,14 @@ async function resolveConfig(
? dirname(packageJsonPath)
: cwd;
const config =
"config" in result.config ? (result.config.config as TriggerConfig) : result.config;
validateConfig(config, warn);
let dirs = config.dirs ? config.dirs : await autoDetectDirs(workingDir);
dirs = dirs.map((dir) => resolveTriggerDir(dir, workingDir));
const features = featuresFromCompatibilityFlags(
["run_engine_v2" as const].concat(config.compatibilityFlags ?? [])
);
const defaultRuntime: BuildRuntime = features.run_engine_v2 ? "node" : DEFAULT_RUNTIME;
const mergedConfig = defu(
{
workingDir,
runtime,
configFile: result.configFile,
packageJsonPath,
tsconfigPath,
@@ -230,7 +242,7 @@ async function resolveConfig(
...mergedConfig,
dirs: Array.from(new Set(dirs)),
instrumentedPackageNames: getInstrumentedPackageNames(mergedConfig),
runtime: mergedConfig.runtime,
runtime,
};
}
@@ -0,0 +1,28 @@
import type { BuildRuntime } from "@trigger.dev/core/v3/schemas";
import { describe, expect, it } from "vitest";
import { generateContainerfile } from "./buildImage.js";
const nodeImages: Array<[BuildRuntime, string]> = [
[
"node-24",
"node:24.18.0-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d",
],
[
"node-26",
"node:26.4.0-bookworm-slim@sha256:ec82d089a8ae2cf02628da7b34ea57dc357b24db724d557fe2d240e6beb659c1",
],
];
describe("generateContainerfile", () => {
it.each(nodeImages)("selects the pinned multiplatform image for %s", async (runtime, image) => {
const containerfile = await generateContainerfile({
runtime,
build: {},
image: undefined,
indexScript: "index.js",
entrypoint: "entrypoint.js",
});
expect(containerfile).toContain(`FROM ${image} AS base`);
});
});
+7 -1
View File
@@ -692,6 +692,10 @@ const BASE_IMAGE: Record<BuildRuntime, string> = {
node: "node:21.7.3-bookworm-slim@sha256:dfc05dee209a1d7adf2ef189bd97396daad4e97c6eaa85778d6f75205ba1b0fb",
"node-22":
"node:22.16.0-bookworm-slim@sha256:048ed02c5fd52e86fda6fbd2f6a76cf0d4492fd6c6fee9e2c463ed5108da0e34",
"node-24":
"node:24.18.0-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d",
"node-26":
"node:26.4.0-bookworm-slim@sha256:ec82d089a8ae2cf02628da7b34ea57dc357b24db724d557fe2d240e6beb659c1",
};
const DEFAULT_PACKAGES = ["busybox", "ca-certificates", "dumb-init", "git", "openssl"];
@@ -699,7 +703,9 @@ const DEFAULT_PACKAGES = ["busybox", "ca-certificates", "dumb-init", "git", "ope
export async function generateContainerfile(options: GenerateContainerfileOptions) {
switch (options.runtime) {
case "node":
case "node-22": {
case "node-22":
case "node-24":
case "node-26": {
return await generateNodeContainerfile(options);
}
case "bun": {
+22 -18
View File
@@ -1,26 +1,30 @@
import { type Defu } from "defu";
import type { Prettify } from "ts-essentials";
import { CompatibilityFlag, CompatibilityFlagFeatures, TriggerConfig } from "../config.js";
import { BuildRuntime } from "../schemas/build.js";
import { ResolveEnvironmentVariablesFunction } from "../types/index.js";
import type { CompatibilityFlag, CompatibilityFlagFeatures, TriggerConfig } from "../config.js";
import type { BuildRuntime } from "../schemas/build.js";
import type { ResolveEnvironmentVariablesFunction } from "../types/index.js";
export type ResolvedConfig = Prettify<
Defu<
TriggerConfig,
[
{},
{
runtime: BuildRuntime;
dirs: string[];
tsconfig: string;
build: {
jsx: { factory: string; fragment: string; automatic: true };
} & Omit<NonNullable<TriggerConfig["build"]>, "jsx">;
compatibilityFlags: CompatibilityFlag[];
features: CompatibilityFlagFeatures;
},
]
Omit<
Defu<
TriggerConfig,
[
{},
{
runtime: BuildRuntime;
dirs: string[];
tsconfig: string;
build: {
jsx: { factory: string; fragment: string; automatic: true };
} & Omit<NonNullable<TriggerConfig["build"]>, "jsx">;
compatibilityFlags: CompatibilityFlag[];
features: CompatibilityFlagFeatures;
},
]
>,
"runtime"
> & {
runtime: BuildRuntime;
workingDir: string;
workspaceDir: string;
packageJsonPath: string;
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { BuildManifest, BuildRuntime, ConfigRuntime, WorkerManifest } from "../schemas/build.js";
import { isExperimentalConfigRuntime, resolveBuildRuntime } from "./runtime.js";
describe("runtime configuration", () => {
it.each(["node", "node-22", "experimental-node-24", "experimental-node-26", "bun"] as const)(
"accepts %s as a public config runtime",
(runtime) => {
expect(ConfigRuntime.parse(runtime)).toBe(runtime);
}
);
it.each([
["experimental-node-24", "node-24"],
["experimental-node-26", "node-26"],
["node", "node"],
["node-22", "node-22"],
["bun", "bun"],
] as const)("normalizes %s to %s", (runtime, expected) => {
expect(resolveBuildRuntime(runtime)).toBe(expected);
});
it.each(["node-24", "node-26"] as const)(
"keeps internal runtime %s out of the public config schema",
(runtime) => {
expect(ConfigRuntime.safeParse(runtime).success).toBe(false);
expect(BuildRuntime.safeParse(runtime).success).toBe(true);
expect(() => resolveBuildRuntime(runtime)).toThrowError(/Unsupported runtime/);
}
);
it("rejects unsupported config runtimes with a clear error", () => {
expect(() => resolveBuildRuntime("node-23")).toThrowError(
/Unsupported runtime "node-23" in trigger\.config\. Supported runtimes:/
);
});
it.each(["experimental-node-24", "experimental-node-26"] as const)(
"keeps %s out of internal runtime schemas",
(runtime) => {
expect(BuildRuntime.safeParse(runtime).success).toBe(false);
expect(BuildManifest.shape.runtime.safeParse(runtime).success).toBe(false);
expect(WorkerManifest.shape.runtime.safeParse(runtime).success).toBe(false);
expect(isExperimentalConfigRuntime(runtime)).toBe(true);
}
);
});
+39 -2
View File
@@ -1,15 +1,48 @@
import { join } from "node:path";
import { pathToFileURL } from "url";
import { BuildRuntime } from "../schemas/build.js";
import { BuildRuntime, ConfigRuntime } from "../schemas/build.js";
import { dedupFlags } from "./flags.js";
import { homedir } from "node:os";
export const DEFAULT_RUNTIME = "node" satisfies BuildRuntime;
export type ExperimentalConfigRuntime = "experimental-node-24" | "experimental-node-26";
export function isExperimentalConfigRuntime(
runtime: unknown
): runtime is ExperimentalConfigRuntime {
return runtime === "experimental-node-24" || runtime === "experimental-node-26";
}
export function resolveBuildRuntime(runtime: unknown): BuildRuntime {
const parsedRuntime = ConfigRuntime.safeParse(runtime);
if (!parsedRuntime.success) {
const value = typeof runtime === "string" ? `"${runtime}"` : String(runtime);
throw new Error(
`Unsupported runtime ${value} in trigger.config. Supported runtimes: ${ConfigRuntime.options.join(
", "
)}.`
);
}
switch (parsedRuntime.data) {
case "experimental-node-24":
return "node-24";
case "experimental-node-26":
return "node-26";
default:
return parsedRuntime.data;
}
}
export function binaryForRuntime(runtime: BuildRuntime): string {
switch (runtime) {
case "node":
case "node-22":
case "node-24":
case "node-26":
return "node";
case "bun":
return "bun";
@@ -22,6 +55,8 @@ export function execPathForRuntime(runtime: BuildRuntime): string {
switch (runtime) {
case "node":
case "node-22":
case "node-24":
case "node-26":
return process.execPath;
case "bun":
if (typeof process.env.BUN_INSTALL === "string") {
@@ -50,7 +85,9 @@ export function execOptionsForRuntime(
): string {
switch (runtime) {
case "node":
case "node-22": {
case "node-22":
case "node-24":
case "node-26": {
const importEntryPoint = options.loaderEntryPoint
? `--import=${pathToFileURL(options.loaderEntryPoint).href}`
: undefined;
+2 -2
View File
@@ -7,7 +7,7 @@ import type {
AnyOnInitHookFunction,
AnyOnStartHookFunction,
AnyOnSuccessHookFunction,
BuildRuntime,
ConfigRuntime,
RetryOptions,
} from "./index.js";
import type { LogLevel } from "./logger/taskLogger.js";
@@ -44,7 +44,7 @@ export type TriggerConfig = {
/**
* @default "node"
*/
runtime?: BuildRuntime;
runtime?: ConfigRuntime;
/**
* Specify the project ref for your trigger.dev tasks. This is the project ref that you get when you create a new project in the trigger.dev dashboard.
+11 -1
View File
@@ -13,7 +13,17 @@ export const BuildTarget = z.enum(["dev", "deploy", "unmanaged"]);
export type BuildTarget = z.infer<typeof BuildTarget>;
export const BuildRuntime = z.enum(["node", "node-22", "bun"]);
export const ConfigRuntime = z.enum([
"node",
"node-22",
"experimental-node-24",
"experimental-node-26",
"bun",
]);
export type ConfigRuntime = z.infer<typeof ConfigRuntime>;
export const BuildRuntime = z.enum(["node", "node-22", "node-24", "node-26", "bun"]);
export type BuildRuntime = z.infer<typeof BuildRuntime>;
@@ -272,6 +272,8 @@ export const DequeuedMessage = z.object({
id: z.string(),
friendlyId: z.string(),
version: z.string(),
// Canonical runtime identifier (e.g. "node", "node-22", "node-24", "bun")
runtime: z.string().optional(),
}),
deployment: z.object({
id: z.string().optional(),