@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.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