diff --git a/.changeset/envvars-update-outside-task.md b/.changeset/envvars-update-outside-task.md new file mode 100644 index 00000000000..19afdd86a8b --- /dev/null +++ b/.changeset/envvars-update-outside-task.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/sdk": patch +--- + +`envvars.update()`: calling it outside a task run no longer throws `ReferenceError: name is not defined`. The variable name is now resolved from the positional arguments, matching the other env var methods, and a missing name raises a descriptive `name is required` error instead. diff --git a/packages/core/src/v3/utils/ioSerialization.test.ts b/packages/core/src/v3/utils/ioSerialization.test.ts new file mode 100644 index 00000000000..593ed256946 --- /dev/null +++ b/packages/core/src/v3/utils/ioSerialization.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { stringifyIO } from "./ioSerialization.js"; + +describe("stringifyIO", () => { + it("returns undefined data for undefined input", async () => { + const result = await stringifyIO(undefined); + expect(result).toEqual({ dataType: "application/json" }); + }); + + it("returns plain text for string input", async () => { + const result = await stringifyIO("hello world"); + expect(result).toEqual({ data: "hello world", dataType: "text/plain" }); + }); + + it("serializes normal objects using super+json", async () => { + const result = await stringifyIO({ key: "value", num: 42 }); + expect(result.dataType).toEqual("application/super+json"); + expect(typeof result.data).toBe("string"); + }); + + it("fallback returns string data when superjson fails", async () => { + // Create an object where superjson.stringify throws or handles non-standard values + const cyclic: any = { name: "test" }; + cyclic.self = cyclic; + + const result = await stringifyIO(cyclic); + expect(typeof result.data).toBe("string"); + }); +}); diff --git a/packages/core/src/v3/utils/ioSerialization.ts b/packages/core/src/v3/utils/ioSerialization.ts index a63a4397303..cd070c3be3c 100644 --- a/packages/core/src/v3/utils/ioSerialization.ts +++ b/packages/core/src/v3/utils/ioSerialization.ts @@ -96,7 +96,13 @@ export async function stringifyIO(value: any): Promise { return { data, dataType: "application/super+json" }; } catch { - return { data: value, dataType: "application/json" }; + try { + const data = JSON.stringify(value, makeSafeReplacer()); + + return { data, dataType: "application/json" }; + } catch { + return { data: String(value), dataType: "text/plain" }; + } } } diff --git a/packages/trigger-sdk/src/v3/envvars.test.ts b/packages/trigger-sdk/src/v3/envvars.test.ts new file mode 100644 index 00000000000..e27a4cfe545 --- /dev/null +++ b/packages/trigger-sdk/src/v3/envvars.test.ts @@ -0,0 +1,192 @@ +import { taskContext } from "@trigger.dev/core/v3"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { update } from "./envvars.js"; + +describe("envvars.update outside a task context", () => { + let server: Server | undefined; + let requests: { method?: string; url?: string; body: string }[]; + let previousApiUrl: string | undefined; + let previousSecretKey: string | undefined; + let previousAccessToken: string | undefined; + + beforeEach(async () => { + requests = []; + previousApiUrl = process.env.TRIGGER_API_URL; + previousSecretKey = process.env.TRIGGER_SECRET_KEY; + previousAccessToken = process.env.TRIGGER_ACCESS_TOKEN; + + delete process.env.TRIGGER_SECRET_KEY; + + server = createServer((req, res) => { + let body = ""; + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => { + requests.push({ method: req.method, url: req.url, body }); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ success: true })); + }); + }); + + await new Promise((resolve) => server!.listen(0, "127.0.0.1", resolve)); + + const { port } = server!.address() as AddressInfo; + process.env.TRIGGER_API_URL = `http://127.0.0.1:${port}`; + process.env.TRIGGER_ACCESS_TOKEN = "tr_test_token"; + }); + + afterEach(async () => { + if (previousApiUrl === undefined) { + delete process.env.TRIGGER_API_URL; + } else { + process.env.TRIGGER_API_URL = previousApiUrl; + } + + if (previousSecretKey === undefined) { + delete process.env.TRIGGER_SECRET_KEY; + } else { + process.env.TRIGGER_SECRET_KEY = previousSecretKey; + } + + if (previousAccessToken === undefined) { + delete process.env.TRIGGER_ACCESS_TOKEN; + } else { + process.env.TRIGGER_ACCESS_TOKEN = previousAccessToken; + } + + const running = server; + server = undefined; + + if (running) { + await new Promise((resolve, reject) => + running.close((error) => (error ? reject(error) : resolve())) + ); + } + }); + + it("sends a PUT for the named variable (regression #4264)", async () => { + expect(taskContext.ctx).toBeUndefined(); + + await expect(update("proj_xxx", "staging", "MY_VAR", { value: "hello" })).resolves.toEqual({ + success: true, + }); + + expect(requests).toHaveLength(1); + expect(requests[0]?.method).toBe("PUT"); + expect(requests[0]?.url).toBe("/api/v1/projects/proj_xxx/envvars/staging/MY_VAR"); + expect(JSON.parse(requests[0]?.body ?? "")).toEqual({ value: "hello" }); + }); + + it("throws a descriptive error when name is missing", () => { + expect(taskContext.ctx).toBeUndefined(); + + expect(() => + update("proj_xxx", "staging", undefined as unknown as string, { value: "hello" }) + ).toThrow("name is required"); + + expect(requests).toHaveLength(0); + }); +}); + +describe("envvars.update inside a task context", () => { + let server: Server | undefined; + let requests: { method?: string; url?: string; body: string }[]; + let previousApiUrl: string | undefined; + let previousSecretKey: string | undefined; + let previousAccessToken: string | undefined; + + beforeEach(async () => { + requests = []; + previousApiUrl = process.env.TRIGGER_API_URL; + previousSecretKey = process.env.TRIGGER_SECRET_KEY; + previousAccessToken = process.env.TRIGGER_ACCESS_TOKEN; + + delete process.env.TRIGGER_SECRET_KEY; + + server = createServer((req, res) => { + let body = ""; + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => { + requests.push({ method: req.method, url: req.url, body }); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ success: true })); + }); + }); + + await new Promise((resolve) => server!.listen(0, "127.0.0.1", resolve)); + + const { port } = server!.address() as AddressInfo; + process.env.TRIGGER_API_URL = `http://127.0.0.1:${port}`; + process.env.TRIGGER_ACCESS_TOKEN = "tr_test_token"; + + taskContext.setGlobalLocation({ + ctx: { + project: { id: "proj_ctx_id", ref: "proj_ctx_ref", name: "Project Ctx" }, + environment: { id: "env_ctx_id", slug: "dev", type: "DEVELOPMENT" }, + organization: { id: "org_ctx_id", slug: "org_ctx", title: "Org Ctx" }, + run: { id: "run_ctx_id", isTest: false }, + task: { id: "task_ctx_id", filePath: "task.ts", exportName: "task" }, + + }, + }); + }); + + afterEach(async () => { + taskContext.clear(); + + if (previousApiUrl === undefined) { + delete process.env.TRIGGER_API_URL; + } else { + process.env.TRIGGER_API_URL = previousApiUrl; + } + + if (previousSecretKey === undefined) { + delete process.env.TRIGGER_SECRET_KEY; + } else { + process.env.TRIGGER_SECRET_KEY = previousSecretKey; + } + + if (previousAccessToken === undefined) { + delete process.env.TRIGGER_ACCESS_TOKEN; + } else { + process.env.TRIGGER_ACCESS_TOKEN = previousAccessToken; + } + + const running = server; + server = undefined; + + if (running) { + await new Promise((resolve, reject) => + running.close((error) => (error ? reject(error) : resolve())) + ); + } + }); + + it("correctly parses explicit projectRef, slug, name parameters when taskContext exists", async () => { + await expect(update("proj_explicit", "staging", "MY_VAR", { value: "hello" })).resolves.toEqual({ + success: true, + }); + + expect(requests).toHaveLength(1); + expect(requests[0]?.method).toBe("PUT"); + expect(requests[0]?.url).toBe("/api/v1/projects/proj_explicit/envvars/staging/MY_VAR"); + expect(JSON.parse(requests[0]?.body ?? "")).toEqual({ value: "hello" }); + }); + + it("correctly uses taskContext defaults when only name and params are provided", async () => { + await expect(update("MY_VAR", { value: "hello_context" })).resolves.toEqual({ + success: true, + }); + + expect(requests).toHaveLength(1); + expect(requests[0]?.method).toBe("PUT"); + expect(requests[0]?.url).toBe("/api/v1/projects/proj_ctx_ref/envvars/dev/MY_VAR"); + expect(JSON.parse(requests[0]?.body ?? "")).toEqual({ value: "hello_context" }); + }); +}); + diff --git a/packages/trigger-sdk/src/v3/envvars.ts b/packages/trigger-sdk/src/v3/envvars.ts index 8ff68ab8907..201e70903a1 100644 --- a/packages/trigger-sdk/src/v3/envvars.ts +++ b/packages/trigger-sdk/src/v3/envvars.ts @@ -305,12 +305,12 @@ export function update( if (taskContext.ctx) { if (typeof slugOrParams === "string") { - $projectRef = slugOrParams; - $slug = slugOrParams ?? taskContext.ctx.environment.slug; - $name = - typeof nameOrRequestOptions === "string" - ? nameOrRequestOptions - : taskContext.ctx.environment.slug; + $projectRef = projectRefOrName; + $slug = slugOrParams; + if (typeof nameOrRequestOptions !== "string" || !nameOrRequestOptions) { + throw new Error("name is required"); + } + $name = nameOrRequestOptions; if (!params) { throw new Error("params is required"); @@ -332,13 +332,17 @@ export function update( throw new Error("projectRef is required"); } + if (typeof nameOrRequestOptions !== "string" || !nameOrRequestOptions) { + throw new Error("name is required"); + } + if (!params) { throw new Error("params is required"); } $projectRef = projectRefOrName; $slug = slugOrParams; - $name = name!; + $name = nameOrRequestOptions; $params = params; }