Skip to content

Commit 06c5bec

Browse files
committed
Add support for triggering from the backend
1 parent bf5c64b commit 06c5bec

File tree

13 files changed

+1442
-104
lines changed

13 files changed

+1442
-104
lines changed

packages/build/package.json

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
"./extensions/typescript": "./src/extensions/typescript.ts",
3232
"./extensions/puppeteer": "./src/extensions/puppeteer.ts",
3333
"./extensions/playwright": "./src/extensions/playwright.ts",
34-
"./extensions/lightpanda": "./src/extensions/lightpanda.ts"
34+
"./extensions/lightpanda": "./src/extensions/lightpanda.ts",
35+
"./extensions/secureExec": "./src/extensions/secureExec.ts"
3536
},
3637
"sourceDialects": [
3738
"@triggerdotdev/source"
@@ -65,6 +66,9 @@
6566
],
6667
"extensions/lightpanda": [
6768
"dist/commonjs/extensions/lightpanda.d.ts"
69+
],
70+
"extensions/secureExec": [
71+
"dist/commonjs/extensions/secureExec.d.ts"
6872
]
6973
}
7074
},
@@ -207,6 +211,17 @@
207211
"types": "./dist/commonjs/extensions/lightpanda.d.ts",
208212
"default": "./dist/commonjs/extensions/lightpanda.js"
209213
}
214+
},
215+
"./extensions/secureExec": {
216+
"import": {
217+
"@triggerdotdev/source": "./src/extensions/secureExec.ts",
218+
"types": "./dist/esm/extensions/secureExec.d.ts",
219+
"default": "./dist/esm/extensions/secureExec.js"
220+
},
221+
"require": {
222+
"types": "./dist/commonjs/extensions/secureExec.d.ts",
223+
"default": "./dist/commonjs/extensions/secureExec.js"
224+
}
210225
}
211226
},
212227
"main": "./dist/commonjs/index.js",
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { BuildTarget } from "@trigger.dev/core/v3";
2+
import { BuildManifest } from "@trigger.dev/core/v3/schemas";
3+
import { BuildContext, BuildExtension } from "@trigger.dev/core/v3/build";
4+
import { dirname, resolve, join } from "node:path";
5+
import { readFileSync } from "node:fs";
6+
import { createRequire } from "node:module";
7+
import { readPackageJSON } from "pkg-types";
8+
9+
export type SecureExecOptions = {
10+
/**
11+
* Packages available inside the sandbox at runtime.
12+
*
13+
* These are `require()`'d inside the V8 isolate at runtime — the bundler
14+
* never sees them statically. They are marked external and installed as
15+
* deploy dependencies.
16+
*
17+
* @example
18+
* ```ts
19+
* secureExec({ packages: ["jszip", "lodash"] })
20+
* ```
21+
*/
22+
packages?: string[];
23+
};
24+
25+
/**
26+
* Build extension for [secure-exec](https://secureexec.dev) — run untrusted
27+
* JavaScript/TypeScript in V8 isolates with configurable permissions.
28+
*
29+
* Handles the esbuild workarounds needed for secure-exec's runtime
30+
* `require.resolve` calls, native binaries, and module-scope resolution.
31+
*
32+
* @example
33+
* ```ts
34+
* import { secureExec } from "@trigger.dev/build/extensions/secureExec";
35+
*
36+
* export default defineConfig({
37+
* build: {
38+
* extensions: [secureExec()],
39+
* },
40+
* });
41+
* ```
42+
*/
43+
export function secureExec(options?: SecureExecOptions): BuildExtension {
44+
return new SecureExecExtension(options ?? {});
45+
}
46+
47+
class SecureExecExtension implements BuildExtension {
48+
public readonly name = "SecureExecExtension";
49+
50+
private userPackages: string[];
51+
52+
constructor(options: SecureExecOptions) {
53+
this.userPackages = options.packages ?? [];
54+
}
55+
56+
externalsForTarget(_target: BuildTarget) {
57+
return [
58+
// esbuild must not be bundled — it locates its native binary via a
59+
// relative path from its JS API entry point. secure-exec uses esbuild
60+
// at runtime to bundle polyfills for sandbox code.
61+
"esbuild",
62+
// User-specified packages are require()'d inside the V8 sandbox at
63+
// runtime — the bundler never sees them statically.
64+
...this.userPackages,
65+
];
66+
}
67+
68+
onBuildStart(context: BuildContext) {
69+
context.logger.debug(`Adding ${this.name} esbuild plugins`);
70+
71+
// Plugin 1: Replace node-stdlib-browser with pre-resolved paths.
72+
//
73+
// Trigger's ESM shim anchors require.resolve() to the chunk path, so
74+
// node-stdlib-browser's runtime require.resolve("./mock/empty.js") breaks.
75+
// Fix: load the real node-stdlib-browser at build time (where require.resolve
76+
// works), capture the resolved path map, and inline it as a static export.
77+
const workingDir = context.workingDir;
78+
context.registerPlugin({
79+
name: "secure-exec-stdlib-resolver",
80+
setup(build) {
81+
build.onResolve({ filter: /^node-stdlib-browser$/ }, () => ({
82+
path: "node-stdlib-browser",
83+
namespace: "secure-exec-nsb-resolved",
84+
}));
85+
build.onLoad({ filter: /.*/, namespace: "secure-exec-nsb-resolved" }, () => {
86+
const buildRequire = createRequire(join(workingDir, "package.json"));
87+
const resolved = buildRequire("node-stdlib-browser");
88+
return {
89+
contents: `export default ${JSON.stringify(resolved)};`,
90+
loader: "js",
91+
};
92+
});
93+
},
94+
});
95+
96+
// Plugin 2: Inline bridge.js at build time.
97+
//
98+
// bridge-loader.js in @secure-exec/node(js) uses __dirname and
99+
// require.resolve("@secure-exec/core") at module scope to locate
100+
// dist/bridge.js on disk. This fails in Trigger's bundled output.
101+
// Fix: read bridge.js content at build time and inline it as a
102+
// string literal so no runtime filesystem resolution is needed.
103+
//
104+
context.registerPlugin({
105+
name: "secure-exec-bridge-inline",
106+
setup(build) {
107+
build.onLoad(
108+
{ filter: /[\\/]@secure-exec[\\/]node[\\/]dist[\\/]bridge-loader\.js$/ },
109+
(args) => {
110+
try {
111+
const buildRequire = createRequire(args.path);
112+
const coreEntry = buildRequire.resolve("@secure-exec/core");
113+
const coreRoot = resolve(dirname(coreEntry), "..");
114+
const bridgeCode = readFileSync(join(coreRoot, "dist", "bridge.js"), "utf8");
115+
116+
return {
117+
contents: [
118+
`import { getIsolateRuntimeSource } from "@secure-exec/core";`,
119+
`const bridgeCodeCache = ${JSON.stringify(bridgeCode)};`,
120+
`export function getRawBridgeCode() { return bridgeCodeCache; }`,
121+
`export function getBridgeAttachCode() { return getIsolateRuntimeSource("bridgeAttach"); }`,
122+
].join("\n"),
123+
loader: "js",
124+
};
125+
} catch {
126+
// If we can't inline the bridge, let the normal loader handle it.
127+
return undefined;
128+
}
129+
}
130+
);
131+
},
132+
});
133+
}
134+
135+
async onBuildComplete(context: BuildContext, _manifest: BuildManifest) {
136+
if (context.target === "dev") {
137+
return;
138+
}
139+
140+
context.logger.debug(`Adding ${this.name} deploy dependencies`);
141+
142+
const dependencies: Record<string, string> = {};
143+
144+
// Resolve versions for user-specified sandbox packages
145+
for (const pkg of this.userPackages) {
146+
try {
147+
const modulePath = await context.resolvePath(pkg);
148+
if (!modulePath) {
149+
dependencies[pkg] = "latest";
150+
continue;
151+
}
152+
153+
const packageJSON = await readPackageJSON(dirname(modulePath));
154+
dependencies[pkg] = packageJSON.version ?? "latest";
155+
} catch {
156+
context.logger.warn(
157+
`Could not resolve version for sandbox package ${pkg}, defaulting to latest`
158+
);
159+
dependencies[pkg] = "latest";
160+
}
161+
}
162+
163+
context.addLayer({
164+
id: "secureExec",
165+
dependencies,
166+
image: {
167+
// isolated-vm requires native compilation tools
168+
pkgs: ["python3", "make", "g++"],
169+
},
170+
});
171+
}
172+
}

packages/trigger-sdk/src/v3/ai.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ import { locals } from "./locals.js";
4343
import { metadata } from "./metadata.js";
4444
import type { ResolvedPrompt } from "./prompt.js";
4545
import { streams } from "./streams.js";
46-
import { createTask } from "./shared.js";
46+
import { createTask, trigger as triggerTaskInternal } from "./shared.js";
47+
import type { TriggerChatTaskParams, TriggerChatTaskResult } from "./chat.js";
4748
import { tracer } from "./tracer.js";
4849

4950
/** Re-export for typing `ctx` in `chat.task` hooks without importing `@trigger.dev/core`. */
@@ -5125,13 +5126,72 @@ export type InferChatUIMessage<TTask extends AnyTask> = TTask extends Task<
51255126
? TUIM
51265127
: UIMessage;
51275128

5129+
/**
5130+
* Options for {@link createChatTriggerAction}.
5131+
*/
5132+
export type CreateChatTriggerActionOptions = {
5133+
/** TTL for the run-scoped public access token. @default "1h" */
5134+
tokenTTL?: string | number | Date;
5135+
};
5136+
5137+
/**
5138+
* Creates a function that triggers a chat task and returns a run-scoped session.
5139+
*
5140+
* Wrap the returned function in a Next.js server action (or any server-side handler)
5141+
* to keep task triggering on the server. The function calls `tasks.trigger()` with
5142+
* the secret key and mints a run-scoped PAT for stream subscription + input stream writes.
5143+
*
5144+
* @example
5145+
* ```ts
5146+
* // actions.ts
5147+
* "use server";
5148+
* import { chat } from "@trigger.dev/sdk/ai";
5149+
*
5150+
* export const triggerChat = chat.createTriggerAction("my-chat");
5151+
* ```
5152+
*
5153+
* Then pass it to the transport:
5154+
* ```tsx
5155+
* const transport = useTriggerChatTransport({
5156+
* task: "my-chat",
5157+
* triggerTask: triggerChat,
5158+
* });
5159+
* ```
5160+
*/
5161+
function createChatTriggerAction(
5162+
taskId: string,
5163+
options?: CreateChatTriggerActionOptions
5164+
): (params: TriggerChatTaskParams) => Promise<TriggerChatTaskResult> {
5165+
return async (params: TriggerChatTaskParams): Promise<TriggerChatTaskResult> => {
5166+
const handle = await triggerTaskInternal(taskId, params.payload, {
5167+
tags: params.options.tags,
5168+
queue: params.options.queue,
5169+
maxAttempts: params.options.maxAttempts,
5170+
machine: params.options.machine as any,
5171+
priority: params.options.priority,
5172+
});
5173+
5174+
const publicAccessToken = await auth.createPublicToken({
5175+
scopes: {
5176+
read: { runs: handle.id },
5177+
write: { inputStreams: handle.id },
5178+
},
5179+
expirationTime: options?.tokenTTL ?? "1h",
5180+
});
5181+
5182+
return { runId: handle.id, publicAccessToken };
5183+
};
5184+
}
5185+
51285186
export const chat = {
51295187
/** Create a chat task. See {@link chatTask}. */
51305188
task: chatTask,
51315189
/** Create a chat task with a fixed {@link UIMessage} subtype and optional default stream options. See {@link withUIMessage}. */
51325190
withUIMessage,
51335191
/** Create a chat task with a fixed client data schema. See {@link withClientData}. */
51345192
withClientData,
5193+
/** Create a server-side trigger action helper. See {@link createChatTriggerAction}. */
5194+
createTriggerAction: createChatTriggerAction,
51355195
/** Pipe a stream to the chat transport. See {@link pipeChat}. */
51365196
pipe: pipeChat,
51375197
/** Create a per-run typed local. See {@link chatLocal}. */

packages/trigger-sdk/src/v3/chat-react.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,11 @@ export function useTriggerChatTransport<TTask extends AnyTask = AnyTask>(
8686
): TriggerChatTransport {
8787
const ref = useRef<TriggerChatTransport | null>(null);
8888
if (ref.current === null) {
89-
ref.current = new TriggerChatTransport(options);
89+
ref.current = new TriggerChatTransport(options as TriggerChatTransportOptions);
9090
}
9191

92-
// Keep onSessionChange up to date without recreating the transport
93-
const { onSessionChange, renewRunAccessToken } = options;
92+
// Keep callbacks up to date without recreating the transport
93+
const { onSessionChange, renewRunAccessToken, triggerTask } = options;
9494
useEffect(() => {
9595
ref.current?.setOnSessionChange(onSessionChange);
9696
}, [onSessionChange]);
@@ -99,6 +99,10 @@ export function useTriggerChatTransport<TTask extends AnyTask = AnyTask>(
9999
ref.current?.setRenewRunAccessToken(renewRunAccessToken);
100100
}, [renewRunAccessToken]);
101101

102+
useEffect(() => {
103+
ref.current?.setTriggerTask(triggerTask);
104+
}, [triggerTask]);
105+
102106
return ref.current;
103107
}
104108

0 commit comments

Comments
 (0)