diff --git a/.changeset/vendor-superjson-esm-fix.md b/.changeset/vendor-superjson-esm-fix.md new file mode 100644 index 0000000000..ef04201d2c --- /dev/null +++ b/.changeset/vendor-superjson-esm-fix.md @@ -0,0 +1,7 @@ +--- +"@trigger.dev/core": patch +--- + +fix: vendor superjson to fix ESM/CJS compatibility + +Bundle superjson during build to avoid `ERR_REQUIRE_ESM` errors on Node.js versions that don't support `require(ESM)` by default (< 22.12.0) and AWS Lambda which intentionally disables it. diff --git a/.github/workflows/pr_checks.yml b/.github/workflows/pr_checks.yml index b6be1eddfa..dab18223e3 100644 --- a/.github/workflows/pr_checks.yml +++ b/.github/workflows/pr_checks.yml @@ -29,3 +29,7 @@ jobs: with: package: cli-v3 secrets: inherit + + sdk-compat: + uses: ./.github/workflows/sdk-compat.yml + secrets: inherit diff --git a/.github/workflows/sdk-compat.yml b/.github/workflows/sdk-compat.yml new file mode 100644 index 0000000000..36f4e3ffba --- /dev/null +++ b/.github/workflows/sdk-compat.yml @@ -0,0 +1,182 @@ +name: "๐Ÿ”Œ SDK Compatibility Tests" + +permissions: + contents: read + +on: + workflow_call: + +jobs: + node-compat: + name: "Node.js ${{ matrix.node }} (${{ matrix.os }})" + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + node: ["18.20", "20.20", "22.12"] + exclude: + # Skip Node 18 on macOS/Windows to reduce CI time + # Linux coverage is sufficient for Node 18 compatibility + - os: macos-latest + node: "18.20" + - os: windows-latest + node: "18.20" + + steps: + - name: โฌ‡๏ธ Checkout repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: โŽ” Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.23.0 + + - name: โŽ” Setup node + uses: buildjet/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: "pnpm" + + - name: ๐Ÿ“ฅ Download deps + run: pnpm install --frozen-lockfile + + - name: ๐Ÿ“€ Generate Prisma Client + run: pnpm run generate + + - name: ๐Ÿ”จ Build SDK dependencies + run: pnpm run build --filter @trigger.dev/sdk^... + + - name: ๐Ÿ”จ Build SDK + run: pnpm run build --filter @trigger.dev/sdk + + - name: ๐Ÿงช Run SDK Compatibility Tests + run: pnpm --filter @internal/sdk-compat-tests test + + bun-compat: + name: "Bun Runtime" + runs-on: ubuntu-latest + steps: + - name: โฌ‡๏ธ Checkout repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: โŽ” Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.23.0 + + - name: โŽ” Setup node + uses: buildjet/setup-node@v4 + with: + node-version: 20.20.0 + cache: "pnpm" + + - name: ๐ŸฅŸ Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: ๐Ÿ“ฅ Download deps + run: pnpm install --frozen-lockfile + + - name: ๐Ÿ“€ Generate Prisma Client + run: pnpm run generate + + - name: ๐Ÿ”จ Build SDK dependencies + run: pnpm run build --filter @trigger.dev/sdk^... + + - name: ๐Ÿ”จ Build SDK + run: pnpm run build --filter @trigger.dev/sdk + + - name: ๐Ÿงช Run Bun Compatibility Test + working-directory: internal-packages/sdk-compat-tests/src/fixtures/bun + run: bun run test.ts + + deno-compat: + name: "Deno Runtime" + runs-on: ubuntu-latest + steps: + - name: โฌ‡๏ธ Checkout repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: โŽ” Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.23.0 + + - name: โŽ” Setup node + uses: buildjet/setup-node@v4 + with: + node-version: 20.20.0 + cache: "pnpm" + + - name: ๐Ÿฆ• Setup Deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: ๐Ÿ“ฅ Download deps + run: pnpm install --frozen-lockfile + + - name: ๐Ÿ“€ Generate Prisma Client + run: pnpm run generate + + - name: ๐Ÿ”จ Build SDK dependencies + run: pnpm run build --filter @trigger.dev/sdk^... + + - name: ๐Ÿ”จ Build SDK + run: pnpm run build --filter @trigger.dev/sdk + + - name: ๐Ÿ”— Link node_modules for Deno fixture + working-directory: internal-packages/sdk-compat-tests/src/fixtures/deno + run: ln -s ../../../../../node_modules node_modules + + - name: ๐Ÿงช Run Deno Compatibility Test + working-directory: internal-packages/sdk-compat-tests/src/fixtures/deno + run: deno run --allow-read --allow-env --allow-sys test.ts + + cloudflare-compat: + name: "Cloudflare Workers" + runs-on: ubuntu-latest + steps: + - name: โฌ‡๏ธ Checkout repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: โŽ” Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.23.0 + + - name: โŽ” Setup node + uses: buildjet/setup-node@v4 + with: + node-version: 20.20.0 + cache: "pnpm" + + - name: ๐Ÿ“ฅ Download deps + run: pnpm install --frozen-lockfile + + - name: ๐Ÿ“€ Generate Prisma Client + run: pnpm run generate + + - name: ๐Ÿ”จ Build SDK dependencies + run: pnpm run build --filter @trigger.dev/sdk^... + + - name: ๐Ÿ”จ Build SDK + run: pnpm run build --filter @trigger.dev/sdk + + - name: ๐Ÿ“ฅ Install Cloudflare fixture deps + working-directory: internal-packages/sdk-compat-tests/src/fixtures/cloudflare-worker + run: pnpm install + + - name: ๐Ÿงช Run Cloudflare Workers Compatibility Test (dry-run) + working-directory: internal-packages/sdk-compat-tests/src/fixtures/cloudflare-worker + run: pnpm exec wrangler deploy --dry-run --outdir dist diff --git a/.gitignore b/.gitignore index d0dfea89c5..071b9b5903 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ out/ dist packages/**/dist +# vendored bundles (generated during build) +packages/**/src/**/vendor + # Tailwind apps/**/styles/tailwind.css packages/**/styles/tailwind.css diff --git a/internal-packages/sdk-compat-tests/package.json b/internal-packages/sdk-compat-tests/package.json new file mode 100644 index 0000000000..5487591a5a --- /dev/null +++ b/internal-packages/sdk-compat-tests/package.json @@ -0,0 +1,20 @@ +{ + "name": "@internal/sdk-compat-tests", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "test": "vitest --run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@trigger.dev/sdk": "workspace:*" + }, + "devDependencies": { + "esbuild": "^0.24.0", + "execa": "^9.3.0", + "typescript": "^5.5.0", + "vitest": "^2.0.5" + } +} diff --git a/internal-packages/sdk-compat-tests/src/fixtures/bun/package.json b/internal-packages/sdk-compat-tests/src/fixtures/bun/package.json new file mode 100644 index 0000000000..c69e2dd23e --- /dev/null +++ b/internal-packages/sdk-compat-tests/src/fixtures/bun/package.json @@ -0,0 +1,5 @@ +{ + "name": "bun-fixture", + "private": true, + "type": "module" +} diff --git a/internal-packages/sdk-compat-tests/src/fixtures/bun/test.ts b/internal-packages/sdk-compat-tests/src/fixtures/bun/test.ts new file mode 100644 index 0000000000..853869304f --- /dev/null +++ b/internal-packages/sdk-compat-tests/src/fixtures/bun/test.ts @@ -0,0 +1,62 @@ +/** + * Bun Import Test Fixture + * + * Tests that the SDK works correctly with Bun runtime. + * Bun has high Node.js compatibility but uses its own module resolver. + */ + +import { task, logger, schedules, runs, configure, queue, retry, wait } from "@trigger.dev/sdk"; + +// Validate exports exist +const checks: [string, boolean][] = [ + ["task", typeof task === "function"], + ["logger", typeof logger === "object" && typeof logger.info === "function"], + ["schedules", typeof schedules === "object"], + ["runs", typeof runs === "object"], + ["configure", typeof configure === "function"], + ["queue", typeof queue === "function"], + ["retry", typeof retry === "object"], + ["wait", typeof wait === "object"], +]; + +let failed = false; +for (const [name, passed] of checks) { + if (!passed) { + console.error(`FAIL: ${name} export check failed`); + failed = true; + } +} + +// Test task definition with types +interface Payload { + message: string; +} + +const myTask = task({ + id: "bun-test-task", + run: async (payload: Payload) => { + return { received: payload.message }; + }, +}); + +if (myTask.id !== "bun-test-task") { + console.error(`FAIL: task.id mismatch`); + failed = true; +} + +// Test queue definition +const myQueue = queue({ + name: "bun-test-queue", + concurrencyLimit: 5, +}); + +if (!myQueue) { + console.error(`FAIL: queue creation failed`); + failed = true; +} + +if (failed) { + process.exit(1); +} + +console.log("SUCCESS: Bun imports validated"); diff --git a/internal-packages/sdk-compat-tests/src/fixtures/cjs-require/package.json b/internal-packages/sdk-compat-tests/src/fixtures/cjs-require/package.json new file mode 100644 index 0000000000..953ed7d2db --- /dev/null +++ b/internal-packages/sdk-compat-tests/src/fixtures/cjs-require/package.json @@ -0,0 +1,4 @@ +{ + "name": "cjs-require-fixture", + "private": true +} diff --git a/internal-packages/sdk-compat-tests/src/fixtures/cjs-require/test.cjs b/internal-packages/sdk-compat-tests/src/fixtures/cjs-require/test.cjs new file mode 100644 index 0000000000..447d03970a --- /dev/null +++ b/internal-packages/sdk-compat-tests/src/fixtures/cjs-require/test.cjs @@ -0,0 +1,57 @@ +/** + * CJS Require Test Fixture + * + * This file validates that the SDK can be required using CommonJS syntax. + * This is critical for: + * - Node.js < 22.12.0 (where require(ESM) is not enabled by default) + * - AWS Lambda (intentionally disables require(ESM)) + * - Legacy Node.js applications + */ + +// Test main export +const sdk = require("@trigger.dev/sdk"); + +// Test /v3 subpath +const sdkV3 = require("@trigger.dev/sdk/v3"); + +// Validate exports exist +const checks = [ + ["task", typeof sdk.task === "function"], + ["taskV3", typeof sdkV3.task === "function"], + ["logger", typeof sdk.logger === "object" && typeof sdk.logger.info === "function"], + ["schedules", typeof sdk.schedules === "object"], + ["runs", typeof sdk.runs === "object"], + ["configure", typeof sdk.configure === "function"], + ["queue", typeof sdk.queue === "function"], + ["retry", typeof sdk.retry === "object"], + ["wait", typeof sdk.wait === "object"], + ["metadata", typeof sdk.metadata === "object"], + ["tags", typeof sdk.tags === "object"], +]; + +let failed = false; +for (const [name, passed] of checks) { + if (!passed) { + console.error(`FAIL: ${name} export check failed`); + failed = true; + } +} + +// Test task definition works +const myTask = sdk.task({ + id: "cjs-test-task", + run: async (payload) => { + return { received: payload }; + }, +}); + +if (myTask.id !== "cjs-test-task") { + console.error(`FAIL: task.id mismatch: expected "cjs-test-task", got "${myTask.id}"`); + failed = true; +} + +if (failed) { + process.exit(1); +} + +console.log("SUCCESS: All CJS requires validated"); diff --git a/internal-packages/sdk-compat-tests/src/fixtures/cloudflare-worker/package.json b/internal-packages/sdk-compat-tests/src/fixtures/cloudflare-worker/package.json new file mode 100644 index 0000000000..d9fca987c3 --- /dev/null +++ b/internal-packages/sdk-compat-tests/src/fixtures/cloudflare-worker/package.json @@ -0,0 +1,11 @@ +{ + "name": "cloudflare-worker-fixture", + "private": true, + "type": "module", + "scripts": { + "build": "wrangler deploy --dry-run --outdir dist" + }, + "devDependencies": { + "wrangler": "^3.0.0" + } +} diff --git a/internal-packages/sdk-compat-tests/src/fixtures/cloudflare-worker/src/index.ts b/internal-packages/sdk-compat-tests/src/fixtures/cloudflare-worker/src/index.ts new file mode 100644 index 0000000000..30b5fcc79e --- /dev/null +++ b/internal-packages/sdk-compat-tests/src/fixtures/cloudflare-worker/src/index.ts @@ -0,0 +1,41 @@ +/** + * Cloudflare Worker Test Fixture + * + * Tests that the SDK can be bundled for Cloudflare Workers (workerd runtime). + * This validates the bundling process works - actual execution would require + * a Trigger.dev API connection. + */ + +import { task, runs, configure } from "@trigger.dev/sdk"; + +// Define a task (won't execute in worker, but validates import) +const myTask = task({ + id: "cloudflare-test-task", + run: async (payload: { message: string }) => { + return { received: payload.message }; + }, +}); + +export default { + async fetch(request: Request, env: unknown, ctx: ExecutionContext): Promise { + // Validate SDK imports work + const checks = { + taskDefined: typeof task === "function", + runsDefined: typeof runs === "object", + configureDefined: typeof configure === "function", + taskIdCorrect: myTask.id === "cloudflare-test-task", + }; + + const allPassed = Object.values(checks).every((v) => v === true); + + return new Response( + JSON.stringify({ + success: allPassed, + checks, + }), + { + headers: { "Content-Type": "application/json" }, + } + ); + }, +}; diff --git a/internal-packages/sdk-compat-tests/src/fixtures/cloudflare-worker/wrangler.toml b/internal-packages/sdk-compat-tests/src/fixtures/cloudflare-worker/wrangler.toml new file mode 100644 index 0000000000..f038e47bb6 --- /dev/null +++ b/internal-packages/sdk-compat-tests/src/fixtures/cloudflare-worker/wrangler.toml @@ -0,0 +1,4 @@ +name = "sdk-compat-test" +main = "src/index.ts" +compatibility_date = "2024-01-01" +compatibility_flags = ["nodejs_compat"] diff --git a/internal-packages/sdk-compat-tests/src/fixtures/deno/deno.json b/internal-packages/sdk-compat-tests/src/fixtures/deno/deno.json new file mode 100644 index 0000000000..4525b34d3e --- /dev/null +++ b/internal-packages/sdk-compat-tests/src/fixtures/deno/deno.json @@ -0,0 +1,6 @@ +{ + "tasks": { + "test": "deno run --allow-read --allow-env --allow-sys test.ts" + }, + "nodeModulesDir": "manual" +} diff --git a/internal-packages/sdk-compat-tests/src/fixtures/deno/test.ts b/internal-packages/sdk-compat-tests/src/fixtures/deno/test.ts new file mode 100644 index 0000000000..6894606fd0 --- /dev/null +++ b/internal-packages/sdk-compat-tests/src/fixtures/deno/test.ts @@ -0,0 +1,65 @@ +/** + * Deno Import Test Fixture + * + * Tests that the SDK can be imported in Deno using Node.js compatibility. + * The CI workflow installs the SDK into node_modules via npm for local resolution. + */ + +// Use bare specifier - resolved via node_modules when nodeModulesDir is enabled +import { task, logger, schedules, runs, configure, queue, retry, wait, metadata, tags } from "@trigger.dev/sdk"; + +// Validate exports exist +const checks: [string, boolean][] = [ + ["task", typeof task === "function"], + ["logger", typeof logger === "object" && typeof logger.info === "function"], + ["schedules", typeof schedules === "object"], + ["runs", typeof runs === "object"], + ["configure", typeof configure === "function"], + ["queue", typeof queue === "function"], + ["retry", typeof retry === "object"], + ["wait", typeof wait === "object"], + ["metadata", typeof metadata === "object"], + ["tags", typeof tags === "object"], +]; + +let failed = false; +for (const [name, passed] of checks) { + if (!passed) { + console.error(`FAIL: ${name} export check failed`); + failed = true; + } +} + +// Test task definition with types +interface Payload { + message: string; +} + +const myTask = task({ + id: "deno-test-task", + run: async (payload: Payload) => { + return { received: payload.message }; + }, +}); + +if (myTask.id !== "deno-test-task") { + console.error(`FAIL: task.id mismatch`); + failed = true; +} + +// Test queue definition +const myQueue = queue({ + name: "deno-test-queue", + concurrencyLimit: 5, +}); + +if (!myQueue) { + console.error(`FAIL: queue creation failed`); + failed = true; +} + +if (failed) { + Deno.exit(1); +} + +console.log("SUCCESS: Deno imports validated"); diff --git a/internal-packages/sdk-compat-tests/src/fixtures/esm-import/package.json b/internal-packages/sdk-compat-tests/src/fixtures/esm-import/package.json new file mode 100644 index 0000000000..c0d56cd02f --- /dev/null +++ b/internal-packages/sdk-compat-tests/src/fixtures/esm-import/package.json @@ -0,0 +1,5 @@ +{ + "name": "esm-import-fixture", + "private": true, + "type": "module" +} diff --git a/internal-packages/sdk-compat-tests/src/fixtures/esm-import/superjson-test.mjs b/internal-packages/sdk-compat-tests/src/fixtures/esm-import/superjson-test.mjs new file mode 100644 index 0000000000..fc034de19e --- /dev/null +++ b/internal-packages/sdk-compat-tests/src/fixtures/esm-import/superjson-test.mjs @@ -0,0 +1,59 @@ +/** + * SuperJSON Serialization Test + * + * This validates the fix for #2937 - ESM/CJS compatibility with superjson. + * Tests that complex types (Date, Set, Map, BigInt) serialize correctly. + */ + +import { task, logger } from "@trigger.dev/sdk"; + +// The SDK uses superjson internally for serialization +// This test ensures the vendored superjson works correctly + +const complexData = { + date: new Date("2024-01-15T12:00:00Z"), + set: new Set([1, 2, 3]), + map: new Map([ + ["key1", "value1"], + ["key2", "value2"], + ]), + bigint: BigInt("9007199254740991"), + nested: { + innerDate: new Date("2024-06-01"), + innerSet: new Set(["a", "b"]), + }, +}; + +// Create a task that uses complex types +const complexTask = task({ + id: "superjson-test-task", + run: async (payload) => { + // Just verify the payload structure matches expectations + return { + hasDate: payload.date instanceof Date, + hasSet: payload.set instanceof Set, + hasMap: payload.map instanceof Map, + hasBigInt: typeof payload.bigint === "bigint", + hasNestedDate: payload.nested?.innerDate instanceof Date, + }; + }, +}); + +// Verify task was created successfully +if (!complexTask.id) { + console.error("FAIL: Task creation failed"); + process.exit(1); +} + +// Test that logger works (it uses superjson for structured logging) +try { + logger.info("Testing superjson serialization", { + complexData, + timestamp: new Date(), + }); +} catch (error) { + console.error("FAIL: Logger with complex data failed:", error); + process.exit(1); +} + +console.log("SUCCESS: SuperJSON serialization validated"); diff --git a/internal-packages/sdk-compat-tests/src/fixtures/esm-import/test.mjs b/internal-packages/sdk-compat-tests/src/fixtures/esm-import/test.mjs new file mode 100644 index 0000000000..70b055aaa2 --- /dev/null +++ b/internal-packages/sdk-compat-tests/src/fixtures/esm-import/test.mjs @@ -0,0 +1,54 @@ +/** + * ESM Import Test Fixture + * + * This file validates that the SDK can be imported using ESM syntax. + * It tests all major export paths and verifies runtime functionality. + */ + +// Test main export +import { task, logger, schedules, runs, configure, queue, retry, wait, metadata, tags } from "@trigger.dev/sdk"; + +// Test /v3 subpath (legacy, but should still work) +import { task as taskV3 } from "@trigger.dev/sdk/v3"; + +// Validate exports are functions/objects +const checks = [ + ["task", typeof task === "function"], + ["taskV3", typeof taskV3 === "function"], + ["logger", typeof logger === "object" && typeof logger.info === "function"], + ["schedules", typeof schedules === "object"], + ["runs", typeof runs === "object"], + ["configure", typeof configure === "function"], + ["queue", typeof queue === "function"], + ["retry", typeof retry === "object"], + ["wait", typeof wait === "object"], + ["metadata", typeof metadata === "object"], + ["tags", typeof tags === "object"], +]; + +let failed = false; +for (const [name, passed] of checks) { + if (!passed) { + console.error(`FAIL: ${name} export check failed`); + failed = true; + } +} + +// Test task definition works +const myTask = task({ + id: "esm-test-task", + run: async (payload) => { + return { received: payload }; + }, +}); + +if (myTask.id !== "esm-test-task") { + console.error(`FAIL: task.id mismatch: expected "esm-test-task", got "${myTask.id}"`); + failed = true; +} + +if (failed) { + process.exit(1); +} + +console.log("SUCCESS: All ESM imports validated"); diff --git a/internal-packages/sdk-compat-tests/src/fixtures/typescript/package.json b/internal-packages/sdk-compat-tests/src/fixtures/typescript/package.json new file mode 100644 index 0000000000..7663bc7aca --- /dev/null +++ b/internal-packages/sdk-compat-tests/src/fixtures/typescript/package.json @@ -0,0 +1,5 @@ +{ + "name": "typescript-fixture", + "private": true, + "type": "module" +} diff --git a/internal-packages/sdk-compat-tests/src/fixtures/typescript/test.ts b/internal-packages/sdk-compat-tests/src/fixtures/typescript/test.ts new file mode 100644 index 0000000000..bfcb4892ab --- /dev/null +++ b/internal-packages/sdk-compat-tests/src/fixtures/typescript/test.ts @@ -0,0 +1,74 @@ +/** + * TypeScript Import Test Fixture + * + * This file validates that the SDK types work correctly with TypeScript. + * It tests type inference, generics, and type-only imports. + */ + +import { + task, + logger, + schedules, + runs, + configure, + queue, + retry, + wait, + metadata, + tags, + type Context, + type RetryOptions, +} from "@trigger.dev/sdk"; + +// Type-only import test +import type { ApiClientConfiguration } from "@trigger.dev/sdk"; + +// Test typed task with payload +interface MyPayload { + message: string; + count: number; +} + +interface MyOutput { + processed: boolean; + result: string; +} + +const typedTask = task({ + id: "typescript-test-task", + run: async (payload: MyPayload, { ctx }): Promise => { + // Verify context type + const runId: string = ctx.run.id; + + return { + processed: true, + result: `Processed ${payload.message} with count ${payload.count}`, + }; + }, +}); + +// Verify task type inference +type TaskPayload = Parameters[0]; +type _PayloadCheck = TaskPayload extends MyPayload ? true : never; + +// Test queue definition +const myQueue = queue({ + name: "test-queue", + concurrencyLimit: 10, +}); + +// Test retry options type +const retryOpts: RetryOptions = { + maxAttempts: 3, + factor: 2, + minTimeoutInMs: 1000, + maxTimeoutInMs: 30000, +}; + +// Validate runtime +if (typedTask.id !== "typescript-test-task") { + console.error(`FAIL: task.id mismatch`); + process.exit(1); +} + +console.log("SUCCESS: TypeScript types validated"); diff --git a/internal-packages/sdk-compat-tests/src/fixtures/typescript/tsconfig.json b/internal-packages/sdk-compat-tests/src/fixtures/typescript/tsconfig.json new file mode 100644 index 0000000000..432fff32ad --- /dev/null +++ b/internal-packages/sdk-compat-tests/src/fixtures/typescript/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["test.ts"] +} diff --git a/internal-packages/sdk-compat-tests/src/tests/bundler.test.ts b/internal-packages/sdk-compat-tests/src/tests/bundler.test.ts new file mode 100644 index 0000000000..e3e18c49f3 --- /dev/null +++ b/internal-packages/sdk-compat-tests/src/tests/bundler.test.ts @@ -0,0 +1,117 @@ +/** + * Bundler Compatibility Tests + * + * These tests validate that the SDK can be bundled correctly using + * common bundlers like esbuild. + */ + +import { describe, it, expect } from "vitest"; +import * as esbuild from "esbuild"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const fixturesDir = resolve(__dirname, "../fixtures"); + +describe("esbuild Bundling Tests", () => { + it("should bundle ESM entrypoint without errors", async () => { + const result = await esbuild.build({ + entryPoints: [resolve(fixturesDir, "esm-import/test.mjs")], + bundle: true, + format: "esm", + platform: "node", + target: "node18", + write: false, + external: ["@trigger.dev/sdk", "@trigger.dev/sdk/*"], + logLevel: "silent", + }); + + expect(result.errors).toHaveLength(0); + expect(result.outputFiles).toHaveLength(1); + }); + + it("should bundle CJS entrypoint without errors", async () => { + const result = await esbuild.build({ + entryPoints: [resolve(fixturesDir, "cjs-require/test.cjs")], + bundle: true, + format: "cjs", + platform: "node", + target: "node18", + write: false, + external: ["@trigger.dev/sdk", "@trigger.dev/sdk/*"], + logLevel: "silent", + }); + + expect(result.errors).toHaveLength(0); + expect(result.outputFiles).toHaveLength(1); + }); + + it("should bundle SDK inline (simulating production build)", async () => { + // This simulates what happens when a user bundles their app with the SDK included + const entryContent = ` + import { task, logger } from "@trigger.dev/sdk"; + + export const myTask = task({ + id: "bundled-task", + run: async (payload) => { + logger.info("Processing", { payload }); + return { success: true }; + }, + }); + `; + + const result = await esbuild.build({ + stdin: { + contents: entryContent, + loader: "ts", + resolveDir: resolve(__dirname, "../../"), + }, + bundle: true, + format: "esm", + platform: "node", + target: "node18", + write: false, + // Don't externalize SDK - bundle it inline + logLevel: "silent", + metafile: true, + }); + + expect(result.errors).toHaveLength(0); + expect(result.outputFiles).toHaveLength(1); + + // Verify the bundle contains the SDK code + const bundleContent = result.outputFiles[0].text; + expect(bundleContent).toBeTruthy(); + expect(bundleContent.length).toBeGreaterThan(1000); // Should be substantial + }); + + it("should handle tree-shaking correctly", async () => { + // Import only specific functions to test tree-shaking + const entryContent = ` + import { task } from "@trigger.dev/sdk"; + + export const myTask = task({ + id: "tree-shake-task", + run: async () => ({ done: true }), + }); + `; + + const result = await esbuild.build({ + stdin: { + contents: entryContent, + loader: "ts", + resolveDir: resolve(__dirname, "../../"), + }, + bundle: true, + format: "esm", + platform: "node", + target: "node18", + write: false, + treeShaking: true, + logLevel: "silent", + }); + + expect(result.errors).toHaveLength(0); + expect(result.outputFiles).toHaveLength(1); + }); +}); diff --git a/internal-packages/sdk-compat-tests/src/tests/import.test.ts b/internal-packages/sdk-compat-tests/src/tests/import.test.ts new file mode 100644 index 0000000000..7d81ccce25 --- /dev/null +++ b/internal-packages/sdk-compat-tests/src/tests/import.test.ts @@ -0,0 +1,84 @@ +/** + * Import Validation Tests + * + * These tests validate that the SDK can be imported correctly across + * different module systems (ESM and CJS). + */ + +import { describe, it, expect, beforeAll } from "vitest"; +import { execa, type Options as ExecaOptions } from "execa"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const fixturesDir = resolve(__dirname, "../fixtures"); + +// Find the SDK package in the monorepo +const sdkDir = resolve(__dirname, "../../../../packages/trigger-sdk"); + +// Common execa options +const execaOpts: ExecaOptions = { + env: { + ...process.env, + // Ensure Node.js can resolve workspace packages + NODE_PATH: resolve(__dirname, "../../../../node_modules"), + }, + timeout: 30_000, +}; + +describe("ESM Import Tests", () => { + it("should import SDK using ESM syntax", async () => { + const result = await execa("node", ["test.mjs"], { + ...execaOpts, + cwd: resolve(fixturesDir, "esm-import"), + }); + + expect(result.stdout).toContain("SUCCESS"); + expect(result.exitCode).toBe(0); + }); + + it("should validate superjson serialization in ESM", async () => { + const result = await execa("node", ["superjson-test.mjs"], { + ...execaOpts, + cwd: resolve(fixturesDir, "esm-import"), + }); + + expect(result.stdout).toContain("SUCCESS"); + expect(result.exitCode).toBe(0); + }); +}); + +describe("CJS Require Tests", () => { + it("should require SDK using CommonJS syntax", async () => { + const result = await execa("node", ["test.cjs"], { + ...execaOpts, + cwd: resolve(fixturesDir, "cjs-require"), + }); + + expect(result.stdout).toContain("SUCCESS"); + expect(result.exitCode).toBe(0); + }); + + it("should work with --experimental-require-module flag on older Node", async () => { + // This flag is needed for Node < 22.12.0 to require ESM modules + // On newer Node.js, it's a no-op + const result = await execa("node", ["--experimental-require-module", "test.cjs"], { + ...execaOpts, + cwd: resolve(fixturesDir, "cjs-require"), + }); + + expect(result.stdout).toContain("SUCCESS"); + expect(result.exitCode).toBe(0); + }); +}); + +describe("TypeScript Compilation Tests", () => { + it("should typecheck SDK imports successfully", async () => { + const result = await execa("npx", ["tsc", "--noEmit"], { + ...execaOpts, + cwd: resolve(fixturesDir, "typescript"), + }); + + expect(result.exitCode).toBe(0); + }); +}); diff --git a/internal-packages/sdk-compat-tests/tsconfig.json b/internal-packages/sdk-compat-tests/tsconfig.json new file mode 100644 index 0000000000..05afb6f355 --- /dev/null +++ b/internal-packages/sdk-compat-tests/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": false, + "outDir": "dist", + "rootDir": "src", + "types": ["vitest/globals"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "src/fixtures"] +} diff --git a/internal-packages/sdk-compat-tests/vitest.config.ts b/internal-packages/sdk-compat-tests/vitest.config.ts new file mode 100644 index 0000000000..2617dd1018 --- /dev/null +++ b/internal-packages/sdk-compat-tests/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/tests/**/*.test.ts"], + globals: true, + isolate: true, + testTimeout: 120_000, // Some framework builds can take time + hookTimeout: 60_000, + }, +}); diff --git a/packages/core/package.json b/packages/core/package.json index 989a707eae..d73b425f7d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -157,11 +157,13 @@ }, "sideEffects": false, "scripts": { - "clean": "rimraf dist .tshy .tshy-build .turbo", + "clean": "rimraf dist .tshy .tshy-build .turbo src/v3/vendor", "update-version": "tsx ../../scripts/updateVersion.ts", - "build": "tshy && pnpm run update-version", - "dev": "tshy --watch", - "typecheck": "tsc --noEmit -p tsconfig.src.json", + "bundle-vendor": "node scripts/bundle-superjson.mjs", + "build": "pnpm run bundle-vendor && tshy && node scripts/bundle-superjson.mjs --copy && pnpm run update-version", + "dev": "pnpm run bundle-vendor && tshy --watch", + "typecheck": "pnpm run bundle-vendor && tsc --noEmit -p tsconfig.src.json", + "pretest": "pnpm run bundle-vendor", "test": "vitest", "check-exports": "attw --pack ." }, @@ -193,7 +195,6 @@ "socket.io": "4.7.4", "socket.io-client": "4.7.5", "std-env": "^3.8.1", - "superjson": "^2.2.1", "tinyexec": "^0.3.2", "uncrypto": "^0.1.3", "zod": "3.25.76", @@ -212,6 +213,7 @@ "defu": "^6.1.4", "esbuild": "^0.23.0", "rimraf": "^3.0.2", + "superjson": "^2.2.1", "ts-essentials": "10.0.1", "tshy": "^3.0.2", "tsx": "4.17.0" diff --git a/packages/core/scripts/bundle-superjson.mjs b/packages/core/scripts/bundle-superjson.mjs new file mode 100644 index 0000000000..c4e9a7b001 --- /dev/null +++ b/packages/core/scripts/bundle-superjson.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node + +/** + * This script bundles superjson and its dependency (copy-anything) into + * vendored CJS and ESM bundles to avoid the ERR_REQUIRE_ESM error. + * + * superjson v2.x is ESM-only, which causes issues on: + * - Node.js versions before 22.12.0 (require(ESM) not enabled by default) + * - AWS Lambda (intentionally disables require(ESM)) + * + * The output files are gitignored and regenerated during each build. + * This script runs automatically as part of `pnpm run build`. + * + * Usage: + * node scripts/bundle-superjson.mjs # Bundle to src/v3/vendor + * node scripts/bundle-superjson.mjs --copy # Also copy to dist directories + */ + +import * as esbuild from "esbuild"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { readFileSync, mkdirSync, copyFileSync } from "node:fs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const packageRoot = join(__dirname, ".."); +const vendorDir = join(packageRoot, "src", "v3", "vendor"); + +// Get the installed superjson version for the banner +const superjsonPkg = JSON.parse( + readFileSync(join(packageRoot, "node_modules", "superjson", "package.json"), "utf-8") +); +const banner = `/** + * Bundled superjson v${superjsonPkg.version} + * + * This file is auto-generated by scripts/bundle-superjson.mjs + * Do not edit directly - run the script to regenerate. + * + * Original package: https://github.com/flightcontrolhq/superjson + * License: MIT + */`; + +async function bundle() { + // Ensure vendor directory exists + mkdirSync(vendorDir, { recursive: true }); + + // Bundle for CommonJS + await esbuild.build({ + entryPoints: [join(packageRoot, "node_modules", "superjson", "dist", "index.js")], + bundle: true, + format: "cjs", + platform: "node", + target: "node18", + outfile: join(vendorDir, "superjson.cjs"), + banner: { js: banner }, + // Don't minify to keep it debuggable + minify: false, + }); + + // Bundle for ESM + await esbuild.build({ + entryPoints: [join(packageRoot, "node_modules", "superjson", "dist", "index.js")], + bundle: true, + format: "esm", + platform: "node", + target: "node18", + outfile: join(vendorDir, "superjson.mjs"), + banner: { js: banner }, + minify: false, + }); + + console.log("Bundled superjson v" + superjsonPkg.version); + console.log(" -> src/v3/vendor/superjson.cjs (CommonJS)"); + console.log(" -> src/v3/vendor/superjson.mjs (ESM)"); + + // Copy to dist directories if --copy flag is passed + if (process.argv.includes("--copy")) { + const distCommonjsVendor = join(packageRoot, "dist", "commonjs", "v3", "vendor"); + const distEsmVendor = join(packageRoot, "dist", "esm", "v3", "vendor"); + + mkdirSync(distCommonjsVendor, { recursive: true }); + mkdirSync(distEsmVendor, { recursive: true }); + + copyFileSync(join(vendorDir, "superjson.cjs"), join(distCommonjsVendor, "superjson.cjs")); + copyFileSync(join(vendorDir, "superjson.mjs"), join(distEsmVendor, "superjson.mjs")); + + console.log("Copied to dist directories"); + } +} + +bundle().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/core/src/v3/imports/superjson-cjs.cts b/packages/core/src/v3/imports/superjson-cjs.cts index a7f1466e7c..89ed3d2458 100644 --- a/packages/core/src/v3/imports/superjson-cjs.cts +++ b/packages/core/src/v3/imports/superjson-cjs.cts @@ -1,8 +1,10 @@ +// Use vendored superjson bundle to avoid ESM/CJS compatibility issues +// See: https://github.com/triggerdotdev/trigger.dev/issues/2937 // @ts-ignore -const { default: superjson } = require("superjson"); +const superjson = require("../vendor/superjson.cjs"); // @ts-ignore -superjson.registerCustom( +superjson.default.registerCustom( { isApplicable: (v: unknown): v is Buffer => typeof Buffer === "function" && Buffer.isBuffer(v), serialize: (v: Buffer) => [...v], @@ -12,4 +14,4 @@ superjson.registerCustom( ); // @ts-ignore -module.exports.default = superjson; +module.exports.default = superjson.default; diff --git a/packages/core/src/v3/imports/superjson.ts b/packages/core/src/v3/imports/superjson.ts index aa29250523..1545c083e0 100644 --- a/packages/core/src/v3/imports/superjson.ts +++ b/packages/core/src/v3/imports/superjson.ts @@ -1,11 +1,13 @@ +// Use vendored superjson bundle to avoid ESM/CJS compatibility issues +// See: https://github.com/triggerdotdev/trigger.dev/issues/2937 // @ts-ignore -import superjson from "superjson"; +import superjson from "../vendor/superjson.mjs"; superjson.registerCustom( { - isApplicable: (v): v is Buffer => typeof Buffer === "function" && Buffer.isBuffer(v), - serialize: (v) => [...v], - deserialize: (v) => Buffer.from(v), + isApplicable: (v: unknown): v is Buffer => typeof Buffer === "function" && Buffer.isBuffer(v), + serialize: (v: Buffer) => [...v], + deserialize: (v: number[]) => Buffer.from(v), }, "buffer" ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 76f12a8774..0186ca04ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1088,7 +1088,7 @@ importers: version: 18.3.1 react-email: specifier: ^2.1.1 - version: 2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(bufferutil@4.0.9)(eslint@8.31.0) + version: 2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(eslint@8.31.0) resend: specifier: ^3.2.0 version: 3.2.0 @@ -1723,9 +1723,6 @@ importers: std-env: specifier: ^3.8.1 version: 3.8.1 - superjson: - specifier: ^2.2.1 - version: 2.2.1 tinyexec: specifier: ^0.3.2 version: 0.3.2 @@ -1775,6 +1772,9 @@ importers: rimraf: specifier: ^3.0.2 version: 3.0.2 + superjson: + specifier: ^2.2.1 + version: 2.2.1 ts-essentials: specifier: 10.0.1 version: 10.0.1(typescript@5.5.4) @@ -38600,7 +38600,7 @@ snapshots: react: 19.1.0 scheduler: 0.26.0 - react-email@2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(bufferutil@4.0.9)(eslint@8.31.0): + react-email@2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(eslint@8.31.0): dependencies: '@babel/parser': 7.24.1 '@radix-ui/colors': 1.0.1 @@ -38637,8 +38637,8 @@ snapshots: react: 18.3.1 react-dom: 18.2.0(react@18.3.1) shelljs: 0.8.5 - socket.io: 4.7.3(bufferutil@4.0.9) - socket.io-client: 4.7.3(bufferutil@4.0.9) + socket.io: 4.7.3 + socket.io-client: 4.7.3 sonner: 1.3.1(react-dom@18.2.0(react@18.3.1))(react@18.3.1) source-map-js: 1.0.2 stacktrace-parser: 0.1.10 @@ -39785,7 +39785,7 @@ snapshots: - supports-color - utf-8-validate - socket.io-client@4.7.3(bufferutil@4.0.9): + socket.io-client@4.7.3: dependencies: '@socket.io/component-emitter': 3.1.0 debug: 4.3.7(supports-color@10.0.0) @@ -39814,7 +39814,7 @@ snapshots: transitivePeerDependencies: - supports-color - socket.io@4.7.3(bufferutil@4.0.9): + socket.io@4.7.3: dependencies: accepts: 1.3.8 base64id: 2.0.0