@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/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 `@mastra/ai-sdk` `chatRoute` work behind an Express mount
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
- * `chatRoute` works when `MastraServer` is hosted on an Express subapp
177
- * mounted under a parent path (e.g. `/api/mastra`).
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
- * Two concerns:
180
- *
181
- * 1. The adapter's `registerCustomApiRoutes` matches against `req.path`
182
- * (mount-relative, correct) but dispatches to its internal Hono
183
- * mini-app using `req.originalUrl`, which still contains the parent
184
- * mount prefix. The Hono app registers the literal `chatRoute` paths
185
- * (for example `/route/chat`), so the absolute URL never matches
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 isChat = req.path === "/route" || req.path.startsWith("/route/");
198
- if (!isChat) return next();
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
- * Dynamic model resolution against Databricks Model Serving.
2
+ * Mastra-specific glue over the generic `@dbx-tools/model` toolkit.
3
3
  *
4
- * Turns loose, human-typed model names into real serving-endpoint ids
5
- * so the same agent can target different endpoints without code
6
- * changes. The workspace's `/serving-endpoints` list is fetched once
7
- * per host and cached with a TTL, with concurrent callers sharing one
8
- * in-flight promise (the coalescing pattern of Python's
9
- * `cachetools-async`). Names are matched through `fuse.js` extended
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 { CacheManager, type getExecutionContext } from "@databricks/appkit";
17
- import { logUtils, stringUtils } from "@dbx-tools/shared";
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 the `/models` route
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 `pickModelId` walks when
285
- * nothing explicit is set; defaults live in `model.ts`
286
- * (`FALLBACK_MODEL_IDS`) and are passed in by callers to avoid a
287
- * circular import between `serving.ts` and `model.ts`.
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 ?? DEFAULT_TTL_MS,
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 ?? defaultFallbacks,
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 { MinimalWriter } from "@dbx-tools/appkit-mastra-shared";
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: MinimalWriter | undefined,
27
+ writer: MastraWriter | undefined,
28
28
  chunk: unknown,
29
29
  context: Record<string, unknown> = {},
30
30
  ): Promise<void> {
@@ -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;
@@ -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
- }