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