Add support for triggering from the backend
This commit is contained in:
@@ -31,7 +31,8 @@
|
||||
"./extensions/typescript": "./src/extensions/typescript.ts",
|
||||
"./extensions/puppeteer": "./src/extensions/puppeteer.ts",
|
||||
"./extensions/playwright": "./src/extensions/playwright.ts",
|
||||
"./extensions/lightpanda": "./src/extensions/lightpanda.ts"
|
||||
"./extensions/lightpanda": "./src/extensions/lightpanda.ts",
|
||||
"./extensions/secureExec": "./src/extensions/secureExec.ts"
|
||||
},
|
||||
"sourceDialects": [
|
||||
"@triggerdotdev/source"
|
||||
@@ -65,6 +66,9 @@
|
||||
],
|
||||
"extensions/lightpanda": [
|
||||
"dist/commonjs/extensions/lightpanda.d.ts"
|
||||
],
|
||||
"extensions/secureExec": [
|
||||
"dist/commonjs/extensions/secureExec.d.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -207,6 +211,17 @@
|
||||
"types": "./dist/commonjs/extensions/lightpanda.d.ts",
|
||||
"default": "./dist/commonjs/extensions/lightpanda.js"
|
||||
}
|
||||
},
|
||||
"./extensions/secureExec": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/extensions/secureExec.ts",
|
||||
"types": "./dist/esm/extensions/secureExec.d.ts",
|
||||
"default": "./dist/esm/extensions/secureExec.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/extensions/secureExec.d.ts",
|
||||
"default": "./dist/commonjs/extensions/secureExec.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"main": "./dist/commonjs/index.js",
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { BuildTarget } from "@trigger.dev/core/v3";
|
||||
import { BuildManifest } from "@trigger.dev/core/v3/schemas";
|
||||
import { BuildContext, BuildExtension } from "@trigger.dev/core/v3/build";
|
||||
import { dirname, resolve, join } from "node:path";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { readPackageJSON } from "pkg-types";
|
||||
|
||||
export type SecureExecOptions = {
|
||||
/**
|
||||
* Packages available inside the sandbox at runtime.
|
||||
*
|
||||
* These are `require()`'d inside the V8 isolate at runtime — the bundler
|
||||
* never sees them statically. They are marked external and installed as
|
||||
* deploy dependencies.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* secureExec({ packages: ["jszip", "lodash"] })
|
||||
* ```
|
||||
*/
|
||||
packages?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Build extension for [secure-exec](https://secureexec.dev) — run untrusted
|
||||
* JavaScript/TypeScript in V8 isolates with configurable permissions.
|
||||
*
|
||||
* Handles the esbuild workarounds needed for secure-exec's runtime
|
||||
* `require.resolve` calls, native binaries, and module-scope resolution.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { secureExec } from "@trigger.dev/build/extensions/secureExec";
|
||||
*
|
||||
* export default defineConfig({
|
||||
* build: {
|
||||
* extensions: [secureExec()],
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function secureExec(options?: SecureExecOptions): BuildExtension {
|
||||
return new SecureExecExtension(options ?? {});
|
||||
}
|
||||
|
||||
class SecureExecExtension implements BuildExtension {
|
||||
public readonly name = "SecureExecExtension";
|
||||
|
||||
private userPackages: string[];
|
||||
|
||||
constructor(options: SecureExecOptions) {
|
||||
this.userPackages = options.packages ?? [];
|
||||
}
|
||||
|
||||
externalsForTarget(_target: BuildTarget) {
|
||||
return [
|
||||
// esbuild must not be bundled — it locates its native binary via a
|
||||
// relative path from its JS API entry point. secure-exec uses esbuild
|
||||
// at runtime to bundle polyfills for sandbox code.
|
||||
"esbuild",
|
||||
// User-specified packages are require()'d inside the V8 sandbox at
|
||||
// runtime — the bundler never sees them statically.
|
||||
...this.userPackages,
|
||||
];
|
||||
}
|
||||
|
||||
onBuildStart(context: BuildContext) {
|
||||
context.logger.debug(`Adding ${this.name} esbuild plugins`);
|
||||
|
||||
// Plugin 1: Replace node-stdlib-browser with pre-resolved paths.
|
||||
//
|
||||
// Trigger's ESM shim anchors require.resolve() to the chunk path, so
|
||||
// node-stdlib-browser's runtime require.resolve("./mock/empty.js") breaks.
|
||||
// Fix: load the real node-stdlib-browser at build time (where require.resolve
|
||||
// works), capture the resolved path map, and inline it as a static export.
|
||||
const workingDir = context.workingDir;
|
||||
context.registerPlugin({
|
||||
name: "secure-exec-stdlib-resolver",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^node-stdlib-browser$/ }, () => ({
|
||||
path: "node-stdlib-browser",
|
||||
namespace: "secure-exec-nsb-resolved",
|
||||
}));
|
||||
build.onLoad({ filter: /.*/, namespace: "secure-exec-nsb-resolved" }, () => {
|
||||
const buildRequire = createRequire(join(workingDir, "package.json"));
|
||||
const resolved = buildRequire("node-stdlib-browser");
|
||||
return {
|
||||
contents: `export default ${JSON.stringify(resolved)};`,
|
||||
loader: "js",
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Plugin 2: Inline bridge.js at build time.
|
||||
//
|
||||
// bridge-loader.js in @secure-exec/node(js) uses __dirname and
|
||||
// require.resolve("@secure-exec/core") at module scope to locate
|
||||
// dist/bridge.js on disk. This fails in Trigger's bundled output.
|
||||
// Fix: read bridge.js content at build time and inline it as a
|
||||
// string literal so no runtime filesystem resolution is needed.
|
||||
//
|
||||
context.registerPlugin({
|
||||
name: "secure-exec-bridge-inline",
|
||||
setup(build) {
|
||||
build.onLoad(
|
||||
{ filter: /[\\/]@secure-exec[\\/]node[\\/]dist[\\/]bridge-loader\.js$/ },
|
||||
(args) => {
|
||||
try {
|
||||
const buildRequire = createRequire(args.path);
|
||||
const coreEntry = buildRequire.resolve("@secure-exec/core");
|
||||
const coreRoot = resolve(dirname(coreEntry), "..");
|
||||
const bridgeCode = readFileSync(join(coreRoot, "dist", "bridge.js"), "utf8");
|
||||
|
||||
return {
|
||||
contents: [
|
||||
`import { getIsolateRuntimeSource } from "@secure-exec/core";`,
|
||||
`const bridgeCodeCache = ${JSON.stringify(bridgeCode)};`,
|
||||
`export function getRawBridgeCode() { return bridgeCodeCache; }`,
|
||||
`export function getBridgeAttachCode() { return getIsolateRuntimeSource("bridgeAttach"); }`,
|
||||
].join("\n"),
|
||||
loader: "js",
|
||||
};
|
||||
} catch {
|
||||
// If we can't inline the bridge, let the normal loader handle it.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async onBuildComplete(context: BuildContext, _manifest: BuildManifest) {
|
||||
if (context.target === "dev") {
|
||||
return;
|
||||
}
|
||||
|
||||
context.logger.debug(`Adding ${this.name} deploy dependencies`);
|
||||
|
||||
const dependencies: Record<string, string> = {};
|
||||
|
||||
// Resolve versions for user-specified sandbox packages
|
||||
for (const pkg of this.userPackages) {
|
||||
try {
|
||||
const modulePath = await context.resolvePath(pkg);
|
||||
if (!modulePath) {
|
||||
dependencies[pkg] = "latest";
|
||||
continue;
|
||||
}
|
||||
|
||||
const packageJSON = await readPackageJSON(dirname(modulePath));
|
||||
dependencies[pkg] = packageJSON.version ?? "latest";
|
||||
} catch {
|
||||
context.logger.warn(
|
||||
`Could not resolve version for sandbox package ${pkg}, defaulting to latest`
|
||||
);
|
||||
dependencies[pkg] = "latest";
|
||||
}
|
||||
}
|
||||
|
||||
context.addLayer({
|
||||
id: "secureExec",
|
||||
dependencies,
|
||||
image: {
|
||||
// isolated-vm requires native compilation tools
|
||||
pkgs: ["python3", "make", "g++"],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,8 @@ import { locals } from "./locals.js";
|
||||
import { metadata } from "./metadata.js";
|
||||
import type { ResolvedPrompt } from "./prompt.js";
|
||||
import { streams } from "./streams.js";
|
||||
import { createTask } from "./shared.js";
|
||||
import { createTask, trigger as triggerTaskInternal } from "./shared.js";
|
||||
import type { TriggerChatTaskParams, TriggerChatTaskResult } from "./chat.js";
|
||||
import { tracer } from "./tracer.js";
|
||||
|
||||
/** Re-export for typing `ctx` in `chat.task` hooks without importing `@trigger.dev/core`. */
|
||||
@@ -5125,6 +5126,63 @@ export type InferChatUIMessage<TTask extends AnyTask> = TTask extends Task<
|
||||
? TUIM
|
||||
: UIMessage;
|
||||
|
||||
/**
|
||||
* Options for {@link createChatTriggerAction}.
|
||||
*/
|
||||
export type CreateChatTriggerActionOptions = {
|
||||
/** TTL for the run-scoped public access token. @default "1h" */
|
||||
tokenTTL?: string | number | Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a function that triggers a chat task and returns a run-scoped session.
|
||||
*
|
||||
* Wrap the returned function in a Next.js server action (or any server-side handler)
|
||||
* to keep task triggering on the server. The function calls `tasks.trigger()` with
|
||||
* the secret key and mints a run-scoped PAT for stream subscription + input stream writes.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // actions.ts
|
||||
* "use server";
|
||||
* import { chat } from "@trigger.dev/sdk/ai";
|
||||
*
|
||||
* export const triggerChat = chat.createTriggerAction("my-chat");
|
||||
* ```
|
||||
*
|
||||
* Then pass it to the transport:
|
||||
* ```tsx
|
||||
* const transport = useTriggerChatTransport({
|
||||
* task: "my-chat",
|
||||
* triggerTask: triggerChat,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
function createChatTriggerAction(
|
||||
taskId: string,
|
||||
options?: CreateChatTriggerActionOptions
|
||||
): (params: TriggerChatTaskParams) => Promise<TriggerChatTaskResult> {
|
||||
return async (params: TriggerChatTaskParams): Promise<TriggerChatTaskResult> => {
|
||||
const handle = await triggerTaskInternal(taskId, params.payload, {
|
||||
tags: params.options.tags,
|
||||
queue: params.options.queue,
|
||||
maxAttempts: params.options.maxAttempts,
|
||||
machine: params.options.machine as any,
|
||||
priority: params.options.priority,
|
||||
});
|
||||
|
||||
const publicAccessToken = await auth.createPublicToken({
|
||||
scopes: {
|
||||
read: { runs: handle.id },
|
||||
write: { inputStreams: handle.id },
|
||||
},
|
||||
expirationTime: options?.tokenTTL ?? "1h",
|
||||
});
|
||||
|
||||
return { runId: handle.id, publicAccessToken };
|
||||
};
|
||||
}
|
||||
|
||||
export const chat = {
|
||||
/** Create a chat task. See {@link chatTask}. */
|
||||
task: chatTask,
|
||||
@@ -5132,6 +5190,8 @@ export const chat = {
|
||||
withUIMessage,
|
||||
/** Create a chat task with a fixed client data schema. See {@link withClientData}. */
|
||||
withClientData,
|
||||
/** Create a server-side trigger action helper. See {@link createChatTriggerAction}. */
|
||||
createTriggerAction: createChatTriggerAction,
|
||||
/** Pipe a stream to the chat transport. See {@link pipeChat}. */
|
||||
pipe: pipeChat,
|
||||
/** Create a per-run typed local. See {@link chatLocal}. */
|
||||
|
||||
@@ -86,11 +86,11 @@ export function useTriggerChatTransport<TTask extends AnyTask = AnyTask>(
|
||||
): TriggerChatTransport {
|
||||
const ref = useRef<TriggerChatTransport | null>(null);
|
||||
if (ref.current === null) {
|
||||
ref.current = new TriggerChatTransport(options);
|
||||
ref.current = new TriggerChatTransport(options as TriggerChatTransportOptions);
|
||||
}
|
||||
|
||||
// Keep onSessionChange up to date without recreating the transport
|
||||
const { onSessionChange, renewRunAccessToken } = options;
|
||||
// Keep callbacks up to date without recreating the transport
|
||||
const { onSessionChange, renewRunAccessToken, triggerTask } = options;
|
||||
useEffect(() => {
|
||||
ref.current?.setOnSessionChange(onSessionChange);
|
||||
}, [onSessionChange]);
|
||||
@@ -99,6 +99,10 @@ export function useTriggerChatTransport<TTask extends AnyTask = AnyTask>(
|
||||
ref.current?.setRenewRunAccessToken(renewRunAccessToken);
|
||||
}, [renewRunAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
ref.current?.setTriggerTask(triggerTask);
|
||||
}, [triggerTask]);
|
||||
|
||||
return ref.current;
|
||||
}
|
||||
|
||||
|
||||
@@ -66,9 +66,42 @@ export type ResolveChatAccessTokenParams = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Options for creating a TriggerChatTransport.
|
||||
* Payload passed to the {@link TriggerChatTransportOptions.triggerTask} callback.
|
||||
*/
|
||||
export type TriggerChatTransportOptions<TClientData = unknown> = {
|
||||
export type TriggerChatTaskParams = {
|
||||
/** The full payload to pass to the task. */
|
||||
payload: {
|
||||
messages: UIMessage[];
|
||||
chatId: string;
|
||||
trigger: "submit-message" | "regenerate-message" | "preload";
|
||||
messageId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
continuation?: boolean;
|
||||
previousRunId?: string;
|
||||
idleTimeoutInSeconds?: number;
|
||||
};
|
||||
/** Trigger options (tags, queue, etc.) — pre-merged by the transport. */
|
||||
options: {
|
||||
tags: string[];
|
||||
queue?: string;
|
||||
maxAttempts?: number;
|
||||
machine?: string;
|
||||
priority?: number;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Return value from the {@link TriggerChatTransportOptions.triggerTask} callback.
|
||||
*/
|
||||
export type TriggerChatTaskResult = {
|
||||
/** The run ID from the triggered task. */
|
||||
runId: string;
|
||||
/** A run-scoped public access token for stream subscription and input stream writes. */
|
||||
publicAccessToken: string;
|
||||
};
|
||||
|
||||
/** Common options shared by all TriggerChatTransport configurations. */
|
||||
type TriggerChatTransportOptionsBase<TClientData = unknown> = {
|
||||
/**
|
||||
* The Trigger.dev task ID to trigger for chat completions.
|
||||
* This task should be defined using `chatTask()` from `@trigger.dev/sdk/ai`,
|
||||
@@ -76,19 +109,6 @@ export type TriggerChatTransportOptions<TClientData = unknown> = {
|
||||
*/
|
||||
task: string;
|
||||
|
||||
/**
|
||||
* An access token for authenticating with the Trigger.dev API.
|
||||
*
|
||||
* This must be a token with permission to trigger the task. You can use:
|
||||
* - A **trigger public token** created via `auth.createTriggerPublicToken(taskId)` (recommended for frontend use)
|
||||
* - A **secret API key** (for server-side use only — never expose in the browser)
|
||||
*
|
||||
* Can also be a function that returns a token string (sync or async),
|
||||
* useful for dynamic token refresh or passing a Next.js server action directly.
|
||||
* The function receives `chatId` and `purpose` (`trigger` vs `preload`) so you can mint or log per conversation.
|
||||
*/
|
||||
accessToken: string | ((params: ResolveChatAccessTokenParams) => string | Promise<string>);
|
||||
|
||||
/**
|
||||
* Base URL for the Trigger.dev API.
|
||||
* @default "https://api.trigger.dev"
|
||||
@@ -239,6 +259,51 @@ export type TriggerChatTransportOptions<TClientData = unknown> = {
|
||||
) => string | undefined | null | Promise<string | undefined | null>;
|
||||
};
|
||||
|
||||
/** Access token used for frontend-triggered runs. */
|
||||
type AccessTokenOption =
|
||||
| string
|
||||
| ((params: ResolveChatAccessTokenParams) => string | Promise<string>);
|
||||
|
||||
/**
|
||||
* Options for creating a TriggerChatTransport.
|
||||
*
|
||||
* Provide either `accessToken` (frontend triggering) or `triggerTask` (server-side triggering).
|
||||
* When `triggerTask` is provided, `accessToken` is optional.
|
||||
*/
|
||||
export type TriggerChatTransportOptions<TClientData = unknown> =
|
||||
| (TriggerChatTransportOptionsBase<TClientData> & {
|
||||
/** Access token for frontend-triggered runs. Required when `triggerTask` is not set. */
|
||||
accessToken: AccessTokenOption;
|
||||
triggerTask?: undefined;
|
||||
})
|
||||
| (TriggerChatTransportOptionsBase<TClientData> & {
|
||||
/**
|
||||
* Delegate run triggering to a server-side callback (e.g. a Next.js server action).
|
||||
*
|
||||
* When provided, the transport calls this function instead of triggering the task directly
|
||||
* from the browser. The callback should trigger the task using the secret key and return
|
||||
* both the `runId` and a run-scoped `publicAccessToken` for stream subscription.
|
||||
*
|
||||
* Use `chat.createTriggerAction(taskId)` to create the callback body.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // actions.ts ("use server")
|
||||
* import { chat } from "@trigger.dev/sdk/ai";
|
||||
* export const triggerChat = chat.createTriggerAction("my-chat");
|
||||
*
|
||||
* // component.tsx
|
||||
* const transport = useTriggerChatTransport({
|
||||
* task: "my-chat",
|
||||
* triggerTask: triggerChat,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
triggerTask: (params: TriggerChatTaskParams) => Promise<TriggerChatTaskResult>;
|
||||
/** Optional when `triggerTask` is set. Only needed if the transport needs to resolve tokens for other purposes. */
|
||||
accessToken?: AccessTokenOption;
|
||||
});
|
||||
|
||||
/**
|
||||
* Internal state for tracking active chat sessions.
|
||||
* @internal
|
||||
@@ -286,6 +351,9 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
|
||||
private readonly resolveAccessTokenFn:
|
||||
| ((params: ResolveChatAccessTokenParams) => string | Promise<string>)
|
||||
| undefined;
|
||||
private triggerTaskFn:
|
||||
| ((params: TriggerChatTaskParams) => Promise<TriggerChatTaskResult>)
|
||||
| undefined;
|
||||
private readonly baseURL: string;
|
||||
private readonly streamKey: string;
|
||||
private readonly extraHeaders: Record<string, string>;
|
||||
@@ -306,12 +374,19 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
|
||||
|
||||
constructor(options: TriggerChatTransportOptions) {
|
||||
this.taskId = options.task;
|
||||
if (typeof options.accessToken === "function") {
|
||||
this.staticAccessToken = undefined;
|
||||
this.resolveAccessTokenFn = options.accessToken;
|
||||
} else {
|
||||
this.staticAccessToken = options.accessToken;
|
||||
this.resolveAccessTokenFn = undefined;
|
||||
this.triggerTaskFn = options.triggerTask;
|
||||
if (options.accessToken) {
|
||||
if (typeof options.accessToken === "function") {
|
||||
this.staticAccessToken = undefined;
|
||||
this.resolveAccessTokenFn = options.accessToken;
|
||||
} else {
|
||||
this.staticAccessToken = options.accessToken;
|
||||
this.resolveAccessTokenFn = undefined;
|
||||
}
|
||||
} else if (!options.triggerTask) {
|
||||
throw new Error(
|
||||
"TriggerChatTransport: either `accessToken` or `triggerTask` must be provided."
|
||||
);
|
||||
}
|
||||
this.baseURL = options.baseURL ?? DEFAULT_BASE_URL;
|
||||
this.streamKey = options.streamKey ?? DEFAULT_STREAM_KEY;
|
||||
@@ -425,43 +500,18 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
|
||||
}
|
||||
|
||||
// First message or run has ended — trigger a new run
|
||||
const currentToken = await this.resolveAccessToken({ chatId, purpose: "trigger" });
|
||||
const apiClient = new ApiClient(this.baseURL, currentToken);
|
||||
|
||||
// Auto-tag with chatId; merge with user-provided tags (API limit: 5 tags)
|
||||
const autoTags = [`chat:${chatId}`];
|
||||
const userTags = this.triggerOptions?.tags ?? [];
|
||||
const tags = [...autoTags, ...userTags].slice(0, 5);
|
||||
|
||||
const triggerResponse = await apiClient.triggerTask(this.taskId, {
|
||||
payload: {
|
||||
...payload,
|
||||
continuation: isContinuation,
|
||||
...(previousRunId ? { previousRunId } : {}),
|
||||
},
|
||||
options: {
|
||||
payloadType: "application/json",
|
||||
tags,
|
||||
queue: this.triggerOptions?.queue ? { name: this.triggerOptions.queue } : undefined,
|
||||
maxAttempts: this.triggerOptions?.maxAttempts,
|
||||
machine: this.triggerOptions?.machine,
|
||||
priority: this.triggerOptions?.priority,
|
||||
},
|
||||
});
|
||||
|
||||
const runId = triggerResponse.id;
|
||||
const publicAccessToken =
|
||||
"publicAccessToken" in triggerResponse
|
||||
? (triggerResponse as { publicAccessToken?: string }).publicAccessToken
|
||||
: undefined;
|
||||
|
||||
const newSession: ChatSessionState = {
|
||||
runId,
|
||||
publicAccessToken: publicAccessToken ?? currentToken,
|
||||
const triggerPayload = {
|
||||
...payload,
|
||||
continuation: isContinuation,
|
||||
...(previousRunId ? { previousRunId } : {}),
|
||||
};
|
||||
|
||||
const { runId, publicAccessToken } = await this.triggerNewRun(chatId, triggerPayload, "trigger");
|
||||
|
||||
const newSession: ChatSessionState = { runId, publicAccessToken };
|
||||
this.sessions.set(chatId, newSession);
|
||||
this.notifySessionChange(chatId, newSession);
|
||||
return this.subscribeToStream(runId, publicAccessToken ?? currentToken, abortSignal, chatId);
|
||||
return this.subscribeToStream(runId, publicAccessToken, abortSignal, chatId);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -605,6 +655,15 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
|
||||
this.renewRunAccessToken = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the server-side trigger callback without recreating the transport.
|
||||
*/
|
||||
setTriggerTask(
|
||||
fn: ((params: TriggerChatTaskParams) => Promise<TriggerChatTaskResult>) | undefined
|
||||
): void {
|
||||
this.triggerTaskFn = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eagerly trigger a run for a chat before the first message is sent.
|
||||
* This allows initialization (DB setup, context loading) to happen
|
||||
@@ -631,13 +690,51 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
|
||||
: {}),
|
||||
};
|
||||
|
||||
const currentToken = await this.resolveAccessToken({ chatId, purpose: "preload" });
|
||||
const apiClient = new ApiClient(this.baseURL, currentToken);
|
||||
const { runId, publicAccessToken } = await this.triggerNewRun(chatId, payload, "preload");
|
||||
|
||||
const autoTags = [`chat:${chatId}`, "preload:true"];
|
||||
const newSession: ChatSessionState = { runId, publicAccessToken };
|
||||
this.sessions.set(chatId, newSession);
|
||||
this.notifySessionChange(chatId, newSession);
|
||||
}
|
||||
|
||||
private async resolveAccessToken(params: ResolveChatAccessTokenParams): Promise<string> {
|
||||
if (this.staticAccessToken !== undefined) {
|
||||
return this.staticAccessToken;
|
||||
}
|
||||
if (this.resolveAccessTokenFn) {
|
||||
return await this.resolveAccessTokenFn(params);
|
||||
}
|
||||
throw new Error(
|
||||
"TriggerChatTransport: accessToken is required for this operation but was not provided."
|
||||
);
|
||||
}
|
||||
|
||||
private async triggerNewRun(
|
||||
chatId: string,
|
||||
payload: Record<string, unknown>,
|
||||
purpose: "trigger" | "preload"
|
||||
): Promise<{ runId: string; publicAccessToken: string }> {
|
||||
const autoTags =
|
||||
purpose === "preload" ? [`chat:${chatId}`, "preload:true"] : [`chat:${chatId}`];
|
||||
const userTags = this.triggerOptions?.tags ?? [];
|
||||
const tags = [...autoTags, ...userTags].slice(0, 5);
|
||||
|
||||
if (this.triggerTaskFn) {
|
||||
return await this.triggerTaskFn({
|
||||
payload: payload as TriggerChatTaskParams["payload"],
|
||||
options: {
|
||||
tags,
|
||||
queue: this.triggerOptions?.queue,
|
||||
maxAttempts: this.triggerOptions?.maxAttempts,
|
||||
machine: this.triggerOptions?.machine,
|
||||
priority: this.triggerOptions?.priority,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const currentToken = await this.resolveAccessToken({ chatId, purpose });
|
||||
const apiClient = new ApiClient(this.baseURL, currentToken);
|
||||
|
||||
const triggerResponse = await apiClient.triggerTask(this.taskId, {
|
||||
payload,
|
||||
options: {
|
||||
@@ -656,19 +753,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
|
||||
? (triggerResponse as { publicAccessToken?: string }).publicAccessToken
|
||||
: undefined;
|
||||
|
||||
const newSession: ChatSessionState = {
|
||||
runId,
|
||||
publicAccessToken: publicAccessToken ?? currentToken,
|
||||
};
|
||||
this.sessions.set(chatId, newSession);
|
||||
this.notifySessionChange(chatId, newSession);
|
||||
}
|
||||
|
||||
private async resolveAccessToken(params: ResolveChatAccessTokenParams): Promise<string> {
|
||||
if (this.staticAccessToken !== undefined) {
|
||||
return this.staticAccessToken;
|
||||
}
|
||||
return await this.resolveAccessTokenFn!(params);
|
||||
return { runId, publicAccessToken: publicAccessToken ?? currentToken };
|
||||
}
|
||||
|
||||
private notifySessionChange(chatId: string, session: ChatSessionState | null): void {
|
||||
|
||||
Generated
+953
-3
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,8 @@ minimumReleaseAgeExclude:
|
||||
- "next"
|
||||
- "@next/*"
|
||||
- "agentcrumbs"
|
||||
- "secure-exec"
|
||||
- "@secure-exec/*"
|
||||
preferOffline: true
|
||||
|
||||
linkWorkspacePackages: false
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
"@prisma/client": "^7.4.2",
|
||||
"@e2b/code-interpreter": "^2.4.0",
|
||||
"@trigger.dev/sdk": "workspace:*",
|
||||
"secure-exec": "0.1.0",
|
||||
"serialize-error": "^11.0.3",
|
||||
"ai": "^6.0.0",
|
||||
"next": "15.3.3",
|
||||
"pg": "^8.16.3",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@trigger.dev/sdk";
|
||||
import { chat } from "@trigger.dev/sdk/ai";
|
||||
import type { ResolveChatAccessTokenParams } from "@trigger.dev/sdk/chat";
|
||||
import type { aiChat, aiChatRaw, aiChatSession } from "@/trigger/chat";
|
||||
import type { ChatUiMessage } from "@/lib/chat-tools";
|
||||
@@ -29,6 +30,14 @@ export async function getChatToken(
|
||||
return auth.createTriggerPublicToken(task, { expirationTime: CHAT_EXAMPLE_PAT_TTL });
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side trigger action — delegates run creation to the server.
|
||||
* Pass this to `useTriggerChatTransport({ triggerTask: triggerChat })`.
|
||||
*/
|
||||
export const triggerChat = chat.createTriggerAction("ai-chat", {
|
||||
tokenTTL: CHAT_EXAMPLE_PAT_TTL,
|
||||
});
|
||||
|
||||
/**
|
||||
* Mint a fresh run-scoped PAT for an existing chat run (same scopes as the task’s turn token).
|
||||
* Used by TriggerChatTransport when the stored PAT expires (401 on realtime / input stream).
|
||||
|
||||
@@ -6,6 +6,7 @@ import { z } from "zod";
|
||||
import os from "node:os";
|
||||
import TurndownService from "turndown";
|
||||
import { codeSandboxRun, runWithCodeSandbox } from "@/lib/code-sandbox";
|
||||
import { runInSecureSandbox } from "@/lib/secure-sandbox";
|
||||
|
||||
const turndown = new TurndownService();
|
||||
|
||||
@@ -283,6 +284,30 @@ export const executeCode = tool({
|
||||
},
|
||||
});
|
||||
|
||||
export const executeJs = tool({
|
||||
description:
|
||||
"Run JavaScript code in an isolated V8 sandbox (secure-exec). " +
|
||||
"Use for calculations, data transformations, or quick JS snippets. " +
|
||||
"The code runs as a CommonJS module — assign results to module.exports. " +
|
||||
"Example: module.exports = { sum: 1 + 2 };",
|
||||
inputSchema: z.object({
|
||||
code: z.string().describe("JavaScript code to execute. Assign results to module.exports."),
|
||||
}),
|
||||
execute: async ({ code }) => {
|
||||
return runInSecureSandbox(async (runtime) => {
|
||||
const result = await runtime.run<unknown>(code);
|
||||
|
||||
if (result.code !== 0) {
|
||||
return {
|
||||
error: result.errorMessage ?? `Exit code ${result.code}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { result: result.exports };
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/** Tool set passed to `streamText` for the main `chat.task` run (includes PostHog). */
|
||||
export const chatTools = {
|
||||
inspectEnvironment,
|
||||
@@ -290,6 +315,7 @@ export const chatTools = {
|
||||
deepResearch,
|
||||
posthogQuery,
|
||||
executeCode,
|
||||
executeJs,
|
||||
};
|
||||
|
||||
type ChatToolSet = typeof chatTools;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* secure-exec V8 sandbox — runs JavaScript in-process via V8 isolates.
|
||||
*
|
||||
* No external API key needed. ~14ms cold start, ~3MB per isolate.
|
||||
* A fresh runtime is created per execution — no warm/dispose lifecycle needed.
|
||||
*/
|
||||
import {
|
||||
NodeRuntime,
|
||||
createNodeDriver,
|
||||
createNodeRuntimeDriverFactory,
|
||||
} from "secure-exec";
|
||||
|
||||
export async function runInSecureSandbox<T>(
|
||||
runner: (runtime: NodeRuntime) => Promise<T>
|
||||
): Promise<T | { error: string }> {
|
||||
const runtime = new NodeRuntime({
|
||||
systemDriver: createNodeDriver({
|
||||
permissions: {
|
||||
fs: (req) => ({
|
||||
allow: req.path.startsWith("/root") || req.path.startsWith("/tmp"),
|
||||
}),
|
||||
network: (req) => ({
|
||||
allow: req.hostname === "127.0.0.1" || req.hostname === "localhost",
|
||||
}),
|
||||
},
|
||||
}),
|
||||
runtimeDriverFactory: createNodeRuntimeDriverFactory(),
|
||||
memoryLimit: 128,
|
||||
cpuTimeLimitMs: 60_000,
|
||||
});
|
||||
|
||||
try {
|
||||
return await runner(runtime);
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err.message : String(err) };
|
||||
} finally {
|
||||
runtime.dispose();
|
||||
}
|
||||
}
|
||||
@@ -167,7 +167,6 @@ export const aiChat = chat
|
||||
})
|
||||
.onChatSuspend(async ({ phase, ctx }) => {
|
||||
logger.debug("Chat suspending", { phase, runId: ctx.run.id });
|
||||
|
||||
await disposeCodeSandboxForRun(ctx.run.id);
|
||||
})
|
||||
.onChatResume(async ({ phase, ctx }) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineConfig } from "@trigger.dev/sdk";
|
||||
import { prismaExtension } from "@trigger.dev/build/extensions/prisma";
|
||||
import { secureExec } from "@trigger.dev/build/extensions/secureExec";
|
||||
|
||||
export default defineConfig({
|
||||
project: process.env.TRIGGER_PROJECT_REF!,
|
||||
@@ -10,6 +11,7 @@ export default defineConfig({
|
||||
prismaExtension({
|
||||
mode: "modern",
|
||||
}),
|
||||
secureExec(),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user