@dbx-tools/appkit-mastra 0.1.40 → 0.1.42
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 +59 -58
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- 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 +14 -11
- 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/memory.d.ts +34 -25
- package/dist/src/memory.js +48 -35
- package/dist/src/model.d.ts +24 -131
- package/dist/src/model.js +39 -268
- package/dist/src/plugin.d.ts +28 -12
- package/dist/src/plugin.js +86 -42
- 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/tools/email.d.ts +10 -1
- package/dist/src/writer.d.ts +2 -2
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/index.ts +2 -13
- package/package.json +7 -6
- package/src/agents.ts +3 -3
- package/src/chart.ts +3 -5
- package/src/config.ts +14 -11
- package/src/genie.ts +4 -22
- package/src/history.ts +6 -6
- package/src/memory.ts +48 -45
- package/src/model.ts +57 -291
- package/src/plugin.ts +99 -50
- 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/src/intercept.ts +0 -206
package/src/server.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Express-layer plumbing for the Mastra plugin: a `MastraServer` that
|
|
3
3
|
* stamps the per-request `RequestContext`, and a route-patch middleware
|
|
4
|
-
* that lets
|
|
5
|
-
* point.
|
|
4
|
+
* that lets the plugin's custom API routes (e.g. `historyRoute`) work
|
|
5
|
+
* behind an Express mount point.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { getExecutionContext } from "@databricks/appkit";
|
|
@@ -172,30 +172,23 @@ export class MastraServer extends MastraServerExpress {
|
|
|
172
172
|
}
|
|
173
173
|
|
|
174
174
|
/**
|
|
175
|
-
* Patches around `@mastra/express`'s custom-route dispatcher so
|
|
176
|
-
*
|
|
177
|
-
* mounted under a parent
|
|
175
|
+
* Patches around `@mastra/express`'s custom-route dispatcher so the
|
|
176
|
+
* plugin's custom API routes (e.g. `historyRoute`) work when
|
|
177
|
+
* `MastraServer` is hosted on an Express subapp mounted under a parent
|
|
178
|
+
* path (e.g. `/api/mastra`).
|
|
178
179
|
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
* until we overwrite `originalUrl` for `/route` and `/route/*` to
|
|
187
|
-
* the mount-relative path.
|
|
188
|
-
*
|
|
189
|
-
* 2. `memory.resource` must be the authenticated user, not whatever the
|
|
190
|
-
* client posts. The custom-route forwarder re-serializes `req.body`
|
|
191
|
-
* into the Request body it hands Hono, so mutating the parsed body
|
|
192
|
-
* here would propagate into `handleChatStream`'s params (kept for
|
|
193
|
-
* future use; `express.json()` runs first so `req.body` is parsed).
|
|
180
|
+
* The adapter's `registerCustomApiRoutes` matches against `req.path`
|
|
181
|
+
* (mount-relative, correct) but dispatches to its internal Hono
|
|
182
|
+
* mini-app using `req.originalUrl`, which still contains the parent
|
|
183
|
+
* mount prefix. The Hono app registers the literal route paths
|
|
184
|
+
* (for example `/route/history`), so the absolute URL never matches
|
|
185
|
+
* until we overwrite `originalUrl` for `/route` and `/route/*` to the
|
|
186
|
+
* mount-relative path.
|
|
194
187
|
*/
|
|
195
188
|
export function attachRoutePatchMiddleware(app: express.Express): void {
|
|
196
189
|
app.use((req, _res, next) => {
|
|
197
|
-
const
|
|
198
|
-
if (!
|
|
190
|
+
const isCustomRoute = req.path === "/route" || req.path.startsWith("/route/");
|
|
191
|
+
if (!isCustomRoute) return next();
|
|
199
192
|
req.originalUrl = req.path;
|
|
200
193
|
next();
|
|
201
194
|
});
|
package/src/serving.ts
CHANGED
|
@@ -1,37 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Mastra-specific glue over the generic `@dbx-tools/model` toolkit.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* `
|
|
10
|
-
* search so tokens like `"claude sonnet"` snap to
|
|
11
|
-
* `databricks-claude-sonnet-4-6`, and a per-request override can be
|
|
12
|
-
* pulled from the request header, query string, or body so a model
|
|
13
|
-
* can be swapped per call without redeploying.
|
|
4
|
+
* The live `/serving-endpoints` catalogue access, fuzzy name
|
|
5
|
+
* resolution, and tier/fallback selection all live in
|
|
6
|
+
* `@dbx-tools/model`; this module only adds what is specific to the
|
|
7
|
+
* Mastra plugin: pulling a per-request model override off an HTTP
|
|
8
|
+
* request (header / query / body) and projecting the plugin config
|
|
9
|
+
* onto the knobs `buildModel` and the `/models` route share.
|
|
14
10
|
*/
|
|
15
11
|
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import Fuse from "fuse.js";
|
|
12
|
+
import { DEFAULT_FUZZY_THRESHOLD, DEFAULT_MODEL_CACHE_TTL_MS } from "@dbx-tools/model";
|
|
13
|
+
import { stringUtils } from "@dbx-tools/shared";
|
|
19
14
|
|
|
20
|
-
import type { ServingEndpointSummary } from "@dbx-tools/appkit-mastra-shared";
|
|
21
15
|
import type { MastraPluginConfig } from "./config.js";
|
|
22
16
|
|
|
23
|
-
export type { ServingEndpointSummary };
|
|
24
|
-
|
|
25
|
-
const log = logUtils.logger("mastra/serving");
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Structural type for the Databricks workspace client. Derived from
|
|
29
|
-
* AppKit's `ExecutionContext` so this module doesn't take a direct
|
|
30
|
-
* dependency on `@databricks/sdk-experimental`; the dep flows in
|
|
31
|
-
* transitively through `@databricks/appkit`.
|
|
32
|
-
*/
|
|
33
|
-
type WorkspaceClientLike = ReturnType<typeof getExecutionContext>["client"];
|
|
34
|
-
|
|
35
17
|
/**
|
|
36
18
|
* `RequestContext` key under which {@link MastraServer} stores the
|
|
37
19
|
* per-request model override (header / query / body). `model.ts`
|
|
@@ -48,187 +30,6 @@ export const MODEL_OVERRIDE_QUERY = "model";
|
|
|
48
30
|
/** Body fields (in priority order) inspected for a per-request model override. */
|
|
49
31
|
export const MODEL_OVERRIDE_BODY_FIELDS = ["model", "modelId"] as const;
|
|
50
32
|
|
|
51
|
-
/** Default TTL for the in-memory endpoint cache. Matches the Databricks SDK's session lifetime budget. */
|
|
52
|
-
const DEFAULT_TTL_MS = 5 * 60 * 1000;
|
|
53
|
-
|
|
54
|
-
/** Default Fuse.js score threshold below which a fuzzy match is accepted. */
|
|
55
|
-
const DEFAULT_FUZZY_THRESHOLD = 0.4;
|
|
56
|
-
|
|
57
|
-
/** Cache key parts under which endpoint listings are stored. */
|
|
58
|
-
const CACHE_KEY_NAMESPACE = "mastra:serving-endpoints";
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Stable `userKey` arg for AppKit's `CacheManager.getOrExecute`.
|
|
62
|
-
* Endpoint visibility is effectively workspace-scoped (we cache by
|
|
63
|
-
* host in the key parts), so a single shared key lets every user of
|
|
64
|
-
* the same workspace share one cached fetch and coalesce on the
|
|
65
|
-
* in-flight promise. Permissions can differ in theory, but the
|
|
66
|
-
* Foundation Model API catalogue is the same view for every caller.
|
|
67
|
-
*/
|
|
68
|
-
const SHARED_USER_KEY = "mastra-shared";
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* List Model Serving endpoints for the workspace owning `client`,
|
|
72
|
-
* routed through AppKit's `CacheManager`. The manager gives us
|
|
73
|
-
* everything `cachetools.TTLCache` provides plus what
|
|
74
|
-
* `cachetools-async` adds on top: per-entry TTL, in-flight request
|
|
75
|
-
* coalescing (concurrent callers share one fetch via the manager's
|
|
76
|
-
* internal `inFlightRequests` map), bounded size, telemetry spans
|
|
77
|
-
* (`cache.getOrExecute`), and optional Lakebase persistence so the
|
|
78
|
-
* catalogue survives restarts when the lakebase plugin is wired up.
|
|
79
|
-
*
|
|
80
|
-
* Returns plain {@link ServingEndpointSummary} objects (a stable
|
|
81
|
-
* subset of the SDK type) so cache hits never expose stale SDK
|
|
82
|
-
* internals. Errors from `CacheManager` or the SDK fetch propagate
|
|
83
|
-
* to the caller - we don't swallow them so users see the real
|
|
84
|
-
* auth / network issue.
|
|
85
|
-
*
|
|
86
|
-
* @param host - Workspace host used as the cache key. Pass the value
|
|
87
|
-
* resolved from `client.config.getHost()` so multi-host apps share
|
|
88
|
-
* one entry per workspace.
|
|
89
|
-
* @param opts.ttlMs - Override the default TTL just for this call.
|
|
90
|
-
* Forwarded to `CacheManager` as seconds.
|
|
91
|
-
*/
|
|
92
|
-
export async function listServingEndpoints(
|
|
93
|
-
client: WorkspaceClientLike,
|
|
94
|
-
host: string,
|
|
95
|
-
opts: { ttlMs?: number } = {},
|
|
96
|
-
): Promise<ServingEndpointSummary[]> {
|
|
97
|
-
const ttlSec = Math.max(1, Math.round((opts.ttlMs ?? DEFAULT_TTL_MS) / 1000));
|
|
98
|
-
return CacheManager.getInstanceSync().getOrExecute(
|
|
99
|
-
[CACHE_KEY_NAMESPACE, host],
|
|
100
|
-
() => fetchEndpoints(client),
|
|
101
|
-
SHARED_USER_KEY,
|
|
102
|
-
{ ttl: ttlSec },
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
async function fetchEndpoints(
|
|
107
|
-
client: WorkspaceClientLike,
|
|
108
|
-
): Promise<ServingEndpointSummary[]> {
|
|
109
|
-
const startedAt = Date.now();
|
|
110
|
-
const out: ServingEndpointSummary[] = [];
|
|
111
|
-
for await (const ep of client.servingEndpoints.list()) {
|
|
112
|
-
if (!ep.name) continue;
|
|
113
|
-
out.push({
|
|
114
|
-
name: ep.name,
|
|
115
|
-
...(ep.task !== undefined ? { task: ep.task } : {}),
|
|
116
|
-
...(ep.state?.ready !== undefined ? { state: String(ep.state.ready) } : {}),
|
|
117
|
-
...(ep.description !== undefined ? { description: ep.description } : {}),
|
|
118
|
-
});
|
|
119
|
-
}
|
|
120
|
-
log.debug("listed", { count: out.length, elapsedMs: Date.now() - startedAt });
|
|
121
|
-
return out;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Force-evict cached endpoint listings via AppKit's `CacheManager`.
|
|
126
|
-
* With a `host` deletes that one workspace's entry; without one
|
|
127
|
-
* clears every cache entry on the manager (since `CacheManager`
|
|
128
|
-
* doesn't expose a namespace-scoped clear, this is the brute-force
|
|
129
|
-
* path - fine for tests, avoid in steady-state code).
|
|
130
|
-
*/
|
|
131
|
-
export async function clearServingEndpointsCache(host?: string): Promise<void> {
|
|
132
|
-
const cache = CacheManager.getInstanceSync();
|
|
133
|
-
if (host) {
|
|
134
|
-
const key = cache.generateKey([CACHE_KEY_NAMESPACE, host], SHARED_USER_KEY);
|
|
135
|
-
await cache.delete(key);
|
|
136
|
-
} else {
|
|
137
|
-
await cache.clear();
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Result of fuzzy-resolving a user-supplied model name against the
|
|
143
|
-
* live endpoint list. `score` is Fuse.js's distance (`0` is exact,
|
|
144
|
-
* `1` is no match); `matched` is `false` when the score exceeds the
|
|
145
|
-
* configured threshold so callers can fall back to the original
|
|
146
|
-
* input (Databricks will then return a clean 404).
|
|
147
|
-
*/
|
|
148
|
-
export interface ResolvedModel {
|
|
149
|
-
modelId: string;
|
|
150
|
-
matched: boolean;
|
|
151
|
-
score?: number;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** Options accepted by {@link resolveModelId}. */
|
|
155
|
-
export interface ResolveModelOptions {
|
|
156
|
-
/** Fuse.js threshold (0 = exact, 1 = anything). Default `0.4`. */
|
|
157
|
-
threshold?: number;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Snap a user-supplied model name to the closest configured serving
|
|
162
|
-
* endpoint:
|
|
163
|
-
*
|
|
164
|
-
* 1. Exact name match wins immediately (no fuzzy needed).
|
|
165
|
-
* 2. Otherwise the input is tokenized (dashes / underscores / spaces
|
|
166
|
-
* become separators) and fed through Fuse.js extended search,
|
|
167
|
-
* which AND-s each token with fuzzy matching enabled. This is the
|
|
168
|
-
* "tokenized fuzzy match" the user reaches for when they type
|
|
169
|
-
* `"claude sonnet"` instead of the full endpoint name.
|
|
170
|
-
* 3. If the best Fuse score is above `threshold`, return the input
|
|
171
|
-
* unchanged and let the upstream call surface the 404. This keeps
|
|
172
|
-
* deliberate model ids (e.g. brand new endpoints) from being
|
|
173
|
-
* silently rewritten to a similar-looking neighbour.
|
|
174
|
-
*
|
|
175
|
-
* Pass an empty endpoint list to short-circuit fuzzy matching - the
|
|
176
|
-
* input is returned verbatim. This is what {@link buildModel} does
|
|
177
|
-
* when the workspace client can't be reached at resolve time.
|
|
178
|
-
*/
|
|
179
|
-
export function resolveModelId(
|
|
180
|
-
input: string,
|
|
181
|
-
endpoints: readonly ServingEndpointSummary[],
|
|
182
|
-
opts: ResolveModelOptions = {},
|
|
183
|
-
): ResolvedModel {
|
|
184
|
-
if (endpoints.length === 0) {
|
|
185
|
-
log.debug("resolve:no-endpoints", { input });
|
|
186
|
-
return { modelId: input, matched: false };
|
|
187
|
-
}
|
|
188
|
-
for (const ep of endpoints) {
|
|
189
|
-
if (ep.name === input) {
|
|
190
|
-
log.debug("resolve:exact", { input });
|
|
191
|
-
return { modelId: ep.name, matched: true, score: 0 };
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
const threshold = opts.threshold ?? DEFAULT_FUZZY_THRESHOLD;
|
|
195
|
-
const fuse = new Fuse(endpoints, {
|
|
196
|
-
keys: ["name"],
|
|
197
|
-
threshold,
|
|
198
|
-
ignoreLocation: true,
|
|
199
|
-
includeScore: true,
|
|
200
|
-
useExtendedSearch: true,
|
|
201
|
-
isCaseSensitive: false,
|
|
202
|
-
});
|
|
203
|
-
// Fuse 7.3 has no built-in tokenize hook; in extended search,
|
|
204
|
-
// space-separated tokens are AND-ed with fuzzy matching enabled. We
|
|
205
|
-
// lean on the shared tokenizer so the splitting rules stay
|
|
206
|
-
// consistent with the rest of the toolkit.
|
|
207
|
-
const query = Array.from(
|
|
208
|
-
stringUtils.tokenizeWithOptions({ lowerCase: true, camelCase: false }, input),
|
|
209
|
-
).join(" ");
|
|
210
|
-
if (!query) {
|
|
211
|
-
log.debug("resolve:empty-tokens", { input });
|
|
212
|
-
return { modelId: input, matched: false };
|
|
213
|
-
}
|
|
214
|
-
const results = fuse.search(query);
|
|
215
|
-
const best = results[0];
|
|
216
|
-
if (best?.item.name && (best.score ?? 0) <= threshold) {
|
|
217
|
-
log.debug("resolve:fuzzy-match", {
|
|
218
|
-
input,
|
|
219
|
-
modelId: best.item.name,
|
|
220
|
-
score: best.score,
|
|
221
|
-
});
|
|
222
|
-
return { modelId: best.item.name, matched: true, score: best.score };
|
|
223
|
-
}
|
|
224
|
-
log.debug("resolve:no-match", {
|
|
225
|
-
input,
|
|
226
|
-
bestScore: best?.score,
|
|
227
|
-
threshold,
|
|
228
|
-
});
|
|
229
|
-
return { modelId: input, matched: false };
|
|
230
|
-
}
|
|
231
|
-
|
|
232
33
|
/**
|
|
233
34
|
* Minimal Express-ish request shape used by {@link extractModelOverride}.
|
|
234
35
|
* Keeps this module independent of `express` so the helper can be
|
|
@@ -278,18 +79,15 @@ export function extractModelOverride(req: ModelOverrideRequest): string | null {
|
|
|
278
79
|
|
|
279
80
|
/**
|
|
280
81
|
* Read the fuzzy-resolution config knobs off the plugin config with
|
|
281
|
-
* defaults applied. Kept here so `buildModel` and
|
|
282
|
-
* agree on what "enabled" means.
|
|
82
|
+
* `@dbx-tools/model` defaults applied. Kept here so `buildModel` and
|
|
83
|
+
* the `/models` route agree on what "enabled" means.
|
|
283
84
|
*
|
|
284
|
-
* `fallbacks` is the priority-ordered list `
|
|
285
|
-
* nothing explicit is set; defaults
|
|
286
|
-
*
|
|
287
|
-
*
|
|
85
|
+
* `fallbacks` is the priority-ordered list `resolveModel` walks first
|
|
86
|
+
* when nothing explicit is set; defaults to an empty list so the
|
|
87
|
+
* generic resolver falls through to the live catalogue and its own
|
|
88
|
+
* `FALLBACK_MODEL_IDS` floor.
|
|
288
89
|
*/
|
|
289
|
-
export function resolveServingConfig(
|
|
290
|
-
config: MastraPluginConfig,
|
|
291
|
-
defaultFallbacks: readonly string[] = [],
|
|
292
|
-
): {
|
|
90
|
+
export function resolveServingConfig(config: MastraPluginConfig): {
|
|
293
91
|
ttlMs: number;
|
|
294
92
|
threshold: number;
|
|
295
93
|
fuzzy: boolean;
|
|
@@ -297,10 +95,10 @@ export function resolveServingConfig(
|
|
|
297
95
|
fallbacks: readonly string[];
|
|
298
96
|
} {
|
|
299
97
|
return {
|
|
300
|
-
ttlMs: config.modelCacheTtlMs ??
|
|
98
|
+
ttlMs: config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS,
|
|
301
99
|
threshold: config.modelFuzzyThreshold ?? DEFAULT_FUZZY_THRESHOLD,
|
|
302
100
|
fuzzy: config.modelFuzzyMatch !== false,
|
|
303
101
|
allowOverride: config.modelOverride !== false,
|
|
304
|
-
fallbacks: config.defaultModelFallbacks ??
|
|
102
|
+
fallbacks: config.defaultModelFallbacks ?? [],
|
|
305
103
|
};
|
|
306
104
|
}
|
package/src/writer.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* away can't crash a tool mid-flight.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import type {
|
|
13
|
+
import type { MastraWriter } from "@dbx-tools/appkit-mastra-shared";
|
|
14
14
|
import { commonUtils, type logUtils } from "@dbx-tools/shared";
|
|
15
15
|
|
|
16
16
|
/**
|
|
@@ -24,7 +24,7 @@ import { commonUtils, type logUtils } from "@dbx-tools/shared";
|
|
|
24
24
|
*/
|
|
25
25
|
export async function safeWrite(
|
|
26
26
|
log: logUtils.Logger,
|
|
27
|
-
writer:
|
|
27
|
+
writer: MastraWriter | undefined,
|
|
28
28
|
chunk: unknown,
|
|
29
29
|
context: Record<string, unknown> = {},
|
|
30
30
|
): Promise<void> {
|
package/dist/src/intercept.d.ts
DELETED
|
@@ -1,48 +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 type express from "express";
|
|
21
|
-
/**
|
|
22
|
-
* Per-frame interceptor invoked with the `JSON.parse`d object of every
|
|
23
|
-
* `data: {json}` frame on the Mastra `/stream` response. The return
|
|
24
|
-
* value decides the frame's fate:
|
|
25
|
-
*
|
|
26
|
-
* - `true`: forward the frame's original bytes unchanged (no
|
|
27
|
-
* re-serialization).
|
|
28
|
-
* - `false`: drop the frame entirely.
|
|
29
|
-
* - `{ replace }`: re-emit the frame with the original `data:` prefix
|
|
30
|
-
* and trailing whitespace preserved and only the JSON body replaced
|
|
31
|
-
* by `JSON.stringify(replace)`.
|
|
32
|
-
*/
|
|
33
|
-
export type StreamFrameInterceptor = (chunk: unknown) => {
|
|
34
|
-
replace: unknown;
|
|
35
|
-
} | true | false;
|
|
36
|
-
/**
|
|
37
|
-
* Wrap `res.write` / `res.end` so each SSE frame on a streamed Mastra
|
|
38
|
-
* response is run through `interceptor` (keep / rewrite / drop).
|
|
39
|
-
*
|
|
40
|
-
* Interception only engages once the response is confirmed to be a
|
|
41
|
-
* `200 text/event-stream` body (decided lazily on the first write,
|
|
42
|
-
* after the handler has set status + content-type); any other
|
|
43
|
-
* response streams through unchanged. A {@link StringDecoder}
|
|
44
|
-
* reassembles multi-byte UTF-8 sequences that straddle chunk
|
|
45
|
-
* boundaries, and a running buffer holds the trailing partial frame
|
|
46
|
-
* until its `\n\n` arrives.
|
|
47
|
-
*/
|
|
48
|
-
export declare function installStreamEventInterceptor(res: express.Response, interceptor: StreamFrameInterceptor): void;
|
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
|
-
}
|