@guillem_puche/mastra-effect 0.0.0
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/LICENSE +201 -0
- package/NOTICE +25 -0
- package/README.md +219 -0
- package/dist/index.cjs +1003 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +222 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.ts +222 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +996 -0
- package/dist/index.js.map +1 -0
- package/package.json +100 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,996 @@
|
|
|
1
|
+
import { findMatchingCustomRoute, isProtectedCustomRoute } from "@mastra/server/auth";
|
|
2
|
+
import { MastraServer as MastraServer$1, applyMcpRequestAuth, checkRouteFGA, getCustomHTTPExceptionResponse, isZodError, normalizeQueryParams, redactStreamChunk, serializeStreamChunk } from "@mastra/server/server-adapter";
|
|
3
|
+
import { Data, Effect, Exit, Layer, Option } from "effect";
|
|
4
|
+
import { Cookies, HttpRouter, HttpServerError, HttpServerRequest, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http";
|
|
5
|
+
import { toFetchResponse, toReqRes } from "fetch-to-node";
|
|
6
|
+
//#region src/index.ts
|
|
7
|
+
/** Methods that may carry a request body, per Mastra's route table. */
|
|
8
|
+
const BODY_METHODS = /* @__PURE__ */ new Set([
|
|
9
|
+
"POST",
|
|
10
|
+
"PUT",
|
|
11
|
+
"PATCH",
|
|
12
|
+
"DELETE"
|
|
13
|
+
]);
|
|
14
|
+
/** Methods whose JSON body may carry a `requestContext` envelope. */
|
|
15
|
+
const CONTEXT_BODY_METHODS = /* @__PURE__ */ new Set([
|
|
16
|
+
"POST",
|
|
17
|
+
"PUT",
|
|
18
|
+
"PATCH"
|
|
19
|
+
]);
|
|
20
|
+
/** What `effect/unstable/http`'s `HttpRouter.add` accepts, besides the `*` wildcard. */
|
|
21
|
+
const EFFECT_METHODS = /* @__PURE__ */ new Set([
|
|
22
|
+
"GET",
|
|
23
|
+
"POST",
|
|
24
|
+
"PUT",
|
|
25
|
+
"PATCH",
|
|
26
|
+
"DELETE",
|
|
27
|
+
"OPTIONS",
|
|
28
|
+
"QUERY"
|
|
29
|
+
]);
|
|
30
|
+
let hasPermissionPromise;
|
|
31
|
+
function loadHasPermission() {
|
|
32
|
+
hasPermissionPromise ??= import("@mastra/core/auth/ee").then((m) => m.hasPermission).catch(() => {
|
|
33
|
+
console.error("[@guillem_puche/mastra-effect] Auth features require @mastra/core >= 1.68.0. Please upgrade: npm install @mastra/core@latest");
|
|
34
|
+
});
|
|
35
|
+
return hasPermissionPromise;
|
|
36
|
+
}
|
|
37
|
+
const JSON_HEADERS = { "Content-Type": "application/json" };
|
|
38
|
+
function json(body, status = 200, headers) {
|
|
39
|
+
return new Response(JSON.stringify(body), {
|
|
40
|
+
status: toHttpStatus(status),
|
|
41
|
+
headers: {
|
|
42
|
+
...JSON_HEADERS,
|
|
43
|
+
...headers
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Adds headers to a response, appending `Set-Cookie` so a refreshed session cookie joins the ones the
|
|
49
|
+
* route set instead of replacing them.
|
|
50
|
+
*/
|
|
51
|
+
function withHeaders(response, headers) {
|
|
52
|
+
const entries = Object.entries(headers);
|
|
53
|
+
if (entries.length === 0) return response;
|
|
54
|
+
const editable = new Response(response.body, response);
|
|
55
|
+
for (const [key, value] of entries) if (key.toLowerCase() === "set-cookie") editable.headers.append(key, value);
|
|
56
|
+
else editable.headers.set(key, value);
|
|
57
|
+
return editable;
|
|
58
|
+
}
|
|
59
|
+
/** `new Response` throws outside 200-599, and this runs on the last-resort error path. */
|
|
60
|
+
function toHttpStatus(status) {
|
|
61
|
+
return typeof status === "number" && Number.isInteger(status) && status >= 200 && status <= 599 ? status : 500;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Forwards a body but swallows a mid-stream error, closing instead of erroring, so chunks already
|
|
65
|
+
* delivered survive an upstream failure rather than tearing down the whole response.
|
|
66
|
+
*/
|
|
67
|
+
function createSafeReadableStream(body) {
|
|
68
|
+
if (!body) return null;
|
|
69
|
+
let reader;
|
|
70
|
+
return new ReadableStream({
|
|
71
|
+
async start(controller) {
|
|
72
|
+
reader = body.getReader();
|
|
73
|
+
try {
|
|
74
|
+
for (;;) {
|
|
75
|
+
const { done, value } = await reader.read();
|
|
76
|
+
if (done) break;
|
|
77
|
+
controller.enqueue(value);
|
|
78
|
+
}
|
|
79
|
+
} catch {} finally {
|
|
80
|
+
try {
|
|
81
|
+
controller.close();
|
|
82
|
+
} catch {}
|
|
83
|
+
try {
|
|
84
|
+
reader.releaseLock();
|
|
85
|
+
} catch {}
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
cancel(reason) {
|
|
89
|
+
return reader?.cancel(reason);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/** `fetch-to-node` needs a body it can replay; a consumed request must be rebuilt before forwarding. */
|
|
94
|
+
async function createForwardRequest(request, parsedBody) {
|
|
95
|
+
if (request.method === "GET" || request.method === "HEAD") return request;
|
|
96
|
+
const headers = new Headers(request.headers);
|
|
97
|
+
let body;
|
|
98
|
+
if (parsedBody !== void 0) {
|
|
99
|
+
body = JSON.stringify(parsedBody);
|
|
100
|
+
if (!headers.has("content-type")) headers.set("content-type", "application/json");
|
|
101
|
+
} else if (!request.bodyUsed) {
|
|
102
|
+
const buffer = await request.clone().arrayBuffer();
|
|
103
|
+
if (buffer.byteLength > 0) body = buffer;
|
|
104
|
+
}
|
|
105
|
+
headers.delete("content-length");
|
|
106
|
+
return new Request(request.url, {
|
|
107
|
+
method: request.method,
|
|
108
|
+
headers,
|
|
109
|
+
body,
|
|
110
|
+
signal: request.signal,
|
|
111
|
+
...body ? { duplex: "half" } : {}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/** Strips hop-by-hop framing the outer fetch layer re-applies; leaving it causes double-chunking. */
|
|
115
|
+
function forwardResponse(source) {
|
|
116
|
+
return new Response(createSafeReadableStream(source.body), {
|
|
117
|
+
status: source.status,
|
|
118
|
+
statusText: source.statusText,
|
|
119
|
+
headers: withoutTransferEncoding(source.headers)
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
function withoutTransferEncoding(source) {
|
|
123
|
+
const headers = new Headers(source);
|
|
124
|
+
headers.delete("Transfer-Encoding");
|
|
125
|
+
return headers;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Forwards what an MCP transport wrote, and tells the transport when the client goes away.
|
|
129
|
+
*
|
|
130
|
+
* `fetch-to-node` builds the body from the simulated Node response but never learns that the
|
|
131
|
+
* stream it handed back was cancelled. Nothing then tells the transport the client is gone, so its
|
|
132
|
+
* keep-alive timer keeps firing, and the next tick writes into a closed stream — an
|
|
133
|
+
* `ERR_INVALID_STATE` thrown from a timer, which nothing can catch and which ends the process.
|
|
134
|
+
* Emitting `close` on the response is what the MCP transport listens for: it stops, and ends the
|
|
135
|
+
* response itself.
|
|
136
|
+
*
|
|
137
|
+
* The bridge stream is drained rather than cancelled, for the same reason: `fetch-to-node` flushes
|
|
138
|
+
* buffered writes from a timer, and cancelling its stream leaves that flush writing into a closed
|
|
139
|
+
* one. Ported from `@mastra/hono`'s `propagateClientDisconnect` (see NOTICE).
|
|
140
|
+
*/
|
|
141
|
+
function forwardMcpResponse(source, res) {
|
|
142
|
+
const init = {
|
|
143
|
+
status: source.status,
|
|
144
|
+
statusText: source.statusText,
|
|
145
|
+
headers: withoutTransferEncoding(source.headers)
|
|
146
|
+
};
|
|
147
|
+
const upstream = source.body;
|
|
148
|
+
if (!upstream) return new Response(null, init);
|
|
149
|
+
const reader = upstream.getReader();
|
|
150
|
+
let disconnected = false;
|
|
151
|
+
const body = new ReadableStream({
|
|
152
|
+
async pull(controller) {
|
|
153
|
+
try {
|
|
154
|
+
const { done, value } = await reader.read();
|
|
155
|
+
if (done) controller.close();
|
|
156
|
+
else controller.enqueue(value);
|
|
157
|
+
} catch {
|
|
158
|
+
controller.close();
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
cancel() {
|
|
162
|
+
if (disconnected) return;
|
|
163
|
+
disconnected = true;
|
|
164
|
+
try {
|
|
165
|
+
res.emit("close");
|
|
166
|
+
} catch {}
|
|
167
|
+
reader.read().then(function drain({ done }) {
|
|
168
|
+
return done ? void 0 : reader.read().then(drain);
|
|
169
|
+
}, () => {});
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
return new Response(body, init);
|
|
173
|
+
}
|
|
174
|
+
/** Maps a Mastra route method onto what Effect's router accepts, rather than asserting it blindly. */
|
|
175
|
+
function effectMethod(method, path) {
|
|
176
|
+
const upper = method.toUpperCase();
|
|
177
|
+
if (upper === "ALL") return "*";
|
|
178
|
+
if (!EFFECT_METHODS.has(upper)) throw new Error(`[@guillem_puche/mastra-effect] Unsupported HTTP method "${method}" for route ${path}. effect/unstable/http accepts ${[...EFFECT_METHODS].join(", ")} or ALL.`);
|
|
179
|
+
return upper;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* A Mastra route that failed with a server error (5xx), surfaced in Effect's error channel.
|
|
183
|
+
*
|
|
184
|
+
* It answers with exactly the response Mastra built, so clients see no difference. What changes is
|
|
185
|
+
* what the app's Effect code sees: middleware can `catchTag('MastraRouteError')`, Effect's logger
|
|
186
|
+
* records the cause, and a tracer marks the request span as failed. 4xx answers stay successes —
|
|
187
|
+
* a refused or invalid request is not a server failure.
|
|
188
|
+
*/
|
|
189
|
+
var MastraRouteError = class extends Data.TaggedError("MastraRouteError") {
|
|
190
|
+
get message() {
|
|
191
|
+
return this.cause instanceof Error ? this.cause.message : String(this.cause);
|
|
192
|
+
}
|
|
193
|
+
[HttpServerRespondable.symbol]() {
|
|
194
|
+
return Effect.succeed(this.response);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
/** The error a route threw, keyed by the response it was turned into. */
|
|
198
|
+
const routeErrors = /* @__PURE__ */ new WeakMap();
|
|
199
|
+
/**
|
|
200
|
+
* The key under which the request's Effect span is stored in Mastra's `RequestContext`.
|
|
201
|
+
*
|
|
202
|
+
* Stored as a function on purpose: `RequestContext` drops functions when it is serialised, so the span
|
|
203
|
+
* never lands in a workflow snapshot, and a client sending `requestContext` as JSON cannot supply one.
|
|
204
|
+
*/
|
|
205
|
+
const REQUEST_SPAN_KEY = "mastraEffect.requestSpan";
|
|
206
|
+
/**
|
|
207
|
+
* Mastra server adapter for Effect's HTTP layer.
|
|
208
|
+
*
|
|
209
|
+
* `TApp` is a live `HttpRouter` service instance rather than a Layer: Mastra's `registerRoutes()`
|
|
210
|
+
* awaits ~400 sequential registrations against one fixed app, which the Layer-based
|
|
211
|
+
* `HttpRouter.add` cannot express but the service's effectful `add` can.
|
|
212
|
+
*/
|
|
213
|
+
var MastraServer = class extends MastraServer$1 {
|
|
214
|
+
contextMiddleware;
|
|
215
|
+
errorChannel;
|
|
216
|
+
constructor(options) {
|
|
217
|
+
const { errorChannel, ...base } = options;
|
|
218
|
+
super(base);
|
|
219
|
+
this.errorChannel = errorChannel ?? true;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Builds the per-request Mastra context. Effect has no `derive`, so routes call this directly.
|
|
223
|
+
*
|
|
224
|
+
* `parsedBody` lets the request path reuse the body it already parsed; omitting it (as the
|
|
225
|
+
* multipart conformance suite does, fetching this middleware standalone) falls back to reading.
|
|
226
|
+
*/
|
|
227
|
+
createContextMiddleware() {
|
|
228
|
+
this.contextMiddleware ??= async (request, parsedBody) => {
|
|
229
|
+
let bodyRequestContext;
|
|
230
|
+
let paramsRequestContext;
|
|
231
|
+
const method = request.method.toUpperCase();
|
|
232
|
+
if (CONTEXT_BODY_METHODS.has(method)) {
|
|
233
|
+
let body = parsedBody;
|
|
234
|
+
if (body === void 0 && request.headers.get("content-type")?.includes("application/json")) try {
|
|
235
|
+
body = await request.clone().json();
|
|
236
|
+
} catch {}
|
|
237
|
+
if (body && typeof body === "object" && "requestContext" in body) bodyRequestContext = body.requestContext;
|
|
238
|
+
}
|
|
239
|
+
if (method === "GET" || method === "POST") {
|
|
240
|
+
const encoded = new URL(request.url).searchParams.get("requestContext");
|
|
241
|
+
if (encoded) try {
|
|
242
|
+
paramsRequestContext = JSON.parse(encoded);
|
|
243
|
+
} catch {
|
|
244
|
+
try {
|
|
245
|
+
paramsRequestContext = JSON.parse(Buffer.from(encoded, "base64").toString("utf-8"));
|
|
246
|
+
} catch {}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const requestContext = this.mergeRequestContext({
|
|
250
|
+
paramsRequestContext,
|
|
251
|
+
bodyRequestContext
|
|
252
|
+
});
|
|
253
|
+
this.applyRequestMetadataToContext({
|
|
254
|
+
requestContext,
|
|
255
|
+
getHeader: (name) => request.headers.get(name) ?? void 0
|
|
256
|
+
});
|
|
257
|
+
return requestContext;
|
|
258
|
+
};
|
|
259
|
+
return this.contextMiddleware;
|
|
260
|
+
}
|
|
261
|
+
async getParams(route, ctx) {
|
|
262
|
+
const url = new URL(ctx.request.url);
|
|
263
|
+
const query = {};
|
|
264
|
+
for (const key of new Set(url.searchParams.keys())) query[key] = url.searchParams.getAll(key);
|
|
265
|
+
return {
|
|
266
|
+
urlParams: ctx.pathParams,
|
|
267
|
+
queryParams: normalizeQueryParams(query),
|
|
268
|
+
body: ctx.body,
|
|
269
|
+
bodyParseError: ctx.bodyParseError
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Reads and parses the body exactly once.
|
|
274
|
+
*
|
|
275
|
+
* `oversize` rather than a body when the limit is blown: a chunked request declares no
|
|
276
|
+
* `Content-Length`, so counting what actually arrives is the only way to enforce the cap — for
|
|
277
|
+
* uploads as much as for JSON.
|
|
278
|
+
*/
|
|
279
|
+
async readBody(route, request, maxSize) {
|
|
280
|
+
if (!BODY_METHODS.has(route.method.toUpperCase())) return {};
|
|
281
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
282
|
+
const isMultipart = contentType.includes("multipart/form-data");
|
|
283
|
+
if (!isMultipart && !contentType.includes("application/json")) return {};
|
|
284
|
+
const bytes = await readBytesWithin(request.clone(), maxSize);
|
|
285
|
+
if (bytes === void 0) return { oversize: true };
|
|
286
|
+
if (isMultipart) try {
|
|
287
|
+
const form = await new Response(bytes, { headers: { "content-type": contentType } }).formData();
|
|
288
|
+
return { body: await this.parseFormData(form) };
|
|
289
|
+
} catch (error) {
|
|
290
|
+
this.mastra.getLogger()?.error("Failed to parse multipart form data", { error: error instanceof Error ? {
|
|
291
|
+
message: error.message,
|
|
292
|
+
stack: error.stack
|
|
293
|
+
} : error });
|
|
294
|
+
return { bodyParseError: { message: error instanceof Error ? error.message : "Failed to parse multipart form data" } };
|
|
295
|
+
}
|
|
296
|
+
const text = new TextDecoder().decode(bytes);
|
|
297
|
+
if (text.trim().length === 0) return {};
|
|
298
|
+
try {
|
|
299
|
+
return { body: JSON.parse(text) };
|
|
300
|
+
} catch (error) {
|
|
301
|
+
return { bodyParseError: { message: error instanceof Error ? error.message : "Invalid JSON in request body" } };
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
/** Files arrive as Node Buffers; the conformance multipart suite asserts `Buffer.isBuffer`. */
|
|
305
|
+
async parseFormData(data) {
|
|
306
|
+
const result = {};
|
|
307
|
+
for (const [key, value] of data.entries()) if (typeof value === "string") try {
|
|
308
|
+
result[key] = JSON.parse(value);
|
|
309
|
+
} catch {
|
|
310
|
+
result[key] = value;
|
|
311
|
+
}
|
|
312
|
+
else result[key] = Buffer.from(await value.arrayBuffer());
|
|
313
|
+
return result;
|
|
314
|
+
}
|
|
315
|
+
async stream(route, _ctx, result) {
|
|
316
|
+
const streamFormat = route.streamFormat || "stream";
|
|
317
|
+
const encoder = new TextEncoder();
|
|
318
|
+
const headers = streamFormat === "sse" ? {
|
|
319
|
+
"Content-Type": "text/event-stream",
|
|
320
|
+
"Cache-Control": "no-cache",
|
|
321
|
+
Connection: "keep-alive",
|
|
322
|
+
"X-Accel-Buffering": "no"
|
|
323
|
+
} : { "Content-Type": "text/plain" };
|
|
324
|
+
const source = result instanceof ReadableStream ? result : result?.fullStream;
|
|
325
|
+
if (!(source instanceof ReadableStream)) throw new Error(`Route ${route.path} declares a stream response but returned no stream`);
|
|
326
|
+
let reader;
|
|
327
|
+
const stream = new ReadableStream({
|
|
328
|
+
start: async (controller) => {
|
|
329
|
+
reader = source.getReader();
|
|
330
|
+
try {
|
|
331
|
+
if (streamFormat === "sse" && route.sseFlushOnConnect) controller.enqueue(encoder.encode(": connected\n\n"));
|
|
332
|
+
for (;;) {
|
|
333
|
+
const { done, value } = await reader.read();
|
|
334
|
+
if (done) break;
|
|
335
|
+
if (!value) continue;
|
|
336
|
+
if (streamFormat === "sse" && typeof value === "string" && value.startsWith(":")) {
|
|
337
|
+
controller.enqueue(encoder.encode(value));
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const outputValue = this.streamOptions?.redact ?? true ? redactStreamChunk(value) : value;
|
|
341
|
+
const serialized = serializeStreamChunk(outputValue);
|
|
342
|
+
if (!serialized.ok) {
|
|
343
|
+
this.mastra.getLogger()?.error("Failed to serialize stream chunk, skipping", {
|
|
344
|
+
path: route.path,
|
|
345
|
+
chunkType: outputValue?.type,
|
|
346
|
+
error: serialized.error.message
|
|
347
|
+
});
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
controller.enqueue(encoder.encode(streamFormat === "sse" ? `data: ${serialized.json}\n\n` : `${serialized.json}\x1E`));
|
|
351
|
+
}
|
|
352
|
+
if (streamFormat === "sse") controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
353
|
+
controller.close();
|
|
354
|
+
} catch (error) {
|
|
355
|
+
this.mastra.getLogger()?.error("Error in stream processing", { error: error instanceof Error ? {
|
|
356
|
+
message: error.message,
|
|
357
|
+
stack: error.stack
|
|
358
|
+
} : error });
|
|
359
|
+
try {
|
|
360
|
+
controller.close();
|
|
361
|
+
} catch {}
|
|
362
|
+
} finally {
|
|
363
|
+
await reader.cancel().catch(() => {});
|
|
364
|
+
}
|
|
365
|
+
},
|
|
366
|
+
cancel(reason) {
|
|
367
|
+
return reader?.cancel(reason);
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
return new Response(stream, { headers });
|
|
371
|
+
}
|
|
372
|
+
async sendResponse(route, ctx, result, prefix) {
|
|
373
|
+
const resolvedPrefix = prefix ?? this.prefix ?? "";
|
|
374
|
+
const refreshHeaders = {};
|
|
375
|
+
if (result && typeof result === "object" && "__refreshHeaders" in result) {
|
|
376
|
+
Object.assign(refreshHeaders, result.__refreshHeaders);
|
|
377
|
+
delete result.__refreshHeaders;
|
|
378
|
+
}
|
|
379
|
+
switch (route.responseType) {
|
|
380
|
+
case "json": return json(result ?? null, 200, refreshHeaders);
|
|
381
|
+
case "stream": return this.stream(route, ctx, result);
|
|
382
|
+
case "datastream-response":
|
|
383
|
+
if (!(result instanceof Response)) throw new Error(`Route ${route.path} declares a Response but returned something else`);
|
|
384
|
+
return forwardResponse(result);
|
|
385
|
+
case "mcp-http": {
|
|
386
|
+
const { server, httpPath, mcpOptions: routeMcpOptions } = result;
|
|
387
|
+
const { setRequestAuth, ...options } = {
|
|
388
|
+
...this.mcpOptions,
|
|
389
|
+
...routeMcpOptions
|
|
390
|
+
};
|
|
391
|
+
return this.bridgeMcpTransport(ctx, {
|
|
392
|
+
label: "[MCP HTTP] Error in background startHTTP",
|
|
393
|
+
setRequestAuth,
|
|
394
|
+
failureBody: {
|
|
395
|
+
jsonrpc: "2.0",
|
|
396
|
+
error: {
|
|
397
|
+
code: -32603,
|
|
398
|
+
message: "Internal server error"
|
|
399
|
+
},
|
|
400
|
+
id: null
|
|
401
|
+
},
|
|
402
|
+
start: ({ url, req, res }) => server.startHTTP({
|
|
403
|
+
url,
|
|
404
|
+
httpPath: `${resolvedPrefix}${httpPath}`,
|
|
405
|
+
req,
|
|
406
|
+
res,
|
|
407
|
+
options: Object.keys(options).length > 0 ? options : void 0
|
|
408
|
+
})
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
case "mcp-sse": {
|
|
412
|
+
const { server, ssePath, messagePath } = result;
|
|
413
|
+
return this.bridgeMcpTransport(ctx, {
|
|
414
|
+
label: "[MCP SSE] Error in background startSSE",
|
|
415
|
+
setRequestAuth: this.mcpOptions?.setRequestAuth,
|
|
416
|
+
failureBody: { error: "Error handling MCP SSE request" },
|
|
417
|
+
start: ({ url, req, res }) => server.startSSE({
|
|
418
|
+
url,
|
|
419
|
+
ssePath: `${resolvedPrefix}${ssePath}`,
|
|
420
|
+
messagePath: `${resolvedPrefix}${messagePath}`,
|
|
421
|
+
req,
|
|
422
|
+
res
|
|
423
|
+
})
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
default: return new Response(null, { status: 500 });
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Runs an MCP transport against a replayable copy of the request and returns what it wrote to the
|
|
431
|
+
* Node response object.
|
|
432
|
+
*
|
|
433
|
+
* `start` is deliberately not awaited: it resolves when the body finishes, while `toFetchResponse`
|
|
434
|
+
* resolves once headers are sent. Awaiting here would stall SSE.
|
|
435
|
+
*/
|
|
436
|
+
async bridgeMcpTransport(ctx, transport) {
|
|
437
|
+
const forwardRequest = await createForwardRequest(ctx.request, ctx.body);
|
|
438
|
+
const { req, res } = toReqRes(forwardRequest);
|
|
439
|
+
await applyMcpRequestAuth({
|
|
440
|
+
req,
|
|
441
|
+
requestContext: ctx.requestContext,
|
|
442
|
+
setRequestAuth: transport.setRequestAuth
|
|
443
|
+
});
|
|
444
|
+
transport.start({
|
|
445
|
+
url: new URL(forwardRequest.url),
|
|
446
|
+
req,
|
|
447
|
+
res
|
|
448
|
+
}).catch((error) => {
|
|
449
|
+
this.mastra.getLogger()?.error(transport.label, { error: error instanceof Error ? {
|
|
450
|
+
message: error.message,
|
|
451
|
+
stack: error.stack
|
|
452
|
+
} : error });
|
|
453
|
+
try {
|
|
454
|
+
if (!res.headersSent) {
|
|
455
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
456
|
+
res.end(JSON.stringify(transport.failureBody));
|
|
457
|
+
}
|
|
458
|
+
} catch {}
|
|
459
|
+
});
|
|
460
|
+
return forwardMcpResponse(await toFetchResponse(res), res);
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Never rejects — the caller runs it through `Effect.promise`. A route that threw is still answered
|
|
464
|
+
* with a response; what it threw is kept in `routeErrors` for `toEffectOutcome`.
|
|
465
|
+
*/
|
|
466
|
+
async handleRoute(route, prefix, request, pathParams, span) {
|
|
467
|
+
let refreshHeaders = {};
|
|
468
|
+
try {
|
|
469
|
+
const maxSize = route.maxBodySize ?? this.bodyLimitOptions?.maxSize;
|
|
470
|
+
if (this.exceedsDeclaredLimit(route, request, maxSize)) return this.bodyLimitResponse(route);
|
|
471
|
+
const parsed = await this.readBody(route, request, maxSize);
|
|
472
|
+
if (parsed.oversize) return this.bodyLimitResponse(route);
|
|
473
|
+
const ctx = {
|
|
474
|
+
request,
|
|
475
|
+
pathParams,
|
|
476
|
+
body: parsed.body,
|
|
477
|
+
bodyParseError: parsed.bodyParseError
|
|
478
|
+
};
|
|
479
|
+
const requestContext = await this.createContextMiddleware()(request, parsed.body);
|
|
480
|
+
if (span) requestContext.set(REQUEST_SPAN_KEY, () => span);
|
|
481
|
+
const url = new URL(request.url);
|
|
482
|
+
const authError = await this.checkRouteAuth(route, {
|
|
483
|
+
path: url.pathname,
|
|
484
|
+
method: request.method,
|
|
485
|
+
getHeader: (name) => request.headers.get(name) ?? void 0,
|
|
486
|
+
getQuery: (name) => url.searchParams.get(name) ?? void 0,
|
|
487
|
+
requestContext,
|
|
488
|
+
request,
|
|
489
|
+
buildAuthorizeContext: () => request
|
|
490
|
+
});
|
|
491
|
+
if (authError?.error) return json({ error: authError.error }, authError.status, authError.headers);
|
|
492
|
+
refreshHeaders = authError?.headers ?? {};
|
|
493
|
+
const permissionError = await this.resolvePermissionError(route, requestContext);
|
|
494
|
+
if (permissionError) return json({
|
|
495
|
+
error: permissionError.error,
|
|
496
|
+
message: permissionError.message
|
|
497
|
+
}, permissionError.status, refreshHeaders);
|
|
498
|
+
const params = await this.getParams(route, ctx);
|
|
499
|
+
if (params.bodyParseError) return json({
|
|
500
|
+
error: "Invalid request body",
|
|
501
|
+
issues: [{
|
|
502
|
+
field: "body",
|
|
503
|
+
message: params.bodyParseError.message
|
|
504
|
+
}]
|
|
505
|
+
}, 400, refreshHeaders);
|
|
506
|
+
const validated = await this.validateParams(route, params, refreshHeaders);
|
|
507
|
+
if ("response" in validated) return validated.response;
|
|
508
|
+
const fgaError = await checkRouteFGA(this.mastra, route, requestContext, {
|
|
509
|
+
...params.urlParams,
|
|
510
|
+
...params.queryParams,
|
|
511
|
+
...typeof params.body === "object" ? params.body : {}
|
|
512
|
+
});
|
|
513
|
+
if (fgaError) return json({
|
|
514
|
+
error: fgaError.error,
|
|
515
|
+
message: fgaError.message
|
|
516
|
+
}, fgaError.status, refreshHeaders);
|
|
517
|
+
const result = await route.handler({
|
|
518
|
+
...params.urlParams,
|
|
519
|
+
...params.queryParams,
|
|
520
|
+
...typeof params.body === "object" ? params.body : {},
|
|
521
|
+
requestContext,
|
|
522
|
+
mastra: this.mastra,
|
|
523
|
+
registeredTools: this.tools ?? {},
|
|
524
|
+
taskStore: this.taskStore,
|
|
525
|
+
abortSignal: request.signal,
|
|
526
|
+
routePrefix: prefix,
|
|
527
|
+
request
|
|
528
|
+
});
|
|
529
|
+
return withHeaders(await this.sendResponse(route, {
|
|
530
|
+
...ctx,
|
|
531
|
+
body: params.body,
|
|
532
|
+
requestContext
|
|
533
|
+
}, result, prefix), refreshHeaders);
|
|
534
|
+
} catch (error) {
|
|
535
|
+
const response = withHeaders(this.toErrorResponse(error, route), refreshHeaders);
|
|
536
|
+
routeErrors.set(response, error);
|
|
537
|
+
return response;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
exceedsDeclaredLimit(route, request, maxSize) {
|
|
541
|
+
if (maxSize === void 0 || !BODY_METHODS.has(route.method.toUpperCase())) return false;
|
|
542
|
+
const contentLength = request.headers.get("content-length");
|
|
543
|
+
return contentLength !== null && Number.parseInt(contentLength, 10) > maxSize;
|
|
544
|
+
}
|
|
545
|
+
bodyLimitResponse(route) {
|
|
546
|
+
let errorResponse = { error: "Request body too large" };
|
|
547
|
+
if (route.maxBodySize === void 0 && this.bodyLimitOptions) try {
|
|
548
|
+
errorResponse = this.bodyLimitOptions.onError(errorResponse);
|
|
549
|
+
} catch {}
|
|
550
|
+
return json(errorResponse, 413);
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Mastra's RBAC check, which only applies once an auth provider is configured.
|
|
554
|
+
*
|
|
555
|
+
* `hasPermission` lives in an enterprise-only module, so `loadHasPermission` can come back empty;
|
|
556
|
+
* the request then proceeds unchecked rather than being refused.
|
|
557
|
+
*/
|
|
558
|
+
async resolvePermissionError(route, requestContext) {
|
|
559
|
+
if (!this.mastra.getStudio?.()?.auth && !this.mastra.getServer()?.auth) return null;
|
|
560
|
+
const hasPermission = await loadHasPermission();
|
|
561
|
+
if (!hasPermission) return null;
|
|
562
|
+
const userPermissions = requestContext.get("mastra__userPermissions");
|
|
563
|
+
return this.checkRoutePermission(route, userPermissions, hasPermission, requestContext);
|
|
564
|
+
}
|
|
565
|
+
async validateParams(route, params, refreshHeaders) {
|
|
566
|
+
const steps = [
|
|
567
|
+
{
|
|
568
|
+
context: "query",
|
|
569
|
+
fallback: "Invalid query parameters",
|
|
570
|
+
run: async () => {
|
|
571
|
+
if (params.queryParams) params.queryParams = await this.parseQueryParams(route, params.queryParams);
|
|
572
|
+
}
|
|
573
|
+
},
|
|
574
|
+
{
|
|
575
|
+
context: "body",
|
|
576
|
+
fallback: "Invalid request body",
|
|
577
|
+
run: async () => {
|
|
578
|
+
if (params.body !== void 0 || route.bodySchema) params.body = await this.parseBody(route, params.body);
|
|
579
|
+
}
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
context: "path",
|
|
583
|
+
fallback: "Invalid path parameters",
|
|
584
|
+
run: async () => {
|
|
585
|
+
if (params.urlParams) params.urlParams = await this.parsePathParams(route, params.urlParams);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
];
|
|
589
|
+
for (const step of steps) try {
|
|
590
|
+
await step.run();
|
|
591
|
+
} catch (error) {
|
|
592
|
+
this.mastra.getLogger()?.error(`Error parsing ${step.context} params`, { error: error instanceof Error ? {
|
|
593
|
+
message: error.message,
|
|
594
|
+
stack: error.stack
|
|
595
|
+
} : error });
|
|
596
|
+
if (isZodError(error)) {
|
|
597
|
+
const resolved = this.resolveValidationError(route, error, step.context);
|
|
598
|
+
return { response: json(resolved.body, resolved.status, refreshHeaders) };
|
|
599
|
+
}
|
|
600
|
+
return { response: json({
|
|
601
|
+
error: step.fallback,
|
|
602
|
+
issues: [{
|
|
603
|
+
field: "unknown",
|
|
604
|
+
message: error instanceof Error ? error.message : "Unknown error"
|
|
605
|
+
}]
|
|
606
|
+
}, 400, refreshHeaders) };
|
|
607
|
+
}
|
|
608
|
+
return { ok: true };
|
|
609
|
+
}
|
|
610
|
+
toErrorResponse(error, route) {
|
|
611
|
+
const status = error?.status ?? error?.details?.status;
|
|
612
|
+
if (!(typeof status === "number" && status >= 400 && status < 500)) this.mastra.getLogger()?.error("Error calling handler", {
|
|
613
|
+
error: error instanceof Error ? {
|
|
614
|
+
message: error.message,
|
|
615
|
+
stack: error.stack
|
|
616
|
+
} : error,
|
|
617
|
+
path: route?.path,
|
|
618
|
+
method: route?.method
|
|
619
|
+
});
|
|
620
|
+
const customResponse = getCustomHTTPExceptionResponse(error);
|
|
621
|
+
if (customResponse) return customResponse;
|
|
622
|
+
const cause = error instanceof Error ? error.cause : void 0;
|
|
623
|
+
const failingItems = typeof status === "number" && cause && typeof cause === "object" && "failingItems" in cause ? cause.failingItems : void 0;
|
|
624
|
+
return json({
|
|
625
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
626
|
+
...Array.isArray(failingItems) ? { cause: { failingItems } } : {}
|
|
627
|
+
}, toHttpStatus(status));
|
|
628
|
+
}
|
|
629
|
+
async registerRoute(app, route, { prefix: prefixParam } = {}) {
|
|
630
|
+
const prefix = prefixParam ?? this.prefix ?? "";
|
|
631
|
+
const fullPath = `${prefix}${route.path}`;
|
|
632
|
+
const handler = this.toEffectHandler((request, pathParams, span) => this.handleRoute(route, prefix, request, pathParams, span));
|
|
633
|
+
await Effect.runPromise(addRoute(app, effectMethod(route.method, route.path), fullPath, handler));
|
|
634
|
+
}
|
|
635
|
+
async registerCustomApiRoutes() {
|
|
636
|
+
const routes = await this.registerSchemaApiRoutes();
|
|
637
|
+
if (!await this.buildCustomRouteHandler(routes)) return;
|
|
638
|
+
const handler = this.toEffectHandler((request, _pathParams, span) => this.handleCustomRoute(request, span));
|
|
639
|
+
for (const route of routes) await Effect.runPromise(addRoute(this.app, effectMethod(route.method, route.path), route.path, handler));
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* An Effect route handler around one of the adapter's Web entry points. The entry point gets the
|
|
643
|
+
* request with a body it can read and a signal that aborts if the client leaves, plus the path
|
|
644
|
+
* parameters and the request's span; what it answers becomes the Effect result.
|
|
645
|
+
*/
|
|
646
|
+
toEffectHandler(answer) {
|
|
647
|
+
return (serverRequest) => Effect.flatMap(Effect.all([
|
|
648
|
+
HttpRouter.params,
|
|
649
|
+
abortOnDisconnect,
|
|
650
|
+
Effect.option(Effect.currentParentSpan)
|
|
651
|
+
]), ([pathParams, signal, span]) => Effect.flatMap(toReadableWebRequest(serverRequest, signal), (request) => Effect.flatMap(Effect.promise(() => answer(request, pathParams, Option.getOrUndefined(span))), (response) => this.toEffectOutcome(request, response))));
|
|
652
|
+
}
|
|
653
|
+
async handleCustomRoute(request, span) {
|
|
654
|
+
try {
|
|
655
|
+
const url = new URL(request.url);
|
|
656
|
+
const path = url.pathname;
|
|
657
|
+
const method = request.method;
|
|
658
|
+
const body = await readBodyFields(request);
|
|
659
|
+
const requestContext = await this.createContextMiddleware()(request, body.json);
|
|
660
|
+
if (span) requestContext.set(REQUEST_SPAN_KEY, () => span);
|
|
661
|
+
const matchedRoute = findMatchingCustomRoute(path, method, this.customApiRoutes ?? this.mastra.getServer()?.apiRoutes);
|
|
662
|
+
const shouldRunAuth = isProtectedCustomRoute(path, method, this.customRouteAuthConfig);
|
|
663
|
+
const shouldRunFGA = !!matchedRoute?.route.fga;
|
|
664
|
+
if (shouldRunAuth || shouldRunFGA) {
|
|
665
|
+
const serverRoute = {
|
|
666
|
+
method: matchedRoute?.route.method ?? method,
|
|
667
|
+
path: matchedRoute?.route.path ?? path,
|
|
668
|
+
responseType: "json",
|
|
669
|
+
handler: async () => {},
|
|
670
|
+
requiresAuth: matchedRoute?.route.requiresAuth,
|
|
671
|
+
requiresPermission: matchedRoute?.route.requiresPermission,
|
|
672
|
+
fga: matchedRoute?.route.fga
|
|
673
|
+
};
|
|
674
|
+
if (shouldRunAuth) {
|
|
675
|
+
const authError = await this.checkRouteAuth(serverRoute, {
|
|
676
|
+
path,
|
|
677
|
+
method,
|
|
678
|
+
getHeader: (name) => request.headers.get(name) ?? void 0,
|
|
679
|
+
getQuery: (name) => url.searchParams.get(name) ?? void 0,
|
|
680
|
+
requestContext,
|
|
681
|
+
request,
|
|
682
|
+
buildAuthorizeContext: () => request
|
|
683
|
+
});
|
|
684
|
+
if (authError?.error) return json({ error: authError.error }, authError.status, authError.headers);
|
|
685
|
+
const permissionError = await this.resolvePermissionError(serverRoute, requestContext);
|
|
686
|
+
if (permissionError) return json({
|
|
687
|
+
error: permissionError.error,
|
|
688
|
+
message: permissionError.message
|
|
689
|
+
}, permissionError.status);
|
|
690
|
+
}
|
|
691
|
+
const fgaError = await checkRouteFGA(this.mastra, serverRoute, requestContext, {
|
|
692
|
+
...matchedRoute?.params,
|
|
693
|
+
...Object.fromEntries(url.searchParams),
|
|
694
|
+
...body.fields
|
|
695
|
+
});
|
|
696
|
+
if (fgaError) return json({
|
|
697
|
+
error: fgaError.error,
|
|
698
|
+
message: fgaError.message
|
|
699
|
+
}, fgaError.status);
|
|
700
|
+
}
|
|
701
|
+
const headers = {};
|
|
702
|
+
request.headers.forEach((value, key) => {
|
|
703
|
+
headers[key] = value;
|
|
704
|
+
});
|
|
705
|
+
return await this.handleCustomRouteRequest(request.url, request.method, headers, request.body ?? void 0, requestContext, request.signal) ?? json({ error: "Not Found" }, 404);
|
|
706
|
+
} catch (error) {
|
|
707
|
+
const response = this.toErrorResponse(error);
|
|
708
|
+
routeErrors.set(response, error);
|
|
709
|
+
return response;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* The Effect result of a route: its response, or — for a server error (5xx), when the error
|
|
714
|
+
* channel is on — a `MastraRouteError` that answers with that same response.
|
|
715
|
+
*
|
|
716
|
+
* Decided by the status, not by whether the adapter caught something: not every server error
|
|
717
|
+
* passes through its `catch`. A custom route's handler that throws is answered by Mastra's own
|
|
718
|
+
* sub-app, and an MCP transport that fails to start writes its 500 itself.
|
|
719
|
+
*/
|
|
720
|
+
toEffectOutcome(request, response) {
|
|
721
|
+
const converted = this.toEffectResponse(response);
|
|
722
|
+
if (!this.errorChannel || response.status < 500) return Effect.succeed(converted);
|
|
723
|
+
const cause = routeErrors.has(response) ? routeErrors.get(response) : /* @__PURE__ */ new Error(`Mastra answered ${request.method} ${new URL(request.url).pathname} with ${response.status}`);
|
|
724
|
+
return Effect.fail(new MastraRouteError({
|
|
725
|
+
status: response.status,
|
|
726
|
+
cause,
|
|
727
|
+
response: converted
|
|
728
|
+
}));
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Converts a Web response for Effect to send, keeping every `Set-Cookie`.
|
|
732
|
+
*
|
|
733
|
+
* `HttpServerResponse.fromWeb` files cookies by name, so two sharing a name — one cleared at two
|
|
734
|
+
* paths, say — collapse into the last, and one that cannot be serialised fails the response when
|
|
735
|
+
* it is sent, leaving the client waiting. Each cookie gets its own entry here instead, and one
|
|
736
|
+
* that could never be sent is dropped with a warning. Attributes Effect does not model (anything
|
|
737
|
+
* but Domain, Path, Expires, Max-Age, HttpOnly, Secure, SameSite, Priority, Partitioned) are lost.
|
|
738
|
+
*/
|
|
739
|
+
toEffectResponse(response) {
|
|
740
|
+
const setCookies = response.headers.getSetCookie();
|
|
741
|
+
const converted = HttpServerResponse.fromWeb(response);
|
|
742
|
+
if (setCookies.length === 0) return converted;
|
|
743
|
+
const cookies = {};
|
|
744
|
+
setCookies.forEach((header, index) => {
|
|
745
|
+
for (const cookie of Object.values(Cookies.fromSetCookie(header).cookies)) try {
|
|
746
|
+
Cookies.serializeCookie(cookie);
|
|
747
|
+
cookies[`${index}:${cookie.name}`] = cookie;
|
|
748
|
+
} catch (error) {
|
|
749
|
+
this.mastra.getLogger()?.warn("Dropped a Set-Cookie header that cannot be sent", {
|
|
750
|
+
cookie: cookie.name,
|
|
751
|
+
error: error instanceof Error ? error.message : String(error)
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
});
|
|
755
|
+
return HttpServerResponse.replaceCookies(converted, Cookies.fromReadonlyRecord(cookies));
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Context is built per route because Effect has no `derive`-style request hook. What does need
|
|
759
|
+
* every request, matched or not, is the warning for a channel webhook nobody registered, which
|
|
760
|
+
* shows up as a 404 no route would ever log — so it runs here, as in `@mastra/hono`.
|
|
761
|
+
*
|
|
762
|
+
* It wraps the app's own routes too, since they share the router, so it steps aside at once for
|
|
763
|
+
* anything that cannot be such a webhook: every method but POST, and a target with no parsable path.
|
|
764
|
+
*/
|
|
765
|
+
registerContextMiddleware() {
|
|
766
|
+
Effect.runSync(this.app.addGlobalMiddleware((httpEffect) => Effect.flatMap(HttpServerRequest.HttpServerRequest, (serverRequest) => {
|
|
767
|
+
if (serverRequest.method.toUpperCase() !== "POST") return httpEffect;
|
|
768
|
+
const url = parseTarget(serverRequest.url);
|
|
769
|
+
if (!url) return httpEffect;
|
|
770
|
+
return Effect.onExit(httpEffect, (exit) => Effect.map(statusOf(exit), (status) => this.warnIfUnregisteredChannelWebhook(url.pathname, serverRequest.method, status)));
|
|
771
|
+
})));
|
|
772
|
+
}
|
|
773
|
+
/** Auth is resolved per route, matching every other adapter. */
|
|
774
|
+
registerAuthMiddleware() {}
|
|
775
|
+
registerHttpLoggingMiddleware() {
|
|
776
|
+
if (!this.httpLoggingConfig?.enabled) return;
|
|
777
|
+
Effect.runSync(this.app.addGlobalMiddleware((httpEffect) => Effect.flatMap(HttpServerRequest.HttpServerRequest, (serverRequest) => {
|
|
778
|
+
const url = parseTarget(serverRequest.url);
|
|
779
|
+
const path = url?.pathname ?? serverRequest.url;
|
|
780
|
+
if (!this.shouldLogRequest(path)) return httpEffect;
|
|
781
|
+
const start = Date.now();
|
|
782
|
+
return Effect.onExit(httpEffect, (exit) => Effect.map(statusOf(exit), (status) => {
|
|
783
|
+
const duration = Date.now() - start;
|
|
784
|
+
const level = this.httpLoggingConfig?.level || "info";
|
|
785
|
+
const logData = {
|
|
786
|
+
method: serverRequest.method,
|
|
787
|
+
path,
|
|
788
|
+
status,
|
|
789
|
+
duration: `${duration}ms`
|
|
790
|
+
};
|
|
791
|
+
if (this.httpLoggingConfig?.includeQueryParams) logData.query = Object.fromEntries(url?.searchParams ?? []);
|
|
792
|
+
if (this.httpLoggingConfig?.includeHeaders) {
|
|
793
|
+
const headers = { ...serverRequest.headers };
|
|
794
|
+
for (const header of this.httpLoggingConfig.redactHeaders ?? []) if (headers[header.toLowerCase()] !== void 0) headers[header.toLowerCase()] = "[REDACTED]";
|
|
795
|
+
logData.headers = headers;
|
|
796
|
+
}
|
|
797
|
+
this.logger[level](`${serverRequest.method} ${path} ${status} ${duration}ms`, logData);
|
|
798
|
+
}));
|
|
799
|
+
})));
|
|
800
|
+
}
|
|
801
|
+
};
|
|
802
|
+
/**
|
|
803
|
+
* The request as a Web `Request` whose body can still be read.
|
|
804
|
+
*
|
|
805
|
+
* `HttpServerRequest.toWeb` hands over the original body. Once a middleware has read it
|
|
806
|
+
* (`request.json`, `request.text`…) that body is empty on a Node server and unusable in a fetch
|
|
807
|
+
* handler, so Mastra would see no input at all. Effect keeps what the middleware read, and the body
|
|
808
|
+
* is rebuilt from that: the bytes where Effect kept them, otherwise the text, which is what a fetch
|
|
809
|
+
* handler keeps for JSON and forms. A body nobody has read passes through untouched, still streaming.
|
|
810
|
+
*/
|
|
811
|
+
const toReadableWebRequest = (serverRequest, signal) => Effect.flatMap(Effect.orDie(HttpServerRequest.toWeb(serverRequest, { signal })), (request) => {
|
|
812
|
+
if (request.method === "GET" || request.method === "HEAD" || !bodyWasRead(serverRequest.source)) return Effect.succeed(request);
|
|
813
|
+
if (!effectKeptBody(serverRequest)) return Effect.die(/* @__PURE__ */ new Error(`The body of ${request.method} ${new URL(request.url).pathname} was consumed before Mastra's route ran, and not through request.text/json/arrayBuffer, so it cannot be recovered`));
|
|
814
|
+
return serverRequest.arrayBuffer.pipe(Effect.map((bytes) => new Uint8Array(bytes)), Effect.catch(() => Effect.map(serverRequest.text, (text) => new TextEncoder().encode(text))), Effect.orDie, Effect.map((body) => new Request(request.url, {
|
|
815
|
+
method: request.method,
|
|
816
|
+
headers: request.headers,
|
|
817
|
+
body,
|
|
818
|
+
signal: request.signal
|
|
819
|
+
})));
|
|
820
|
+
});
|
|
821
|
+
/**
|
|
822
|
+
* A signal that aborts when the request ends without its response having been sent: the client
|
|
823
|
+
* disconnected, or the server shut down first. Mastra hands it to routes as `abortSignal`, which is
|
|
824
|
+
* what stops an agent's model calls — without it they run to the end after the client has left.
|
|
825
|
+
*
|
|
826
|
+
* Tied to the request's scope, which Effect keeps open until the response body has been written,
|
|
827
|
+
* so it also covers a streamed answer the client stops reading partway.
|
|
828
|
+
*/
|
|
829
|
+
const abortOnDisconnect = Effect.suspend(() => {
|
|
830
|
+
const controller = new AbortController();
|
|
831
|
+
return Effect.as(Effect.addFinalizer((exit) => Exit.isSuccess(exit) ? Effect.void : Effect.sync(() => controller.abort())), controller.signal);
|
|
832
|
+
});
|
|
833
|
+
/**
|
|
834
|
+
* The target a request asked for, as a URL, for reading its path and query. `url` may be
|
|
835
|
+
* origin-relative, so URL gets a base purely to parse it.
|
|
836
|
+
*
|
|
837
|
+
* `undefined` for a target URL cannot parse, such as `//[`, which Node still accepts. Throwing
|
|
838
|
+
* instead would fail the request as a defect, so the router's 404 for it would become a 500.
|
|
839
|
+
*/
|
|
840
|
+
const parseTarget = (url) => {
|
|
841
|
+
try {
|
|
842
|
+
return new URL(url, "http://localhost");
|
|
843
|
+
} catch {
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
};
|
|
847
|
+
/**
|
|
848
|
+
* The status a request is answered with, including when it failed instead of producing a response
|
|
849
|
+
* — asked of the failure the same way Effect's server asks when it answers, so a request no route
|
|
850
|
+
* matched reports its 404.
|
|
851
|
+
*/
|
|
852
|
+
const statusOf = (exit) => Exit.isSuccess(exit) ? Effect.succeed(exit.value.status) : Effect.map(HttpServerError.causeResponse(exit.cause), ([response]) => response.status);
|
|
853
|
+
/**
|
|
854
|
+
* The body, or `undefined` once it passes `maxSize`. Counted as it arrives, because a chunked
|
|
855
|
+
* request declares no length; reading stops at the limit instead of buffering the rest.
|
|
856
|
+
*/
|
|
857
|
+
async function readBytesWithin(request, maxSize) {
|
|
858
|
+
if (maxSize === void 0) return new Uint8Array(await request.arrayBuffer());
|
|
859
|
+
const reader = request.body?.getReader();
|
|
860
|
+
if (!reader) return /* @__PURE__ */ new Uint8Array();
|
|
861
|
+
const chunks = [];
|
|
862
|
+
let size = 0;
|
|
863
|
+
for (;;) {
|
|
864
|
+
const { done, value } = await reader.read();
|
|
865
|
+
if (done) break;
|
|
866
|
+
size += value.byteLength;
|
|
867
|
+
if (size > maxSize) {
|
|
868
|
+
reader.cancel().catch(() => {});
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
chunks.push(value);
|
|
872
|
+
}
|
|
873
|
+
const bytes = new Uint8Array(size);
|
|
874
|
+
let offset = 0;
|
|
875
|
+
for (const chunk of chunks) {
|
|
876
|
+
bytes.set(chunk, offset);
|
|
877
|
+
offset += chunk.byteLength;
|
|
878
|
+
}
|
|
879
|
+
return bytes;
|
|
880
|
+
}
|
|
881
|
+
/** Whether something already read the body of a Web `Request` or a Node `IncomingMessage`. */
|
|
882
|
+
const bodyWasRead = (source) => {
|
|
883
|
+
if (source instanceof Request) return source.bodyUsed;
|
|
884
|
+
return source?.readableDidRead === true;
|
|
885
|
+
};
|
|
886
|
+
/**
|
|
887
|
+
* Whether Effect kept a copy of the body when it was read.
|
|
888
|
+
*
|
|
889
|
+
* Checked before asking for that copy, because on Node, asking Effect for the bytes of a stream
|
|
890
|
+
* something else already drained waits forever. The fields are Effect's private caches, the same on
|
|
891
|
+
* the Node and the fetch implementations. Should a release rename them, this answers false and the
|
|
892
|
+
* regression tests for a body read by middleware fail — never a hang.
|
|
893
|
+
*/
|
|
894
|
+
const effectKeptBody = (serverRequest) => {
|
|
895
|
+
const caches = serverRequest;
|
|
896
|
+
return caches.arrayBufferEffect !== void 0 || caches.textEffect !== void 0;
|
|
897
|
+
};
|
|
898
|
+
/**
|
|
899
|
+
* The fields a custom route's permission check may read from the body, mirroring @mastra/hono: the
|
|
900
|
+
* members of a JSON object, or the entries of a form. `json` is the parsed JSON, for the request
|
|
901
|
+
* context. Anything unreadable contributes nothing — the route still receives the body and reports
|
|
902
|
+
* the problem itself.
|
|
903
|
+
*
|
|
904
|
+
* Reads a copy, leaving `request`'s own body for the route, and copies only a body it will read: an
|
|
905
|
+
* unread copy of a large upload would hold every byte of it until the request is gone.
|
|
906
|
+
*/
|
|
907
|
+
async function readBodyFields(request) {
|
|
908
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
909
|
+
try {
|
|
910
|
+
if (contentType.includes("application/json")) {
|
|
911
|
+
const parsed = await request.clone().json();
|
|
912
|
+
return {
|
|
913
|
+
json: parsed,
|
|
914
|
+
fields: typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : {}
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) return { fields: Object.fromEntries(await request.clone().formData()) };
|
|
918
|
+
} catch {}
|
|
919
|
+
return { fields: {} };
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* Creates an empty router to hand to `new MastraServer({ app })`.
|
|
923
|
+
*
|
|
924
|
+
* `ignoreDuplicateSlashes` is forced off because Effect's vendored FindMyWay defaults it on
|
|
925
|
+
* (FindMyWay/internal/router.ts:54), unlike stock find-my-way. Left on, `/api//agents` would serve
|
|
926
|
+
* `/api/agents` instead of 404ing, which diverges from every other Mastra adapter.
|
|
927
|
+
*/
|
|
928
|
+
const createRouter = (config) => Effect.runSync(Effect.provideService(HttpRouter.make, HttpRouter.RouterConfig, {
|
|
929
|
+
ignoreDuplicateSlashes: false,
|
|
930
|
+
...config
|
|
931
|
+
}));
|
|
932
|
+
/**
|
|
933
|
+
* Registers a route whose handler may fail with `MastraRouteError`.
|
|
934
|
+
*
|
|
935
|
+
* `HttpRouter.add` records a handler's error type as a requirement, so middleware can promise to
|
|
936
|
+
* handle it. Nothing needs to: unhandled, the error answers with its own response (it is
|
|
937
|
+
* `Respondable`). So the requirement is dropped here, which keeps registration runnable on its own.
|
|
938
|
+
*/
|
|
939
|
+
const addRoute = (app, method, path, handler) => app.add(method, path, handler);
|
|
940
|
+
/**
|
|
941
|
+
* The Effect span of the HTTP request a Mastra tool or workflow step is running for, when the app
|
|
942
|
+
* has a tracer installed. `undefined` otherwise, and for a workflow resumed by a later request.
|
|
943
|
+
*/
|
|
944
|
+
function requestSpan(requestContext) {
|
|
945
|
+
const getSpan = requestContext?.get(REQUEST_SPAN_KEY);
|
|
946
|
+
return typeof getSpan === "function" ? getSpan() : void 0;
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Runs an Effect from inside a Mastra tool or workflow step.
|
|
950
|
+
*
|
|
951
|
+
* Mastra runs tools and steps as plain promises, outside the Effect request that triggered them.
|
|
952
|
+
* This gives the Effect the services `runtime` provides, makes the HTTP request's span its parent,
|
|
953
|
+
* and interrupts it when Mastra's abort signal fires. Pass the context Mastra hands the tool or
|
|
954
|
+
* step — it carries both the request context and that signal:
|
|
955
|
+
*
|
|
956
|
+
* ```ts
|
|
957
|
+
* execute: (input, context) => runInRequest(runtime, Users.find(input.id), context)
|
|
958
|
+
* ```
|
|
959
|
+
*
|
|
960
|
+
* Two things follow from running through `runtime` rather than the request:
|
|
961
|
+
*
|
|
962
|
+
* - The signal is whatever Mastra hands over. For a tool an agent calls, it aborts when the client
|
|
963
|
+
* leaves. A workflow step's is the run's own: it aborts when the run is cancelled, not when the
|
|
964
|
+
* request that started it is abandoned.
|
|
965
|
+
* - The spans the Effect creates are made by `runtime`'s tracer. Give the runtime the app's tracer
|
|
966
|
+
* as well, or they have the right parent but are never exported.
|
|
967
|
+
*/
|
|
968
|
+
function runInRequest(runtime, effect, context = {}) {
|
|
969
|
+
const span = requestSpan(context.requestContext);
|
|
970
|
+
return runtime.runPromise(span ? Effect.withParentSpan(effect, span) : effect, { signal: context.abortSignal });
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* Turns a populated router into a fetch-compatible handler plus its teardown.
|
|
974
|
+
*
|
|
975
|
+
* Effect logs every request it answers; pass `{ disableLogger: true }` to stop that. `dispose`
|
|
976
|
+
* releases what the handler holds, not Mastra. For a tracer or other services, call
|
|
977
|
+
* `HttpRouter.toWebHandler` with your own layer merged in.
|
|
978
|
+
*/
|
|
979
|
+
const toWebHandler = (router, options) => HttpRouter.toWebHandler(Layer.succeed(HttpRouter.HttpRouter)(router), options);
|
|
980
|
+
/** Convenience wrapper: build a router, register every Mastra route onto it, return both. */
|
|
981
|
+
async function createMastraServer(options) {
|
|
982
|
+
const router = createRouter();
|
|
983
|
+
const adapter = new MastraServer({
|
|
984
|
+
...options,
|
|
985
|
+
app: router
|
|
986
|
+
});
|
|
987
|
+
await adapter.init();
|
|
988
|
+
return {
|
|
989
|
+
router,
|
|
990
|
+
adapter
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
//#endregion
|
|
994
|
+
export { MastraRouteError, MastraServer, createMastraServer, createRouter, requestSpan, runInRequest, toWebHandler };
|
|
995
|
+
|
|
996
|
+
//# sourceMappingURL=index.js.map
|