Skip to content
Draft
6 changes: 6 additions & 0 deletions .changeset/experimental-node-runtimes.md
Original file line number Diff line number Diff line change
@@ -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 changes: 2 additions & 0 deletions apps/supervisor/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
40 changes: 40 additions & 0 deletions apps/supervisor/src/workloadManager/kubernetes.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
8 changes: 7 additions & 1 deletion apps/supervisor/src/workloadManager/kubernetes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Comment thread
carderne marked this conversation as resolved.

await this.k8s.core.createNamespacedPod({
namespace: this.namespace,
body: {
Expand All @@ -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,
Expand Down
33 changes: 33 additions & 0 deletions apps/supervisor/src/workloadManager/kubernetesPodSpec.ts
Original file line number Diff line number Diff line change
@@ -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,
},
},
};
}
4 changes: 4 additions & 0 deletions apps/supervisor/src/workloadManager/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-v3/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
60 changes: 60 additions & 0 deletions packages/cli-v3/src/config.test.ts
Original file line number Diff line number Diff line change
@@ -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`)
);
}
);
});
38 changes: 25 additions & 13 deletions packages/cli-v3/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand Down Expand Up @@ -230,7 +242,7 @@ async function resolveConfig(
...mergedConfig,
dirs: Array.from(new Set(dirs)),
instrumentedPackageNames: getInstrumentedPackageNames(mergedConfig),
runtime: mergedConfig.runtime,
runtime,
};
}

Expand Down
28 changes: 28 additions & 0 deletions packages/cli-v3/src/deploy/buildImage.test.ts
Original file line number Diff line number Diff line change
@@ -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`);
});
});
8 changes: 7 additions & 1 deletion packages/cli-v3/src/deploy/buildImage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -692,14 +692,20 @@ 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"];

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": {
Expand Down
40 changes: 22 additions & 18 deletions packages/core/src/v3/build/resolvedConfig.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Loading