Skip to content

Commit 339fb25

Browse files
committed
fix(build): support decorator metadata with TypeScript 7
1 parent 3864c62 commit 339fb25

7 files changed

Lines changed: 250 additions & 9 deletions

File tree

.changeset/typescript-seven-builds.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44
"@trigger.dev/redis-worker": patch
55
---
66

7-
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.
7+
Refresh package builds for TypeScript 7 compatibility while preserving existing runtime entry points. Projects using `emitDecoratorMetadata()` with TypeScript 7 can install Microsoft's `@typescript/typescript6` compatibility package alongside it; the package remains optional, so installing the Trigger.dev CLI does not install an additional compiler.

docs/config/extensions/emitDecoratorMetadata.mdx

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,31 @@ export default defineConfig({
2222
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.
2323

2424
<Note>
25-
emitDecoratorMetadata works by hooking into the esbuild bundle process and using the TypeScript
26-
compiler API to compile files where we detect the use of decorators. This means you must have
27-
`emitDecoratorMetadata` enabled in your `tsconfig.json` file, as well as `typescript` installed in
28-
your `devDependencies`.
25+
`emitDecoratorMetadata` hooks into the esbuild bundle process and uses the TypeScript compiler API
26+
to compile files containing decorators. Enable `emitDecoratorMetadata` in your `tsconfig.json` and
27+
install `typescript` in your `devDependencies`.
2928
</Note>
29+
30+
## Using with TypeScript 7
31+
32+
TypeScript 7 does not expose the JavaScript compiler API required by this extension. Install Microsoft's TypeScript 6 compatibility package alongside TypeScript 7:
33+
34+
<CodeGroup>
35+
36+
```bash npm
37+
npm install --save-dev @typescript/typescript6@latest
38+
```
39+
40+
```bash pnpm
41+
pnpm add --save-dev @typescript/typescript6@latest
42+
```
43+
44+
```bash bun
45+
bun add --dev @typescript/typescript6@latest
46+
```
47+
48+
</CodeGroup>
49+
50+
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.
51+
52+
Restart the Trigger.dev dev server after installing the package.

packages/build/package.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,16 +89,23 @@
8989
"devDependencies": {
9090
"@arethetypeswrong/cli": "^0.18.5",
9191
"@types/resolve": "^1.20.6",
92+
"@typescript/typescript6": "6.0.2",
9293
"esbuild": "^0.23.0",
9394
"rimraf": "6.0.1",
9495
"tshy": "^4.1.3",
9596
"tsx": "4.17.0",
96-
"typescript": "6.0.3"
97+
"typescript": "6.0.3",
98+
"typescript5": "npm:typescript@5.9.3",
99+
"typescript7": "npm:typescript@7.0.2"
97100
},
98101
"peerDependencies": {
102+
"@typescript/typescript6": "^6.0.0",
99103
"typescript": ">=5.0.0"
100104
},
101105
"peerDependenciesMeta": {
106+
"@typescript/typescript6": {
107+
"optional": true
108+
},
102109
"typescript": {
103110
"optional": true
104111
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { createRequire } from "node:module";
2+
import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { dirname, join } from "node:path";
5+
import { afterEach, describe, expect, it } from "vitest";
6+
import { loadTypescript } from "./loadTypescript.js";
7+
8+
const packageRequire = createRequire(join(process.cwd(), "package.json"));
9+
const projectDirs = new Set<string>();
10+
11+
function createProject(packages: Record<string, string>) {
12+
const projectDir = mkdtempSync(join(tmpdir(), "trigger-typescript-"));
13+
projectDirs.add(projectDir);
14+
const nodeModulesDir = join(projectDir, "node_modules");
15+
16+
mkdirSync(nodeModulesDir);
17+
writeFileSync(join(projectDir, "package.json"), JSON.stringify({ private: true }));
18+
19+
for (const [installedName, sourceName] of Object.entries(packages)) {
20+
const target = dirname(packageRequire.resolve(`${sourceName}/package.json`));
21+
const destination = join(nodeModulesDir, installedName);
22+
23+
mkdirSync(dirname(destination), { recursive: true });
24+
symlinkSync(target, destination, "junction");
25+
}
26+
27+
return projectDir;
28+
}
29+
30+
describe("loadTypescript", () => {
31+
afterEach(() => {
32+
for (const projectDir of projectDirs) {
33+
rmSync(projectDir, { recursive: true, force: true });
34+
}
35+
36+
projectDirs.clear();
37+
});
38+
39+
it("loads the consumer's TypeScript 5 compiler", () => {
40+
const compiler = loadTypescript(createProject({ typescript: "typescript5" }));
41+
42+
expect(compiler.version).toBe("5.9.3");
43+
expect(typeof compiler.transpileModule).toBe("function");
44+
});
45+
46+
it("loads the consumer's TypeScript 6 compiler", () => {
47+
const compiler = loadTypescript(createProject({ typescript: "typescript" }));
48+
49+
expect(compiler.version).toBe("6.0.3");
50+
expect(typeof compiler.transpileModule).toBe("function");
51+
});
52+
53+
it("returns an actionable error for TypeScript 7 without the compatibility package", () => {
54+
const projectDir = createProject({ typescript: "typescript7" });
55+
const requireFromProject = createRequire(join(projectDir, "package.json"));
56+
57+
expect(typeof requireFromProject("typescript").transpileModule).toBe("undefined");
58+
expect(() => loadTypescript(projectDir, ["typescript"])).toThrowError(
59+
expect.objectContaining({
60+
message: expect.stringContaining("npm install --save-dev @typescript/typescript6"),
61+
})
62+
);
63+
});
64+
65+
it("surfaces errors from an installed compiler package", () => {
66+
const projectDir = createProject({});
67+
const packageDir = join(projectDir, "node_modules", "typescript");
68+
69+
mkdirSync(packageDir);
70+
writeFileSync(
71+
join(packageDir, "package.json"),
72+
JSON.stringify({ name: "typescript", main: "index.cjs" })
73+
);
74+
writeFileSync(join(packageDir, "index.cjs"), 'throw new Error("broken compiler");');
75+
76+
expect(() => loadTypescript(projectDir)).toThrowError(
77+
`Failed to load "typescript" from ${projectDir}.`
78+
);
79+
});
80+
81+
it("falls back to the TypeScript 6 compatibility package for TypeScript 7", () => {
82+
const compiler = loadTypescript(
83+
createProject({
84+
typescript: "typescript7",
85+
"@typescript/typescript6": "@typescript/typescript6",
86+
})
87+
);
88+
89+
const output = compiler.transpileModule(
90+
`
91+
class Dependency {}
92+
function injectable<T extends new (...args: any[]) => object>(target: T) {}
93+
94+
@injectable
95+
class Service {
96+
constructor(public dependency: Dependency) {}
97+
}
98+
`,
99+
{
100+
compilerOptions: {
101+
experimentalDecorators: true,
102+
emitDecoratorMetadata: true,
103+
},
104+
}
105+
).outputText;
106+
107+
expect(compiler.version).toBe("6.0.3");
108+
expect(output).toContain('__metadata("design:paramtypes", [Dependency])');
109+
});
110+
111+
it("supports aliasing TypeScript to the compatibility package", () => {
112+
const compiler = loadTypescript(createProject({ typescript: "@typescript/typescript6" }));
113+
114+
expect(compiler.version).toBe("6.0.3");
115+
expect(typeof compiler.transpileModule).toBe("function");
116+
});
117+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { createRequire } from "node:module";
2+
import { join } from "node:path";
3+
4+
export type TypeScriptCompiler = typeof import("typescript");
5+
6+
const compilerPackages = ["typescript", "@typescript/typescript6"] as const;
7+
8+
function hasTranspileModule(value: unknown): value is TypeScriptCompiler {
9+
return (
10+
typeof value === "object" &&
11+
value !== null &&
12+
"transpileModule" in value &&
13+
typeof value.transpileModule === "function"
14+
);
15+
}
16+
17+
function isUnavailablePackage(error: unknown) {
18+
return (
19+
error instanceof Error &&
20+
"code" in error &&
21+
(error.code === "MODULE_NOT_FOUND" || error.code === "ERR_PACKAGE_PATH_NOT_EXPORTED")
22+
);
23+
}
24+
25+
export function loadTypescript(
26+
projectDir: string,
27+
packageNames: readonly string[] = compilerPackages
28+
): TypeScriptCompiler {
29+
const requireFromProject = createRequire(join(projectDir, "package.json"));
30+
31+
for (const packageName of packageNames) {
32+
let resolvedPackage: string;
33+
34+
try {
35+
resolvedPackage = requireFromProject.resolve(packageName);
36+
} catch (error) {
37+
if (isUnavailablePackage(error)) {
38+
continue;
39+
}
40+
41+
throw error;
42+
}
43+
44+
let compiler: unknown;
45+
46+
try {
47+
compiler = requireFromProject(resolvedPackage);
48+
} catch (error) {
49+
throw new Error(`Failed to load "${packageName}" from ${projectDir}.`, { cause: error });
50+
}
51+
52+
if (hasTranspileModule(compiler)) {
53+
return compiler;
54+
}
55+
}
56+
57+
throw new Error(
58+
[
59+
"The emitDecoratorMetadata() build extension requires the TypeScript JavaScript compiler API,",
60+
"which TypeScript 7 does not expose.",
61+
"",
62+
"Install Microsoft's TypeScript 6 compatibility package alongside TypeScript 7:",
63+
"",
64+
" npm install --save-dev @typescript/typescript6",
65+
"",
66+
"Restart the Trigger.dev dev server after installing the package.",
67+
"See https://trigger.dev/docs/config/extensions/emitDecoratorMetadata#using-with-typescript-7",
68+
].join("\n")
69+
);
70+
}

packages/build/src/extensions/typescript.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
import { BuildExtension } from "@trigger.dev/core/v3/build";
22
import { readFile } from "node:fs/promises";
3-
import typescriptPkg from "typescript";
4-
5-
const { transpileModule, ModuleKind } = typescriptPkg;
3+
import { loadTypescript } from "./internal/loadTypescript.js";
64

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

97
export function emitDecoratorMetadata(): BuildExtension {
108
return {
119
name: "emitDecoratorMetadata",
1210
onBuildStart(context) {
11+
const { transpileModule, ModuleKind } = loadTypescript(context.workingDir);
12+
1313
context.registerPlugin({
1414
name: "emitDecoratorMetadata",
1515
async setup(build) {

pnpm-lock.yaml

Lines changed: 24 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)