@dbx-tools/appkit-mastra 0.1.41 → 0.1.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +122 -461
- package/dist/index.d.ts +2 -3
- package/dist/index.js +2 -3
- package/dist/src/agents.d.ts +3 -3
- package/dist/src/agents.js +1 -1
- package/dist/src/chart.d.ts +7 -1
- package/dist/src/chart.js +3 -5
- package/dist/src/config.d.ts +18 -13
- package/dist/src/genie.d.ts +0 -12
- package/dist/src/genie.js +2 -16
- package/dist/src/history.d.ts +6 -6
- package/dist/src/history.js +6 -6
- package/dist/src/model.d.ts +24 -131
- package/dist/src/model.js +39 -268
- package/dist/src/plugin.d.ts +13 -12
- package/dist/src/plugin.js +38 -34
- package/dist/src/server.d.ts +13 -20
- package/dist/src/server.js +15 -22
- package/dist/src/serving.d.ts +14 -98
- package/dist/src/serving.js +18 -163
- package/dist/src/writer.d.ts +2 -2
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/index.ts +2 -14
- package/package.json +6 -11
- package/src/agents.ts +3 -3
- package/src/chart.ts +3 -5
- package/src/config.ts +18 -13
- package/src/genie.ts +4 -22
- package/src/history.ts +6 -6
- package/src/model.ts +57 -291
- package/src/plugin.ts +45 -42
- package/src/server.ts +15 -22
- package/src/serving.ts +18 -220
- package/src/writer.ts +2 -2
- package/dist/src/intercept.d.ts +0 -48
- package/dist/src/intercept.js +0 -167
- package/dist/src/tools/email.d.ts +0 -74
- package/dist/src/tools/email.js +0 -121
- package/src/intercept.ts +0 -206
- package/src/tools/email.ts +0 -158
package/dist/src/intercept.js
DELETED
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Server-side Server-Sent-Events frame interceptor for the native
|
|
3
|
-
* Mastra agent `/stream` endpoint.
|
|
4
|
-
*
|
|
5
|
-
* The Mastra stream (`@mastra/express`) emits one `data: <json>\n\n`
|
|
6
|
-
* SSE frame per chunk, where the decoded JSON carries a
|
|
7
|
-
* discriminating `type` (`text-delta`, `reasoning-delta`,
|
|
8
|
-
* `tool-call`, `step-finish`, ...) and is terminated by a
|
|
9
|
-
* `data: [DONE]` sentinel. {@link installStreamEventInterceptor} wraps
|
|
10
|
-
* an Express response and runs a caller-supplied
|
|
11
|
-
* {@link StreamFrameInterceptor} over each decoded frame so the caller
|
|
12
|
-
* can keep, rewrite, or drop it; every non-`data:` byte (SSE
|
|
13
|
-
* keep-alive comments, the `[DONE]` sentinel, blank padding) passes
|
|
14
|
-
* through verbatim.
|
|
15
|
-
*
|
|
16
|
-
* The wrap is inert unless the response turns out to be a streamed
|
|
17
|
-
* `200 text/event-stream` body, so non-streaming JSON routes and
|
|
18
|
-
* error responses are never touched.
|
|
19
|
-
*/
|
|
20
|
-
import { StringDecoder } from "node:string_decoder";
|
|
21
|
-
/** SSE event separator used by the Mastra stream wire format. */
|
|
22
|
-
const FRAME_DELIMITER = "\n\n";
|
|
23
|
-
/**
|
|
24
|
-
* Matches a whole SSE frame whose `data:` payload is a JSON object,
|
|
25
|
-
* capturing it in three parts: the `data:` field prefix (including
|
|
26
|
-
* whatever inter-token whitespace the producer used), the JSON object
|
|
27
|
-
* body, and any trailing whitespace. Anchored end-to-end so it only
|
|
28
|
-
* matches a single self-contained object frame; anything else (SSE
|
|
29
|
-
* comments, the `[DONE]` sentinel, text/array payloads) fails the
|
|
30
|
-
* match and is forwarded verbatim without a parse attempt. On rewrite
|
|
31
|
-
* the captured prefix/suffix are re-emitted as-is so only the body
|
|
32
|
-
* changes.
|
|
33
|
-
*/
|
|
34
|
-
const DATA_OBJECT_RE = /^(data:\s*)(\{[\s\S]*\})(\s*)$/;
|
|
35
|
-
/**
|
|
36
|
-
* True when `res` is a `200 text/event-stream` body - the only
|
|
37
|
-
* response shape we intercept. Content-type based so it tracks the
|
|
38
|
-
* Mastra `/stream` route without hard-coding the path, and naturally
|
|
39
|
-
* skips the JSON routes (history, models, charts, statements) and any
|
|
40
|
-
* non-200 error response.
|
|
41
|
-
*/
|
|
42
|
-
function isEventStream(res) {
|
|
43
|
-
if (res.statusCode !== 200)
|
|
44
|
-
return false;
|
|
45
|
-
const contentType = String(res.getHeader("content-type") ?? "").toLowerCase();
|
|
46
|
-
return contentType.includes("text/event-stream");
|
|
47
|
-
}
|
|
48
|
-
/**
|
|
49
|
-
* Run `interceptor` over a single SSE frame. Forwards anything that
|
|
50
|
-
* isn't a parseable `data: {json}` object frame verbatim (comments,
|
|
51
|
-
* the `[DONE]` sentinel, blank padding, unparseable / non-object
|
|
52
|
-
* payloads) so the interceptor only ever sees real chunk objects.
|
|
53
|
-
* Returns the frame string to emit, or `undefined` to drop the frame.
|
|
54
|
-
*/
|
|
55
|
-
function interceptFrame(frame, interceptor) {
|
|
56
|
-
const match = DATA_OBJECT_RE.exec(frame);
|
|
57
|
-
if (!match)
|
|
58
|
-
return frame;
|
|
59
|
-
const [, prefix, body, suffix] = match;
|
|
60
|
-
let parsed;
|
|
61
|
-
try {
|
|
62
|
-
parsed = JSON.parse(body);
|
|
63
|
-
}
|
|
64
|
-
catch {
|
|
65
|
-
// Forward unparseable payloads untouched - feeding a partial /
|
|
66
|
-
// malformed frame to the interceptor risks corrupting bytes the
|
|
67
|
-
// client could still have handled.
|
|
68
|
-
return frame;
|
|
69
|
-
}
|
|
70
|
-
const result = interceptor(parsed);
|
|
71
|
-
if (result === true)
|
|
72
|
-
return frame;
|
|
73
|
-
if (result === false)
|
|
74
|
-
return undefined;
|
|
75
|
-
// Preserve the exact captured prefix/suffix so only the JSON body
|
|
76
|
-
// changes; the framing the client parses stays byte-identical.
|
|
77
|
-
return `${prefix}${JSON.stringify(result.replace)}${suffix}`;
|
|
78
|
-
}
|
|
79
|
-
/**
|
|
80
|
-
* Wrap `res.write` / `res.end` so each SSE frame on a streamed Mastra
|
|
81
|
-
* response is run through `interceptor` (keep / rewrite / drop).
|
|
82
|
-
*
|
|
83
|
-
* Interception only engages once the response is confirmed to be a
|
|
84
|
-
* `200 text/event-stream` body (decided lazily on the first write,
|
|
85
|
-
* after the handler has set status + content-type); any other
|
|
86
|
-
* response streams through unchanged. A {@link StringDecoder}
|
|
87
|
-
* reassembles multi-byte UTF-8 sequences that straddle chunk
|
|
88
|
-
* boundaries, and a running buffer holds the trailing partial frame
|
|
89
|
-
* until its `\n\n` arrives.
|
|
90
|
-
*/
|
|
91
|
-
export function installStreamEventInterceptor(res, interceptor) {
|
|
92
|
-
const origWrite = res.write.bind(res);
|
|
93
|
-
const origEnd = res.end.bind(res);
|
|
94
|
-
const decoder = new StringDecoder("utf8");
|
|
95
|
-
let engaged;
|
|
96
|
-
let buffer = "";
|
|
97
|
-
// Append decoded text, emit every whole `\n\n`-delimited frame the
|
|
98
|
-
// interceptor keeps, and retain any trailing partial frame in
|
|
99
|
-
// `buffer`. On `final`, the residual buffer is flushed as the last
|
|
100
|
-
// frame so a stream that doesn't end on a delimiter isn't dropped.
|
|
101
|
-
const intercept = (text, final) => {
|
|
102
|
-
buffer += text;
|
|
103
|
-
const out = [];
|
|
104
|
-
let idx;
|
|
105
|
-
while ((idx = buffer.indexOf(FRAME_DELIMITER)) !== -1) {
|
|
106
|
-
const frame = buffer.slice(0, idx);
|
|
107
|
-
buffer = buffer.slice(idx + FRAME_DELIMITER.length);
|
|
108
|
-
const kept = interceptFrame(frame, interceptor);
|
|
109
|
-
if (kept !== undefined) {
|
|
110
|
-
out.push(kept, FRAME_DELIMITER);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
if (final && buffer.length > 0) {
|
|
114
|
-
const kept = interceptFrame(buffer, interceptor);
|
|
115
|
-
if (kept !== undefined)
|
|
116
|
-
out.push(kept);
|
|
117
|
-
buffer = "";
|
|
118
|
-
}
|
|
119
|
-
return out.length === 0 ? "" : out.join("");
|
|
120
|
-
};
|
|
121
|
-
const toText = (chunk) => {
|
|
122
|
-
if (typeof chunk === "string")
|
|
123
|
-
return chunk;
|
|
124
|
-
if (Buffer.isBuffer(chunk))
|
|
125
|
-
return decoder.write(chunk);
|
|
126
|
-
if (chunk instanceof Uint8Array) {
|
|
127
|
-
return decoder.write(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength));
|
|
128
|
-
}
|
|
129
|
-
return "";
|
|
130
|
-
};
|
|
131
|
-
res.write = function patchedWrite(chunk, encodingOrCb, cb) {
|
|
132
|
-
if (engaged === undefined)
|
|
133
|
-
engaged = isEventStream(res);
|
|
134
|
-
if (!engaged) {
|
|
135
|
-
return origWrite(chunk, encodingOrCb, cb);
|
|
136
|
-
}
|
|
137
|
-
const callback = typeof encodingOrCb === "function" ? encodingOrCb : cb;
|
|
138
|
-
const out = intercept(toText(chunk), false);
|
|
139
|
-
if (out.length === 0) {
|
|
140
|
-
// The chunk only advanced a partial frame; nothing to forward
|
|
141
|
-
// yet. Honor the write callback so backpressure-aware writers
|
|
142
|
-
// don't stall waiting on an ack that never comes.
|
|
143
|
-
if (callback)
|
|
144
|
-
queueMicrotask(() => callback());
|
|
145
|
-
return true;
|
|
146
|
-
}
|
|
147
|
-
return origWrite(out, callback);
|
|
148
|
-
};
|
|
149
|
-
res.end = function patchedEnd(chunk, encodingOrCb, cb) {
|
|
150
|
-
if (engaged === undefined)
|
|
151
|
-
engaged = isEventStream(res);
|
|
152
|
-
if (!engaged) {
|
|
153
|
-
return origEnd(chunk, encodingOrCb, cb);
|
|
154
|
-
}
|
|
155
|
-
const callback = typeof chunk === "function"
|
|
156
|
-
? chunk
|
|
157
|
-
: typeof encodingOrCb === "function"
|
|
158
|
-
? encodingOrCb
|
|
159
|
-
: cb;
|
|
160
|
-
const text = (typeof chunk !== "function" && chunk !== undefined ? toText(chunk) : "") +
|
|
161
|
-
decoder.end();
|
|
162
|
-
const out = intercept(text, true);
|
|
163
|
-
if (out.length > 0)
|
|
164
|
-
origWrite(out);
|
|
165
|
-
return origEnd(callback);
|
|
166
|
-
};
|
|
167
|
-
}
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Mastra tool: `send_email`. Gated behind {@link requireApproval}
|
|
3
|
-
* so the model can call it freely but execution is paused until a
|
|
4
|
-
* human approves via the chat UI.
|
|
5
|
-
*
|
|
6
|
-
* The execute body is a stub - it logs the would-be email to the
|
|
7
|
-
* server console (via `logUtils.logger`) and returns success. Swap
|
|
8
|
-
* in a real SMTP / SES / Resend / Workspace Mail call later by
|
|
9
|
-
* editing the `execute` body; the tool surface and approval gate
|
|
10
|
-
* stay the same.
|
|
11
|
-
*
|
|
12
|
-
* Approval flow (Mastra + AI SDK V5):
|
|
13
|
-
*
|
|
14
|
-
* 1. Model calls the tool with `{ to, subject, body, ... }`.
|
|
15
|
-
* 2. Mastra evaluates `requireApproval` (here always `true`),
|
|
16
|
-
* pauses the agent loop, and emits a `tool-call-approval`
|
|
17
|
-
* chunk on the response stream.
|
|
18
|
-
* 3. The chat client renders an approve/deny prompt against the
|
|
19
|
-
* `state: 'approval-requested'` tool part. On approve, it sends
|
|
20
|
-
* a `MastraToolApproval` response back; on deny, the tool call
|
|
21
|
-
* is rejected and the model sees an error.
|
|
22
|
-
* 4. On approve, this `execute` runs and logs the email.
|
|
23
|
-
*
|
|
24
|
-
* The tool is intentionally NOT auto-installed on every agent -
|
|
25
|
-
* email is domain-specific, not infrastructure. Spread it into the
|
|
26
|
-
* specific agents that should be able to draft emails.
|
|
27
|
-
*/
|
|
28
|
-
import { z } from "zod";
|
|
29
|
-
declare const emailInputSchema: z.ZodObject<{
|
|
30
|
-
to: z.ZodString;
|
|
31
|
-
subject: z.ZodString;
|
|
32
|
-
body: z.ZodString;
|
|
33
|
-
cc: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
34
|
-
bcc: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
35
|
-
}, z.core.$strip>;
|
|
36
|
-
/** Options accepted by {@link buildEmailTool}. */
|
|
37
|
-
export interface BuildEmailToolOptions {
|
|
38
|
-
/**
|
|
39
|
-
* Override the tool id. Defaults to `"send_email"`. Useful if a
|
|
40
|
-
* caller wants `send_internal_email` / `send_external_email`
|
|
41
|
-
* variants.
|
|
42
|
-
*/
|
|
43
|
-
id?: string;
|
|
44
|
-
/**
|
|
45
|
-
* Replace the default execute body with a real provider call.
|
|
46
|
-
* Receives the validated input and must return `{sent, recipient}`.
|
|
47
|
-
* The console-log default is meant for demos / dev; production
|
|
48
|
-
* deployments should wire SMTP / SES / Resend / Workspace Mail
|
|
49
|
-
* here.
|
|
50
|
-
*/
|
|
51
|
-
send?: (input: z.infer<typeof emailInputSchema>) => Promise<void> | void;
|
|
52
|
-
}
|
|
53
|
-
/**
|
|
54
|
-
* Build the `send_email` tool. Approval-gated by default; the
|
|
55
|
-
* execute body either calls the supplied {@link send} hook or
|
|
56
|
-
* logs the email to the server console as a demo stub.
|
|
57
|
-
*
|
|
58
|
-
* @example
|
|
59
|
-
* ```ts
|
|
60
|
-
* import { buildEmailTool, createAgent, mastra } from "@dbx-tools/appkit-mastra";
|
|
61
|
-
*
|
|
62
|
-
* const support = createAgent({
|
|
63
|
-
* instructions: "...",
|
|
64
|
-
* tools(plugins) {
|
|
65
|
-
* return {
|
|
66
|
-
* ...(plugins.genie?.toolkit() ?? {}),
|
|
67
|
-
* send_email: buildEmailTool(),
|
|
68
|
-
* };
|
|
69
|
-
* },
|
|
70
|
-
* });
|
|
71
|
-
* ```
|
|
72
|
-
*/
|
|
73
|
-
export declare function buildEmailTool(opts?: BuildEmailToolOptions): import("@mastra/core/tools").Tool<any, any, any, any, import("@mastra/core/tools").ToolExecutionContext<any, any, unknown>, string, unknown>;
|
|
74
|
-
export {};
|
package/dist/src/tools/email.js
DELETED
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Mastra tool: `send_email`. Gated behind {@link requireApproval}
|
|
3
|
-
* so the model can call it freely but execution is paused until a
|
|
4
|
-
* human approves via the chat UI.
|
|
5
|
-
*
|
|
6
|
-
* The execute body is a stub - it logs the would-be email to the
|
|
7
|
-
* server console (via `logUtils.logger`) and returns success. Swap
|
|
8
|
-
* in a real SMTP / SES / Resend / Workspace Mail call later by
|
|
9
|
-
* editing the `execute` body; the tool surface and approval gate
|
|
10
|
-
* stay the same.
|
|
11
|
-
*
|
|
12
|
-
* Approval flow (Mastra + AI SDK V5):
|
|
13
|
-
*
|
|
14
|
-
* 1. Model calls the tool with `{ to, subject, body, ... }`.
|
|
15
|
-
* 2. Mastra evaluates `requireApproval` (here always `true`),
|
|
16
|
-
* pauses the agent loop, and emits a `tool-call-approval`
|
|
17
|
-
* chunk on the response stream.
|
|
18
|
-
* 3. The chat client renders an approve/deny prompt against the
|
|
19
|
-
* `state: 'approval-requested'` tool part. On approve, it sends
|
|
20
|
-
* a `MastraToolApproval` response back; on deny, the tool call
|
|
21
|
-
* is rejected and the model sees an error.
|
|
22
|
-
* 4. On approve, this `execute` runs and logs the email.
|
|
23
|
-
*
|
|
24
|
-
* The tool is intentionally NOT auto-installed on every agent -
|
|
25
|
-
* email is domain-specific, not infrastructure. Spread it into the
|
|
26
|
-
* specific agents that should be able to draft emails.
|
|
27
|
-
*/
|
|
28
|
-
import { logUtils, stringUtils } from "@dbx-tools/shared";
|
|
29
|
-
import { createTool } from "@mastra/core/tools";
|
|
30
|
-
import { z } from "zod";
|
|
31
|
-
const log = logUtils.logger("mastra/tool/send-email");
|
|
32
|
-
const emailInputSchema = z.object({
|
|
33
|
-
to: z.string().describe(stringUtils.toDescription(`
|
|
34
|
-
Single recipient email address (e.g. "alice@example.com"). For
|
|
35
|
-
multiple recipients, comma-separate them yourself.
|
|
36
|
-
`)),
|
|
37
|
-
subject: z.string().describe(stringUtils.toDescription(`
|
|
38
|
-
Subject line.
|
|
39
|
-
`)),
|
|
40
|
-
body: z.string().describe(stringUtils.toDescription(`
|
|
41
|
-
Email body. Plain text or markdown; the renderer downstream decides
|
|
42
|
-
which to honour. Be specific - the recipient may not have any
|
|
43
|
-
context the model has from prior chat turns.
|
|
44
|
-
`)),
|
|
45
|
-
cc: z
|
|
46
|
-
.array(z.string())
|
|
47
|
-
.optional()
|
|
48
|
-
.describe(stringUtils.toDescription(`
|
|
49
|
-
Optional CC recipients.
|
|
50
|
-
`)),
|
|
51
|
-
bcc: z
|
|
52
|
-
.array(z.string())
|
|
53
|
-
.optional()
|
|
54
|
-
.describe(stringUtils.toDescription(`
|
|
55
|
-
Optional BCC recipients.
|
|
56
|
-
`)),
|
|
57
|
-
});
|
|
58
|
-
const emailOutputSchema = z.object({
|
|
59
|
-
sent: z.boolean().describe(stringUtils.toDescription(`
|
|
60
|
-
True when the email was dispatched. The current implementation
|
|
61
|
-
always returns true after console-logging the would-be email; swap
|
|
62
|
-
in a real provider to make this meaningful.
|
|
63
|
-
`)),
|
|
64
|
-
recipient: z.string().describe(stringUtils.toDescription(`
|
|
65
|
-
Echo of the \`to\` field for confirmation.
|
|
66
|
-
`)),
|
|
67
|
-
});
|
|
68
|
-
/**
|
|
69
|
-
* Build the `send_email` tool. Approval-gated by default; the
|
|
70
|
-
* execute body either calls the supplied {@link send} hook or
|
|
71
|
-
* logs the email to the server console as a demo stub.
|
|
72
|
-
*
|
|
73
|
-
* @example
|
|
74
|
-
* ```ts
|
|
75
|
-
* import { buildEmailTool, createAgent, mastra } from "@dbx-tools/appkit-mastra";
|
|
76
|
-
*
|
|
77
|
-
* const support = createAgent({
|
|
78
|
-
* instructions: "...",
|
|
79
|
-
* tools(plugins) {
|
|
80
|
-
* return {
|
|
81
|
-
* ...(plugins.genie?.toolkit() ?? {}),
|
|
82
|
-
* send_email: buildEmailTool(),
|
|
83
|
-
* };
|
|
84
|
-
* },
|
|
85
|
-
* });
|
|
86
|
-
* ```
|
|
87
|
-
*/
|
|
88
|
-
export function buildEmailTool(opts = {}) {
|
|
89
|
-
return createTool({
|
|
90
|
-
id: opts.id ?? "send_email",
|
|
91
|
-
description: stringUtils.toDescription(`
|
|
92
|
-
Send an email on the user's behalf. Pass a recipient address,
|
|
93
|
-
subject, and body; the user will be prompted to approve the send
|
|
94
|
-
before it goes out (the tool is approval-gated). Use this when
|
|
95
|
-
the user explicitly asks to send / forward / share something via
|
|
96
|
-
email - never autonomously. Keep subjects short and bodies
|
|
97
|
-
focused; the recipient may not have any of the chat context.
|
|
98
|
-
`),
|
|
99
|
-
inputSchema: emailInputSchema,
|
|
100
|
-
outputSchema: emailOutputSchema,
|
|
101
|
-
requireApproval: true,
|
|
102
|
-
execute: async (input) => {
|
|
103
|
-
const { to, subject, body, cc, bcc } = input;
|
|
104
|
-
// Default behaviour: dump the email to the server console so
|
|
105
|
-
// demos can see the gate fire end-to-end without a real
|
|
106
|
-
// provider. Replace by passing `opts.send`.
|
|
107
|
-
log.info("send", {
|
|
108
|
-
to,
|
|
109
|
-
...(cc && cc.length > 0 ? { cc } : {}),
|
|
110
|
-
...(bcc && bcc.length > 0 ? { bcc } : {}),
|
|
111
|
-
subject,
|
|
112
|
-
bodyLength: body.length,
|
|
113
|
-
body,
|
|
114
|
-
});
|
|
115
|
-
if (opts.send) {
|
|
116
|
-
await opts.send(input);
|
|
117
|
-
}
|
|
118
|
-
return { sent: true, recipient: to };
|
|
119
|
-
},
|
|
120
|
-
});
|
|
121
|
-
}
|
package/src/intercept.ts
DELETED
|
@@ -1,206 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Server-side Server-Sent-Events frame interceptor for the native
|
|
3
|
-
* Mastra agent `/stream` endpoint.
|
|
4
|
-
*
|
|
5
|
-
* The Mastra stream (`@mastra/express`) emits one `data: <json>\n\n`
|
|
6
|
-
* SSE frame per chunk, where the decoded JSON carries a
|
|
7
|
-
* discriminating `type` (`text-delta`, `reasoning-delta`,
|
|
8
|
-
* `tool-call`, `step-finish`, ...) and is terminated by a
|
|
9
|
-
* `data: [DONE]` sentinel. {@link installStreamEventInterceptor} wraps
|
|
10
|
-
* an Express response and runs a caller-supplied
|
|
11
|
-
* {@link StreamFrameInterceptor} over each decoded frame so the caller
|
|
12
|
-
* can keep, rewrite, or drop it; every non-`data:` byte (SSE
|
|
13
|
-
* keep-alive comments, the `[DONE]` sentinel, blank padding) passes
|
|
14
|
-
* through verbatim.
|
|
15
|
-
*
|
|
16
|
-
* The wrap is inert unless the response turns out to be a streamed
|
|
17
|
-
* `200 text/event-stream` body, so non-streaming JSON routes and
|
|
18
|
-
* error responses are never touched.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import { StringDecoder } from "node:string_decoder";
|
|
22
|
-
|
|
23
|
-
import type express from "express";
|
|
24
|
-
|
|
25
|
-
/** SSE event separator used by the Mastra stream wire format. */
|
|
26
|
-
const FRAME_DELIMITER = "\n\n";
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Matches a whole SSE frame whose `data:` payload is a JSON object,
|
|
30
|
-
* capturing it in three parts: the `data:` field prefix (including
|
|
31
|
-
* whatever inter-token whitespace the producer used), the JSON object
|
|
32
|
-
* body, and any trailing whitespace. Anchored end-to-end so it only
|
|
33
|
-
* matches a single self-contained object frame; anything else (SSE
|
|
34
|
-
* comments, the `[DONE]` sentinel, text/array payloads) fails the
|
|
35
|
-
* match and is forwarded verbatim without a parse attempt. On rewrite
|
|
36
|
-
* the captured prefix/suffix are re-emitted as-is so only the body
|
|
37
|
-
* changes.
|
|
38
|
-
*/
|
|
39
|
-
const DATA_OBJECT_RE = /^(data:\s*)(\{[\s\S]*\})(\s*)$/;
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Per-frame interceptor invoked with the `JSON.parse`d object of every
|
|
43
|
-
* `data: {json}` frame on the Mastra `/stream` response. The return
|
|
44
|
-
* value decides the frame's fate:
|
|
45
|
-
*
|
|
46
|
-
* - `true`: forward the frame's original bytes unchanged (no
|
|
47
|
-
* re-serialization).
|
|
48
|
-
* - `false`: drop the frame entirely.
|
|
49
|
-
* - `{ replace }`: re-emit the frame with the original `data:` prefix
|
|
50
|
-
* and trailing whitespace preserved and only the JSON body replaced
|
|
51
|
-
* by `JSON.stringify(replace)`.
|
|
52
|
-
*/
|
|
53
|
-
export type StreamFrameInterceptor = (
|
|
54
|
-
chunk: unknown,
|
|
55
|
-
) => { replace: unknown } | true | false;
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* True when `res` is a `200 text/event-stream` body - the only
|
|
59
|
-
* response shape we intercept. Content-type based so it tracks the
|
|
60
|
-
* Mastra `/stream` route without hard-coding the path, and naturally
|
|
61
|
-
* skips the JSON routes (history, models, charts, statements) and any
|
|
62
|
-
* non-200 error response.
|
|
63
|
-
*/
|
|
64
|
-
function isEventStream(res: express.Response): boolean {
|
|
65
|
-
if (res.statusCode !== 200) return false;
|
|
66
|
-
const contentType = String(res.getHeader("content-type") ?? "").toLowerCase();
|
|
67
|
-
return contentType.includes("text/event-stream");
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Run `interceptor` over a single SSE frame. Forwards anything that
|
|
72
|
-
* isn't a parseable `data: {json}` object frame verbatim (comments,
|
|
73
|
-
* the `[DONE]` sentinel, blank padding, unparseable / non-object
|
|
74
|
-
* payloads) so the interceptor only ever sees real chunk objects.
|
|
75
|
-
* Returns the frame string to emit, or `undefined` to drop the frame.
|
|
76
|
-
*/
|
|
77
|
-
function interceptFrame(
|
|
78
|
-
frame: string,
|
|
79
|
-
interceptor: StreamFrameInterceptor,
|
|
80
|
-
): string | undefined {
|
|
81
|
-
const match = DATA_OBJECT_RE.exec(frame);
|
|
82
|
-
if (!match) return frame;
|
|
83
|
-
const [, prefix, body, suffix] = match;
|
|
84
|
-
let parsed: unknown;
|
|
85
|
-
try {
|
|
86
|
-
parsed = JSON.parse(body!);
|
|
87
|
-
} catch {
|
|
88
|
-
// Forward unparseable payloads untouched - feeding a partial /
|
|
89
|
-
// malformed frame to the interceptor risks corrupting bytes the
|
|
90
|
-
// client could still have handled.
|
|
91
|
-
return frame;
|
|
92
|
-
}
|
|
93
|
-
const result = interceptor(parsed);
|
|
94
|
-
if (result === true) return frame;
|
|
95
|
-
if (result === false) return undefined;
|
|
96
|
-
// Preserve the exact captured prefix/suffix so only the JSON body
|
|
97
|
-
// changes; the framing the client parses stays byte-identical.
|
|
98
|
-
return `${prefix}${JSON.stringify(result.replace)}${suffix}`;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Wrap `res.write` / `res.end` so each SSE frame on a streamed Mastra
|
|
103
|
-
* response is run through `interceptor` (keep / rewrite / drop).
|
|
104
|
-
*
|
|
105
|
-
* Interception only engages once the response is confirmed to be a
|
|
106
|
-
* `200 text/event-stream` body (decided lazily on the first write,
|
|
107
|
-
* after the handler has set status + content-type); any other
|
|
108
|
-
* response streams through unchanged. A {@link StringDecoder}
|
|
109
|
-
* reassembles multi-byte UTF-8 sequences that straddle chunk
|
|
110
|
-
* boundaries, and a running buffer holds the trailing partial frame
|
|
111
|
-
* until its `\n\n` arrives.
|
|
112
|
-
*/
|
|
113
|
-
export function installStreamEventInterceptor(
|
|
114
|
-
res: express.Response,
|
|
115
|
-
interceptor: StreamFrameInterceptor,
|
|
116
|
-
): void {
|
|
117
|
-
const origWrite = res.write.bind(res);
|
|
118
|
-
const origEnd = res.end.bind(res);
|
|
119
|
-
const decoder = new StringDecoder("utf8");
|
|
120
|
-
let engaged: boolean | undefined;
|
|
121
|
-
let buffer = "";
|
|
122
|
-
|
|
123
|
-
// Append decoded text, emit every whole `\n\n`-delimited frame the
|
|
124
|
-
// interceptor keeps, and retain any trailing partial frame in
|
|
125
|
-
// `buffer`. On `final`, the residual buffer is flushed as the last
|
|
126
|
-
// frame so a stream that doesn't end on a delimiter isn't dropped.
|
|
127
|
-
const intercept = (text: string, final: boolean): string => {
|
|
128
|
-
buffer += text;
|
|
129
|
-
|
|
130
|
-
const out: string[] = [];
|
|
131
|
-
|
|
132
|
-
let idx: number;
|
|
133
|
-
while ((idx = buffer.indexOf(FRAME_DELIMITER)) !== -1) {
|
|
134
|
-
const frame = buffer.slice(0, idx);
|
|
135
|
-
buffer = buffer.slice(idx + FRAME_DELIMITER.length);
|
|
136
|
-
|
|
137
|
-
const kept = interceptFrame(frame, interceptor);
|
|
138
|
-
if (kept !== undefined) {
|
|
139
|
-
out.push(kept, FRAME_DELIMITER);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
if (final && buffer.length > 0) {
|
|
144
|
-
const kept = interceptFrame(buffer, interceptor);
|
|
145
|
-
if (kept !== undefined) out.push(kept);
|
|
146
|
-
buffer = "";
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
return out.length === 0 ? "" : out.join("");
|
|
150
|
-
};
|
|
151
|
-
|
|
152
|
-
const toText = (chunk: unknown): string => {
|
|
153
|
-
if (typeof chunk === "string") return chunk;
|
|
154
|
-
if (Buffer.isBuffer(chunk)) return decoder.write(chunk);
|
|
155
|
-
if (chunk instanceof Uint8Array) {
|
|
156
|
-
return decoder.write(
|
|
157
|
-
Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength),
|
|
158
|
-
);
|
|
159
|
-
}
|
|
160
|
-
return "";
|
|
161
|
-
};
|
|
162
|
-
|
|
163
|
-
res.write = function patchedWrite(
|
|
164
|
-
chunk: unknown,
|
|
165
|
-
encodingOrCb?: BufferEncoding | ((error?: Error | null) => void),
|
|
166
|
-
cb?: (error?: Error | null) => void,
|
|
167
|
-
): boolean {
|
|
168
|
-
if (engaged === undefined) engaged = isEventStream(res);
|
|
169
|
-
if (!engaged) {
|
|
170
|
-
return origWrite(chunk as never, encodingOrCb as never, cb as never);
|
|
171
|
-
}
|
|
172
|
-
const callback = typeof encodingOrCb === "function" ? encodingOrCb : cb;
|
|
173
|
-
const out = intercept(toText(chunk), false);
|
|
174
|
-
if (out.length === 0) {
|
|
175
|
-
// The chunk only advanced a partial frame; nothing to forward
|
|
176
|
-
// yet. Honor the write callback so backpressure-aware writers
|
|
177
|
-
// don't stall waiting on an ack that never comes.
|
|
178
|
-
if (callback) queueMicrotask(() => callback());
|
|
179
|
-
return true;
|
|
180
|
-
}
|
|
181
|
-
return origWrite(out, callback as never);
|
|
182
|
-
} as typeof res.write;
|
|
183
|
-
|
|
184
|
-
res.end = function patchedEnd(
|
|
185
|
-
chunk?: unknown | (() => void),
|
|
186
|
-
encodingOrCb?: BufferEncoding | (() => void),
|
|
187
|
-
cb?: () => void,
|
|
188
|
-
): express.Response {
|
|
189
|
-
if (engaged === undefined) engaged = isEventStream(res);
|
|
190
|
-
if (!engaged) {
|
|
191
|
-
return origEnd(chunk as never, encodingOrCb as never, cb as never);
|
|
192
|
-
}
|
|
193
|
-
const callback =
|
|
194
|
-
typeof chunk === "function"
|
|
195
|
-
? chunk
|
|
196
|
-
: typeof encodingOrCb === "function"
|
|
197
|
-
? encodingOrCb
|
|
198
|
-
: cb;
|
|
199
|
-
const text =
|
|
200
|
-
(typeof chunk !== "function" && chunk !== undefined ? toText(chunk) : "") +
|
|
201
|
-
decoder.end();
|
|
202
|
-
const out = intercept(text, true);
|
|
203
|
-
if (out.length > 0) origWrite(out);
|
|
204
|
-
return origEnd(callback as never);
|
|
205
|
-
} as typeof res.end;
|
|
206
|
-
}
|