Skip to content

Commit 23b43be

Browse files
authored
feat/realtime-streams (#1470)
* WIP realtime streams * Handle realtime with large payloads or outputs #1451 * feat: optimize Redis stream handling with batching Add STREAM_ORIGIN to environment schema. Improve performance in RealtimeStreams by using TextDecoderStream for simpler text decoding and implementing batching of XADD commands for Redis streams. Limit stream size using MAXLEN option. Update environment variable repository with new variable type. Adjust import statements for Redis key and value types. * 🔧 chore: add dev dependencies for bundle analysis * add metadata tests and a few more utilties * Add stream tests and improve streaming * Added AI tool tasks, descriptions to tasks * Use the config file path to determine the workingDir, then the package.json path * Remove stream test files * useTaskTrigger react hook that allows triggering a task from the client * Add streaming support for the realtime react hooks * Add ability to stream results after useTaskTrigger * Improve the stream throttling * Use the runId as the ID key to bust the cache after triggering * Upgrade to to the latest electric sql client and server * Make realtime server backwards compat with 3.1.2 release * Pass the runId into useRealtimeRun * Fix scopes when specifiying reading all runs * WIP @trigger.dev/rsc package * Various fixes and accepted recommendations by CodeRabbit * Regenerate pnpm lock file * A couple tweaks to rsc and give up on rendering react in tasks for now * Add changeset * Remove triggerRequest from the useEffect deps * Improve realtime & frontend authentication errors * Fixed authorization tests * Remove unnecessary log * Add metadata.stream limits and improve the metadata streams structure * Streams can now have up to 2500 entries * Various coderabbit fixes * additional react-hooks jsdocs
1 parent ea09564 commit 23b43be

File tree

112 files changed

+6054
-1051
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

112 files changed

+6054
-1051
lines changed

.changeset/swift-glasses-mate.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@trigger.dev/react-hooks": patch
3+
"@trigger.dev/sdk": patch
4+
"trigger.dev": patch
5+
"@trigger.dev/build": patch
6+
"@trigger.dev/core": patch
7+
"@trigger.dev/rsc": patch
8+
---
9+
10+
Realtime streams

.vscode/launch.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,15 @@
1313
"cwd": "${workspaceFolder}",
1414
"sourceMaps": true
1515
},
16+
{
17+
"type": "node-terminal",
18+
"request": "launch",
19+
"name": "Debug realtimeStreams.test.ts",
20+
"command": "pnpm run test -t RealtimeStreams",
21+
"envFile": "${workspaceFolder}/.env",
22+
"cwd": "${workspaceFolder}/apps/webapp",
23+
"sourceMaps": true
24+
},
1625
{
1726
"type": "chrome",
1827
"request": "launch",
@@ -36,6 +45,14 @@
3645
"cwd": "${workspaceFolder}/references/v3-catalog",
3746
"sourceMaps": true
3847
},
48+
{
49+
"type": "node-terminal",
50+
"request": "launch",
51+
"name": "Debug Dev Next.js Realtime",
52+
"command": "pnpm exec trigger dev",
53+
"cwd": "${workspaceFolder}/references/nextjs-realtime",
54+
"sourceMaps": true
55+
},
3956
{
4057
"type": "node-terminal",
4158
"request": "launch",

apps/webapp/app/env.server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ const EnvironmentSchema = z.object({
3232
LOGIN_ORIGIN: z.string().default("http://localhost:3030"),
3333
APP_ORIGIN: z.string().default("http://localhost:3030"),
3434
API_ORIGIN: z.string().optional(),
35+
STREAM_ORIGIN: z.string().optional(),
3536
ELECTRIC_ORIGIN: z.string().default("http://localhost:3060"),
3637
APP_ENV: z.string().default(process.env.NODE_ENV),
3738
SERVICE_NAME: z.string().default("trigger.dev webapp"),

apps/webapp/app/presenters/v3/SpanPresenter.server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ export class SpanPresenter extends BasePresenter {
210210
const span = await eventRepository.getSpan(spanId, run.traceId);
211211

212212
const metadata = run.metadata
213-
? await prettyPrintPacket(run.metadata, run.metadataType)
213+
? await prettyPrintPacket(run.metadata, run.metadataType, { filteredKeys: ["$$streams"] })
214214
: undefined;
215215

216216
const context = {

apps/webapp/app/routes/api.v1.packets.$.ts

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime";
22
import { json } from "@remix-run/server-runtime";
33
import { z } from "zod";
44
import { authenticateApiRequest } from "~/services/apiAuth.server";
5+
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
56
import { generatePresignedUrl } from "~/v3/r2.server";
67

78
const ParamsSchema = z.object({
@@ -39,28 +40,27 @@ export async function action({ request, params }: ActionFunctionArgs) {
3940
return json({ presignedUrl });
4041
}
4142

42-
export async function loader({ request, params }: ActionFunctionArgs) {
43-
// Next authenticate the request
44-
const authenticationResult = await authenticateApiRequest(request);
43+
export const loader = createLoaderApiRoute(
44+
{
45+
params: ParamsSchema,
46+
allowJWT: true,
47+
corsStrategy: "all",
48+
},
49+
async ({ params, authentication }) => {
50+
const filename = params["*"];
4551

46-
if (!authenticationResult) {
47-
return json({ error: "Invalid or Missing API key" }, { status: 401 });
48-
}
52+
const presignedUrl = await generatePresignedUrl(
53+
authentication.environment.project.externalRef,
54+
authentication.environment.slug,
55+
filename,
56+
"GET"
57+
);
4958

50-
const parsedParams = ParamsSchema.parse(params);
51-
const filename = parsedParams["*"];
52-
53-
const presignedUrl = await generatePresignedUrl(
54-
authenticationResult.environment.project.externalRef,
55-
authenticationResult.environment.slug,
56-
filename,
57-
"GET"
58-
);
59+
if (!presignedUrl) {
60+
return json({ error: "Failed to generate presigned URL" }, { status: 500 });
61+
}
5962

60-
if (!presignedUrl) {
61-
return json({ error: "Failed to generate presigned URL" }, { status: 500 });
63+
// Caller can now use this URL to fetch that object.
64+
return json({ presignedUrl });
6265
}
63-
64-
// Caller can now use this URL to fetch that object.
65-
return json({ presignedUrl });
66-
}
66+
);

apps/webapp/app/routes/api.v1.projects.$projectRef.runs.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
ApiRunListPresenter,
66
ApiRunListSearchParams,
77
} from "~/presenters/v3/ApiRunListPresenter.server";
8-
import { createLoaderPATApiRoute } from "~/services/routeBuiilders/apiBuilder.server";
8+
import { createLoaderPATApiRoute } from "~/services/routeBuilders/apiBuilder.server";
99

1010
const ParamsSchema = z.object({
1111
projectRef: z.string(),

apps/webapp/app/routes/api.v1.runs.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
ApiRunListPresenter,
44
ApiRunListSearchParams,
55
} from "~/presenters/v3/ApiRunListPresenter.server";
6-
import { createLoaderApiRoute } from "~/services/routeBuiilders/apiBuilder.server";
6+
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
77

88
export const loader = createLoaderApiRoute(
99
{
Lines changed: 112 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
1-
import { fromZodError } from "zod-validation-error";
2-
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
31
import { json } from "@remix-run/server-runtime";
4-
import { TriggerTaskRequestBody } from "@trigger.dev/core/v3";
2+
import { generateJWT as internal_generateJWT, TriggerTaskRequestBody } from "@trigger.dev/core/v3";
3+
import { TaskRun } from "@trigger.dev/database";
54
import { z } from "zod";
65
import { env } from "~/env.server";
7-
import { authenticateApiRequest } from "~/services/apiAuth.server";
6+
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
87
import { logger } from "~/services/logger.server";
9-
import { parseRequestJsonAsync } from "~/utils/parseRequestJson.server";
8+
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
109
import { ServiceValidationError } from "~/v3/services/baseService.server";
1110
import { OutOfEntitlementError, TriggerTaskService } from "~/v3/services/triggerTask.server";
12-
import { startActiveSpan } from "~/v3/tracer.server";
1311

1412
const ParamsSchema = z.object({
1513
taskId: z.string(),
@@ -20,115 +18,125 @@ export const HeadersSchema = z.object({
2018
"trigger-version": z.string().nullish(),
2119
"x-trigger-span-parent-as-link": z.coerce.number().nullish(),
2220
"x-trigger-worker": z.string().nullish(),
21+
"x-trigger-client": z.string().nullish(),
2322
traceparent: z.string().optional(),
2423
tracestate: z.string().optional(),
2524
});
2625

27-
export async function action({ request, params }: ActionFunctionArgs) {
28-
// Ensure this is a POST request
29-
if (request.method.toUpperCase() !== "POST") {
30-
return { status: 405, body: "Method Not Allowed" };
31-
}
32-
33-
logger.debug("TriggerTask action", { headers: Object.fromEntries(request.headers) });
34-
35-
// Next authenticate the request
36-
const authenticationResult = await authenticateApiRequest(request);
37-
38-
if (!authenticationResult) {
39-
return json({ error: "Invalid or Missing API key" }, { status: 401 });
40-
}
41-
42-
const contentLength = request.headers.get("content-length");
43-
44-
if (!contentLength || parseInt(contentLength) > env.TASK_PAYLOAD_MAXIMUM_SIZE) {
45-
return json({ error: "Request body too large" }, { status: 413 });
46-
}
26+
const { action, loader } = createActionApiRoute(
27+
{
28+
headers: HeadersSchema,
29+
params: ParamsSchema,
30+
body: TriggerTaskRequestBody,
31+
allowJWT: true,
32+
maxContentLength: env.TASK_PAYLOAD_MAXIMUM_SIZE,
33+
authorization: {
34+
action: "write",
35+
resource: (params) => ({ tasks: params.taskId }),
36+
superScopes: ["write:tasks", "admin"],
37+
},
38+
corsStrategy: "all",
39+
},
40+
async ({ body, headers, params, authentication }) => {
41+
const {
42+
"idempotency-key": idempotencyKey,
43+
"trigger-version": triggerVersion,
44+
"x-trigger-span-parent-as-link": spanParentAsLink,
45+
traceparent,
46+
tracestate,
47+
"x-trigger-worker": isFromWorker,
48+
"x-trigger-client": triggerClient,
49+
} = headers;
50+
51+
const service = new TriggerTaskService();
52+
53+
try {
54+
const traceContext =
55+
traceparent && isFromWorker /// If the request is from a worker, we should pass the trace context
56+
? { traceparent, tracestate }
57+
: undefined;
58+
59+
logger.debug("Triggering task", {
60+
taskId: params.taskId,
61+
idempotencyKey,
62+
triggerVersion,
63+
headers,
64+
options: body.options,
65+
isFromWorker,
66+
traceContext,
67+
});
68+
69+
const run = await service.call(params.taskId, authentication.environment, body, {
70+
idempotencyKey: idempotencyKey ?? undefined,
71+
triggerVersion: triggerVersion ?? undefined,
72+
traceContext,
73+
spanParentAsLink: spanParentAsLink === 1,
74+
});
75+
76+
if (!run) {
77+
return json({ error: "Task not found" }, { status: 404 });
78+
}
4779

48-
const rawHeaders = Object.fromEntries(request.headers);
80+
const $responseHeaders = await responseHeaders(
81+
run,
82+
authentication.environment,
83+
triggerClient
84+
);
4985

50-
const headers = HeadersSchema.safeParse(rawHeaders);
86+
return json(
87+
{
88+
id: run.friendlyId,
89+
},
90+
{
91+
headers: $responseHeaders,
92+
}
93+
);
94+
} catch (error) {
95+
if (error instanceof ServiceValidationError) {
96+
return json({ error: error.message }, { status: 422 });
97+
} else if (error instanceof OutOfEntitlementError) {
98+
return json({ error: error.message }, { status: 422 });
99+
} else if (error instanceof Error) {
100+
return json({ error: error.message }, { status: 500 });
101+
}
51102

52-
if (!headers.success) {
53-
return json({ error: "Invalid headers" }, { status: 400 });
103+
return json({ error: "Something went wrong" }, { status: 500 });
104+
}
54105
}
55-
56-
const {
57-
"idempotency-key": idempotencyKey,
58-
"trigger-version": triggerVersion,
59-
"x-trigger-span-parent-as-link": spanParentAsLink,
60-
traceparent,
61-
tracestate,
62-
"x-trigger-worker": isFromWorker,
63-
} = headers.data;
64-
65-
const { taskId } = ParamsSchema.parse(params);
66-
67-
// Now parse the request body
68-
const anyBody = await parseRequestJsonAsync(request, { taskId });
69-
70-
const body = await startActiveSpan("TriggerTaskRequestBody.safeParse()", async (span) => {
71-
return TriggerTaskRequestBody.safeParse(anyBody);
106+
);
107+
108+
async function responseHeaders(
109+
run: TaskRun,
110+
environment: AuthenticatedEnvironment,
111+
triggerClient?: string | null
112+
): Promise<Record<string, string>> {
113+
const claimsHeader = JSON.stringify({
114+
sub: environment.id,
115+
pub: true,
72116
});
73117

74-
if (!body.success) {
75-
return json(
76-
{ error: fromZodError(body.error, { prefix: "Invalid trigger call" }).toString() },
77-
{ status: 400 }
78-
);
79-
}
80-
81-
const service = new TriggerTaskService();
82-
83-
try {
84-
const traceContext =
85-
traceparent && isFromWorker /// If the request is from a worker, we should pass the trace context
86-
? { traceparent, tracestate }
87-
: undefined;
88-
89-
logger.debug("Triggering task", {
90-
taskId,
91-
idempotencyKey,
92-
triggerVersion,
93-
headers: Object.fromEntries(request.headers),
94-
options: body.data.options,
95-
isFromWorker,
96-
traceContext,
118+
if (triggerClient === "browser") {
119+
const claims = {
120+
sub: environment.id,
121+
pub: true,
122+
scopes: [`read:runs:${run.friendlyId}`],
123+
};
124+
125+
const jwt = await internal_generateJWT({
126+
secretKey: environment.apiKey,
127+
payload: claims,
128+
expirationTime: "1h",
97129
});
98130

99-
const run = await service.call(taskId, authenticationResult.environment, body.data, {
100-
idempotencyKey: idempotencyKey ?? undefined,
101-
triggerVersion: triggerVersion ?? undefined,
102-
traceContext,
103-
spanParentAsLink: spanParentAsLink === 1,
104-
});
105-
106-
if (!run) {
107-
return json({ error: "Task not found" }, { status: 404 });
108-
}
109-
110-
return json(
111-
{
112-
id: run.friendlyId,
113-
},
114-
{
115-
headers: {
116-
"x-trigger-jwt-claims": JSON.stringify({
117-
sub: authenticationResult.environment.id,
118-
pub: true,
119-
}),
120-
},
121-
}
122-
);
123-
} catch (error) {
124-
if (error instanceof ServiceValidationError) {
125-
return json({ error: error.message }, { status: 422 });
126-
} else if (error instanceof OutOfEntitlementError) {
127-
return json({ error: error.message }, { status: 422 });
128-
} else if (error instanceof Error) {
129-
return json({ error: error.message }, { status: 400 });
130-
}
131-
132-
return json({ error: "Something went wrong" }, { status: 500 });
131+
return {
132+
"x-trigger-jwt-claims": claimsHeader,
133+
"x-trigger-jwt": jwt,
134+
};
133135
}
136+
137+
return {
138+
"x-trigger-jwt-claims": claimsHeader,
139+
};
134140
}
141+
142+
export { action, loader };

apps/webapp/app/routes/api.v3.runs.$runId.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { json } from "@remix-run/server-runtime";
22
import { z } from "zod";
33
import { ApiRetrieveRunPresenter } from "~/presenters/v3/ApiRetrieveRunPresenter.server";
4-
import { createLoaderApiRoute } from "~/services/routeBuiilders/apiBuilder.server";
4+
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
55

66
const ParamsSchema = z.object({
77
runId: z.string(),

0 commit comments

Comments
 (0)