Files
triggerdotdev--trigger.dev/packages/cli-v3/src/dev/devSession.ts
T
Matt Aitken 23016de179 feat(cli): warn on createRequire packages missing from deployed images (#4851)
## Summary

A package loaded with `createRequire(import.meta.url)("pkg")` is
invisible to esbuild: the call is never resolved, so the package is
neither bundled nor collected as an external to install in the deployed
image. The deploy succeeds with zero diagnostics and the task fails at
runtime with a module-not-found error, which can surface as something
far more confusing when a library maps errors coarsely (a database
driver loaded this way can look exactly like a connection failure). It
also works fine in `trigger dev` because the local `node_modules`
exists, making the production-only failure extra misleading.

Both `deploy` and `dev` builds now warn about this, pointing at the
exact file and line, with a note showing the exact config that fixes it:

```
▲ [WARNING] "mssql" is loaded with createRequire() but won't be available in the deployed image, so loading it will fail at runtime. The bundler can't follow createRequire() calls, so "mssql" is neither bundled into your code nor installed in the image. [plugin create-require-collector]

    src/db.ts:12:14:
      12 │ const mssql = createRequire(import.meta.url)("mssql");
         ╵               ^

  To fix this, install "mssql" into the image by adding the additionalPackages build extension to your trigger.config.ts:

    import { additionalPackages } from "@trigger.dev/build/extensions/core";

    export default defineConfig({
      // ...
      build: {
        extensions: [additionalPackages({ packages: ["mssql"] })],
      },
    });

  Alternatively, replace the createRequire() call with a static import so the package is bundled. Docs: https://trigger.dev/docs/config/extensions/additionalPackages
```

In `dev` the message instead explains that the code works locally but
deploys of it will fail, so the problem is caught while the code is
being written rather than after a deploy.

## How it works

An esbuild plugin scans the bundle's input files outside `node_modules`
for string-literal specifiers passed to `createRequire`-created require
functions: `createRequire(...)("pkg")`, `const req = createRequire(...);
req("pkg")`, `req.resolve("pkg")`, aliased imports, namespace access,
CJS destructuring, and dynamic `import("node:module")` bindings. Sources
are parsed with `@babel/parser` (already in the dependency tree), so
comments, strings, templates, regex literals and JSX can't confuse the
scan; a file that fails to parse is skipped. Relative paths and node
builtins never warn.

A usage only warns when the package will actually be missing from the
image. On deploys the resolved manifest externals are the source of
truth (extension-installed layers are already merged in when the warning
runs); `build.external` alone deliberately does not suppress, because
marking a package external installs nothing when nothing statically
imports it. In dev, which predicts a future deploy, suppression
additionally trusts what extensions declare they install, and stays
silent entirely when that can't be determined (an extension hook throws,
or an older `@trigger.dev/build`'s additionalPackages predates the
declaration hook), so dev never makes a false "deploys will fail" claim.
`additionalPackages` declares its packages via a new diagnostics-only
`BuildExtension` field, `installedPackagesForTarget`, which the bundler
ignores: bundling output is unchanged for existing projects.

Detection is name-based, module-level, and deliberately per-file:
computed specifiers, shadowed names, and require helpers imported from
other files are not followed (those degrade to today's behavior, an
unwarned runtime failure), and scanning is scoped to user code because
bundled libraries legitimately use optional-require patterns that would
drown real findings in noise. Packages named in build-layer install
commands (`RUN npm install ...`) are suppressed individually.

Deploys also now surface esbuild's own bundle warnings for user files
(for example `require()` with a non-literal argument), which were
previously discarded on the deploy path; `trigger dev` already showed
them.
2026-08-31 18:47:41 +01:00

249 lines
7.3 KiB
TypeScript

import type { ResolvedConfig } from "@trigger.dev/core/v3/build";
import type * as esbuild from "esbuild";
import type { CliApiClient } from "../apiClient.js";
import type { BundleResult } from "../build/bundle.js";
import {
bundleWorker,
createBuildManifestFromBundle,
getBundleResultFromBuild,
logBuildFailure,
logBuildWarnings,
} from "../build/bundle.js";
import {
createBuildContext,
notifyExtensionOnBuildComplete,
notifyExtensionOnBuildStart,
resolvePluginsForContext,
} from "../build/extensions.js";
import { createExternalsBuildExtension, resolveAlwaysExternal } from "../build/externals.js";
import {
collectCreateRequireWarningMessages,
CreateRequireCollector,
extensionInstalledPackageMatchers,
} from "../build/createRequireWarnings.js";
import { type DevCommandOptions } from "../commands/dev.js";
import { eventBus } from "../utilities/eventBus.js";
import { logger } from "../utilities/logger.js";
import type { EphemeralDirectory } from "../utilities/tempDirectories.js";
import { clearTmpDirs, getStoreDir, getTmpDir } from "../utilities/tempDirectories.js";
import { startDevOutput } from "./devOutput.js";
import { startWorkerRuntime } from "./devSupervisor.js";
import { writeJSONFile } from "../utilities/fileSystem.js";
import { join } from "node:path";
export type DevSessionOptions = {
name: string | undefined;
branch?: string;
dashboardUrl: string;
initialMode: "local";
showInteractiveDevSession: boolean | undefined;
rawConfig: ResolvedConfig;
rawArgs: DevCommandOptions;
client: CliApiClient;
onErr?: (error: Error) => void;
keepTmpFiles: boolean;
};
export type DevSessionInstance = {
stop: () => void;
};
export async function startDevSession({
rawConfig,
name,
branch,
rawArgs,
client,
dashboardUrl,
keepTmpFiles,
}: DevSessionOptions): Promise<DevSessionInstance> {
clearTmpDirs(rawConfig.workingDir, branch);
const destination = getTmpDir(rawConfig.workingDir, "build", keepTmpFiles, branch);
// Create shared store directory for deduplicating chunk files across rebuilds
const storeDir = getStoreDir(rawConfig.workingDir, keepTmpFiles, branch);
const runtime = await startWorkerRuntime({
name,
branch,
config: rawConfig,
args: rawArgs,
client,
dashboardUrl,
});
const stopOutput = startDevOutput({
name,
branch,
dashboardUrl,
config: rawConfig,
args: rawArgs,
});
const alwaysExternal = await resolveAlwaysExternal(client);
logger.debug("Starting dev session", {
destination: destination.path,
rawConfig,
alwaysExternal,
});
const externalsExtension = createExternalsBuildExtension("dev", rawConfig, alwaysExternal);
const createRequireCollector = new CreateRequireCollector(rawConfig.workingDir);
const extensionPackages = extensionInstalledPackageMatchers(rawConfig);
const buildContext = createBuildContext("dev", rawConfig);
buildContext.prependExtension(externalsExtension);
await notifyExtensionOnBuildStart(buildContext);
const pluginsFromExtensions = resolvePluginsForContext(buildContext);
async function updateBundle(bundle: BundleResult, workerDir?: EphemeralDirectory) {
let buildManifest = await createBuildManifestFromBundle({
bundle,
destination: destination.path,
resolvedConfig: rawConfig,
workerDir: workerDir?.path,
environment: "dev",
target: "dev",
storeDir,
});
logger.debug("Created build manifest from bundle", { buildManifest });
await writeJSONFile(
join(workerDir?.path ?? destination.path, "metafile.json"),
bundle.metafile
);
// Skill folder copying happens after the main worker indexer runs in
// `BackgroundWorker.initialize` — that pass already discovers skills
// via the resource catalog and reports them on `workerManifest.skills`,
// so we don't need a duplicate indexer here (which historically ran
// with a bare `process.env` and silently dropped skills on projects
// whose task files read CLI-injected vars at module top level).
buildManifest = await notifyExtensionOnBuildComplete(buildContext, buildManifest);
const createRequireWarnings = collectCreateRequireWarningMessages({
usages: createRequireCollector.usages,
buildManifest,
extensionPackages,
target: "dev",
});
if (createRequireWarnings.length > 0) {
logBuildWarnings(createRequireWarnings);
}
try {
logger.debug("Updated bundle", { bundle, buildManifest });
await runtime.initializeWorker(
buildManifest,
bundle.metafile,
workerDir?.remove ?? (() => {})
);
} catch (error) {
if (error instanceof Error) {
eventBus.emit("backgroundWorkerIndexingError", buildManifest, error);
} else {
logger.error("Error updating bundle", { error });
}
}
}
async function updateBuild(build: esbuild.BuildResult, workerDir: EphemeralDirectory) {
const bundle = await getBundleResultFromBuild(
"dev",
rawConfig.workingDir,
rawConfig,
build,
storeDir
);
if (bundle) {
await updateBundle({ ...bundle, stop: undefined }, workerDir);
}
}
let bundled = false;
const onEnd = {
name: "on-end",
setup(b: esbuild.PluginBuild) {
b.onStart(() => {
logger.debug("on-end plugin started");
if (bundled) {
eventBus.emit("rebuildStarted", "dev");
}
});
b.onEnd(async (result: esbuild.BuildResult) => {
const errors = result.errors;
const warnings = result.warnings;
if (errors.length > 0) {
logBuildFailure(errors, warnings);
return;
}
if (warnings.length > 0) {
logBuildWarnings(warnings);
}
if (!bundled) {
bundled = true;
logger.debug("First bundle, no need to update bundle");
return;
}
const workerDir = getTmpDir(rawConfig.workingDir, "build", keepTmpFiles, branch);
await updateBuild(result, workerDir);
});
},
};
async function runBundle() {
eventBus.emit("buildStarted", "dev");
try {
// Use glob to find initial entryPoints
// Use chokidar to watch for entryPoints changes (e.g. added or removed?)
// When there is a change, update entryPoints and start a new build with watch: true
const bundleResult = await bundleWorker({
target: "dev",
cwd: rawConfig.workingDir,
destination: destination.path,
watch: true,
resolvedConfig: rawConfig,
plugins: [createRequireCollector.plugin, ...pluginsFromExtensions, onEnd],
jsxFactory: rawConfig.build.jsx.factory,
jsxFragment: rawConfig.build.jsx.fragment,
jsxAutomatic: rawConfig.build.jsx.automatic,
storeDir,
});
await updateBundle(bundleResult);
return bundleResult.stop;
} catch (error) {
if (error instanceof Error) {
eventBus.emit("buildFailed", "dev", error);
} else {
eventBus.emit("buildFailed", "dev", new Error(String(error)));
}
throw error;
}
}
const stopBundling = await runBundle();
return {
stop: () => {
logger.debug("Stopping dev session");
destination.remove();
stopBundling?.().catch((error) => {});
runtime.shutdown().catch((error) => {});
stopOutput();
},
};
}