Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/typescript-seven-builds.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@
"@trigger.dev/sdk": patch
---

Refresh package builds for TypeScript 7 compatibility while preserving existing runtime entry points. TypeScript remains an optional peer for the decorator metadata build extension, so installing the Trigger.dev CLI does not install an additional compiler.
Refresh package builds for TypeScript 7 compatibility while preserving existing runtime entry points. Projects using `emitDecoratorMetadata()` with TypeScript 7 can install the `@typescript/typescript6` compatibility package alongside it; the package remains optional, so installing the Trigger.dev CLI does not install an additional compiler.
31 changes: 27 additions & 4 deletions docs/config/extensions/emitDecoratorMetadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,31 @@ export default defineConfig({
This is usually required if you are using certain ORMs, like TypeORM, that require this option to be enabled. It's not enabled by default because there is a performance cost to enabling it.

<Note>
emitDecoratorMetadata works by hooking into the esbuild bundle process and using the TypeScript
compiler API to compile files where we detect the use of decorators. This means you must have
`emitDecoratorMetadata` enabled in your `tsconfig.json` file, as well as `typescript` installed in
your `devDependencies`.
`emitDecoratorMetadata` hooks into the esbuild bundle process and uses the TypeScript compiler API
to compile files containing decorators. Enable `emitDecoratorMetadata` in your `tsconfig.json` and
install `typescript` in your `devDependencies`.
</Note>

## Using with TypeScript 7

TypeScript 7 does not expose the JavaScript compiler API required by this extension. Install the TypeScript 6 compatibility package alongside TypeScript 7:

<CodeGroup>

```bash npm
npm install --save-dev @typescript/typescript6@latest
```

```bash pnpm
pnpm add --save-dev @typescript/typescript6@latest
```

```bash bun
bun add --dev @typescript/typescript6@latest
```

</CodeGroup>

Your project continues using TypeScript 7 for its normal type checking and compiler commands. The extension loads the compatibility package only when it needs to emit decorator metadata.

Restart the Trigger.dev dev server after installing the package.
9 changes: 8 additions & 1 deletion packages/build/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,16 +89,23 @@
"devDependencies": {
"@arethetypeswrong/cli": "^0.18.5",
"@types/resolve": "^1.20.6",
"@typescript/typescript6": "6.0.2",
"esbuild": "^0.23.0",
"rimraf": "6.0.1",
"tshy": "^4.1.3",
"tsx": "4.17.0",
"typescript": "6.0.3"
"typescript": "6.0.3",
"typescript5": "npm:typescript@5.9.3",
"typescript7": "npm:typescript@7.0.2"
},
"peerDependencies": {
"@typescript/typescript6": "^6.0.0",
"typescript": ">=5.0.0"
},
"peerDependenciesMeta": {
"@typescript/typescript6": {
"optional": true
},
"typescript": {
"optional": true
}
Expand Down
133 changes: 133 additions & 0 deletions packages/build/src/extensions/internal/loadTypescript.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { createRequire } from "node:module";
import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { loadTypescript } from "./loadTypescript.js";

const packageRequire = createRequire(join(process.cwd(), "package.json"));
const projectDirs = new Set<string>();

function createProject(packages: Record<string, string>) {
const projectDir = mkdtempSync(join(tmpdir(), "trigger-typescript-"));
projectDirs.add(projectDir);
const nodeModulesDir = join(projectDir, "node_modules");

mkdirSync(nodeModulesDir);
writeFileSync(join(projectDir, "package.json"), JSON.stringify({ private: true }));

for (const [installedName, sourceName] of Object.entries(packages)) {
const target = dirname(packageRequire.resolve(`${sourceName}/package.json`));
const destination = join(nodeModulesDir, installedName);

mkdirSync(dirname(destination), { recursive: true });
symlinkSync(target, destination, "junction");
}

return projectDir;
}

function createBrokenCompiler(projectDir: string, packageName = "typescript") {
const packageDir = join(projectDir, "node_modules", packageName);

mkdirSync(packageDir, { recursive: true });
writeFileSync(
join(packageDir, "package.json"),
JSON.stringify({ name: packageName, main: "index.cjs" })
);
writeFileSync(join(packageDir, "index.cjs"), 'throw new Error("broken compiler");');
}

describe("loadTypescript", () => {
afterEach(() => {
for (const projectDir of projectDirs) {
rmSync(projectDir, { recursive: true, force: true });
}

projectDirs.clear();
});

it("loads the consumer's TypeScript 5 compiler", () => {
const compiler = loadTypescript(createProject({ typescript: "typescript5" }));

expect(compiler.version).toMatch(/^5\./);
expect(typeof compiler.transpileModule).toBe("function");
});

it("loads the consumer's TypeScript 6 compiler", () => {
const compiler = loadTypescript(createProject({ typescript: "typescript" }));

expect(compiler.version).toMatch(/^6\./);
expect(typeof compiler.transpileModule).toBe("function");
});

it("returns an actionable error for TypeScript 7 without the compatibility package", () => {
const projectDir = createProject({ typescript: "typescript7" });
const requireFromProject = createRequire(join(projectDir, "package.json"));

expect(typeof requireFromProject("typescript").transpileModule).toBe("undefined");
expect(() => loadTypescript(projectDir, ["typescript"])).toThrowError(
expect.objectContaining({
message: expect.stringContaining("npm install --save-dev @typescript/typescript6"),
})
);
});

it("surfaces errors from an installed compiler package", () => {
const projectDir = createProject({});
createBrokenCompiler(projectDir);

expect(() => loadTypescript(projectDir, ["typescript"])).toThrowError(
`Failed to load "typescript" from ${projectDir}.`
);
});

it("falls back when an earlier compiler package fails to load", () => {
const projectDir = createProject({
"@typescript/typescript6": "@typescript/typescript6",
});
createBrokenCompiler(projectDir);

const compiler = loadTypescript(projectDir);

expect(compiler.version).toMatch(/^6\./);
expect(typeof compiler.transpileModule).toBe("function");
});
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

it("falls back to the TypeScript 6 compatibility package for TypeScript 7", () => {
const compiler = loadTypescript(
createProject({
typescript: "typescript7",
"@typescript/typescript6": "@typescript/typescript6",
})
);

const output = compiler.transpileModule(
`
class Dependency {}
function injectable<T extends new (...args: any[]) => object>(target: T) {}

@injectable
class Service {
constructor(public dependency: Dependency) {}
}
`,
{
compilerOptions: {
experimentalDecorators: true,
emitDecoratorMetadata: true,
},
}
).outputText;

expect(compiler.version).toMatch(/^6\./);
expect(output).toContain('__metadata("design:paramtypes", [Dependency])');
});

it("supports aliasing TypeScript to the compatibility package", () => {
const compiler = loadTypescript(createProject({ typescript: "@typescript/typescript6" }));

expect(compiler.version).toMatch(/^6\./);
expect(typeof compiler.transpileModule).toBe("function");
});
});
85 changes: 85 additions & 0 deletions packages/build/src/extensions/internal/loadTypescript.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { createRequire } from "node:module";
import { join } from "node:path";

export type TypeScriptCompiler = typeof import("typescript");

const compilerPackages = ["typescript", "@typescript/typescript6"] as const;

function hasTranspileModule(value: unknown): value is TypeScriptCompiler {
return (
typeof value === "object" &&
value !== null &&
"transpileModule" in value &&
typeof value.transpileModule === "function"
);
}

function isUnavailablePackage(error: unknown) {
return (
error instanceof Error &&
"code" in error &&
(error.code === "MODULE_NOT_FOUND" || error.code === "ERR_PACKAGE_PATH_NOT_EXPORTED")
);
}

export function loadTypescript(
projectDir: string,
packageNames: readonly string[] = compilerPackages
): TypeScriptCompiler {
const requireFromProject = createRequire(join(projectDir, "package.json"));
const loadErrors: Error[] = [];

for (const packageName of packageNames) {
let resolvedPackage: string;

try {
resolvedPackage = requireFromProject.resolve(packageName);
} catch (error) {
if (isUnavailablePackage(error)) {
continue;
}

throw error;
}

let compiler: unknown;

try {
compiler = requireFromProject(resolvedPackage);
} catch (error) {
loadErrors.push(
new Error(`Failed to load "${packageName}" from ${projectDir}.`, { cause: error })
);
continue;
}

if (hasTranspileModule(compiler)) {
return compiler;
}
}

if (loadErrors.length === 1) {
throw loadErrors[0];
}

if (loadErrors.length > 1) {
throw new AggregateError(
loadErrors,
`Failed to load a compatible TypeScript compiler from ${projectDir}.`
);
}

throw new Error(
[
"The emitDecoratorMetadata() build extension requires the TypeScript JavaScript compiler API,",
"which TypeScript 7 does not expose.",
"",
"Install the TypeScript 6 compatibility package alongside TypeScript 7:",
"",
" npm install --save-dev @typescript/typescript6",
"",
"Restart the Trigger.dev dev server after installing the package.",
"See https://trigger.dev/docs/config/extensions/emitDecoratorMetadata#using-with-typescript-7",
].join("\n")
);
Comment thread
carderne marked this conversation as resolved.
}
23 changes: 15 additions & 8 deletions packages/build/src/extensions/typescript.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,43 @@
import { BuildExtension } from "@trigger.dev/core/v3/build";
import { readFile } from "node:fs/promises";
import typescriptPkg from "typescript";

const { transpileModule, ModuleKind } = typescriptPkg;
import { dirname } from "node:path";
import { loadTypescript } from "./internal/loadTypescript.js";

const decoratorMatcher = new RegExp(/((?<![(\s]\s*['"])@\w[.[\]\w\d]*\s*(?![;])[((?=\s)])/);

export function emitDecoratorMetadata(): BuildExtension {
return {
name: "emitDecoratorMetadata",
onBuildStart(context) {
const { convertCompilerOptionsFromJson, transpileModule, ModuleKind } = loadTypescript(
context.workingDir
);

context.registerPlugin({
name: "emitDecoratorMetadata",
async setup(build) {
const { parseNative, TSConfckCache } = await import("tsconfck");
const { parse, TSConfckCache } = await import("tsconfck");
const cache = new TSConfckCache<any>();

build.onLoad({ filter: /\.ts$/ }, async (args) => {
context.logger.debug("emitDecoratorMetadata onLoad", { args });

const { tsconfigFile, tsconfig } = await parseNative(args.path, {
const { tsconfigFile, tsconfig } = await parse(args.path, {
ignoreNodeModules: true,
cache,
});
const { options: compilerOptions } = convertCompilerOptionsFromJson(
tsconfig.compilerOptions ?? {},
tsconfigFile ? dirname(tsconfigFile) : context.workingDir
);

context.logger.debug("emitDecoratorMetadata parsed native tsconfig", {
context.logger.debug("emitDecoratorMetadata parsed tsconfig", {
Comment thread
carderne marked this conversation as resolved.
tsconfig,
tsconfigFile,
args,
});

if (tsconfig.compilerOptions?.emitDecoratorMetadata !== true) {
if (compilerOptions.emitDecoratorMetadata !== true) {
context.logger.debug("emitDecoratorMetadata skipping", {
args,
tsconfig,
Expand All @@ -55,7 +62,7 @@ export function emitDecoratorMetadata(): BuildExtension {
const program = transpileModule(ts, {
fileName: args.path,
compilerOptions: {
...tsconfig.compilerOptions,
...compilerOptions,
module: ModuleKind.ES2022,
},
});
Expand Down
16 changes: 16 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading