Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
MASTRA_MODEL=neon/gpt-5-4-mini
MASTRA_STUDIO_TOKEN=replace-with-a-random-token
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: CI

on:
pull_request:
push:
branches:
- main

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Comment thread
andrelandgraf marked this conversation as resolved.
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
- run: bun install --frozen-lockfile
- run: bun run typecheck
- run: bun test
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
14 changes: 14 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
node_modules
coverage
dist
out
*.log
*.tsbuildinfo

.env
.env.*
!.env.example
.neon

.DS_Store
.idea
105 changes: 103 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,103 @@
# mastra-studio
Self-host Mastra Studio with Neon Functions, Postgres, Object Storage, and AI Gateway
# Mastra Studio on Neon

Self-host [Mastra Studio](https://mastra.ai/docs/getting-started/studio) with Neon.

- Neon Functions serves the Mastra API and proxies the Studio SPA.
- Neon Object Storage holds the static Studio assets.
- Neon AI Gateway runs the example assistant.
- Neon Postgres stores memory, logs, traces, and metrics.
- Mastra SimpleAuth protects Studio and every Mastra API route.

![Mastra Studio trace view](docs/mastra-studio.png)

## Architecture

```text
Browser ──▶ Neon Function ──▶ Object Storage (Studio assets)
├────▶ AI Gateway (model calls)
└────▶ Postgres
├─ public schema (memory)
└─ mastra_observability schema (logs, traces, metrics)
```

The Studio bucket is public-read because it contains only versioned static assets. The Function keeps the API behind authentication.

## Deploy

Prerequisites:

- [Bun](https://bun.sh/)
- [Neon CLI](https://neon.com/docs/reference/cli-reference)
- A Neon project on a plan that includes AI Gateway

Install dependencies and create the two deployment settings:

```bash
bun install
cp .env.example .env.deploy
```

Set `MASTRA_STUDIO_TOKEN` in `.env.deploy` to a random value, for example from `openssl rand -hex 32`.

Link the checkout to the target Neon project and branch:

```bash
neon link
neon checkout main
```

Provision Postgres, Object Storage, Functions, and AI Gateway:

```bash
env -u MASTRA_MODEL -u MASTRA_STUDIO_TOKEN neon deploy --env .env.deploy
neon env pull
```

Upload the installed Mastra Studio build:

```bash
bun run studio:upload
```

Open the Function URL printed by `neon deploy`, then sign in with:

```text
Email: admin@example.com
Password: the MASTRA_STUDIO_TOKEN value
```

## Local development

```bash
set -a
source .env.deploy
set +a
neon dev
```

Studio runs at `http://localhost:8787`.

## Included example

The Studio exposes:

- one memory-enabled assistant
- `calculate`, `get-current-time`, and `get-database-time` tools
- persisted logs, full trace trees, and automatic model/agent metrics

## Verify

```bash
bun run typecheck
bun test
curl http://localhost:8787/health
```

Unauthenticated requests to `/api/*` return `401`.

## Production boundaries

- SimpleAuth is a shared-token example, not multi-user authentication.
- Observability uses a separate schema in the application database, not a separate database. Each live Function isolate can open up to five Postgres connections.
- Traces can include prompts and model output. Configure redaction and retention for your data policy; this example does not add a retention job.
- The Mastra packages are pinned alpha releases because the observability APIs used here are not yet stable.
1,433 changes: 1,433 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

Binary file added docs/mastra-studio.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions neon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { defineConfig } from '@neon/config/v1';

export default defineConfig({
preview: {
aiGateway: true,
buckets: {
'mastra-studio': { access: 'public_read' },
},
functions: {
studio: {
name: 'Mastra Studio',
source: 'src/index.ts',
env: {
MASTRA_MODEL: process.env.MASTRA_MODEL ?? '',
MASTRA_STUDIO_TOKEN: process.env.MASTRA_STUDIO_TOKEN ?? '',
},
},
},
},
});
28 changes: 28 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"name": "mastra-studio-on-neon",
"type": "module",
"private": true,
"packageManager": "bun@1.3.14",
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run",
"studio:upload": "bun run scripts/upload-studio.ts"
},
"devDependencies": {
"@aws-sdk/client-s3": "3.1101.0",
"@types/bun": "1.3.14",
"mastra": "1.22.0-alpha.5",
"typescript": "5.9.3",
"vitest": "4.1.10"
},
"dependencies": {
"@mastra/core": "1.56.0-alpha.5",
"@mastra/hono": "1.5.13-alpha.5",
"@mastra/memory": "1.25.0-alpha.1",
"@mastra/observability": "1.16.4-alpha.2",
"@mastra/pg": "1.19.0-alpha.2",
"@neon/config": "1.0.0",
"hono": "4.12.33",
"zod": "4.4.3"
}
}
58 changes: 58 additions & 0 deletions scripts/upload-studio.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { readdir } from 'node:fs/promises';
import { join, relative } from 'node:path';
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { configureStudioHtml, STUDIO_BUCKET } from '../src/studio-assets';
import { requireEnv } from '../src/env';

const studioDirectory = join(import.meta.dir, '..', 'node_modules', 'mastra', 'dist', 'studio');

async function listFiles(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true });
const nested = await Promise.all(
entries.map((entry) => {
const path = join(directory, entry.name);
return entry.isDirectory() ? listFiles(path) : Promise.resolve([path]);
}),
);
return nested.flat().sort();
}

function cacheControl(key: string): string {
if (key === 'index.html') {
return 'no-cache';
}
return key.startsWith('assets/') ? 'public, max-age=31536000, immutable' : 'public, max-age=3600';
}

const client = new S3Client({
endpoint: requireEnv('AWS_ENDPOINT_URL_S3'),
region: requireEnv('AWS_REGION'),
credentials: {
accessKeyId: requireEnv('AWS_ACCESS_KEY_ID'),
secretAccessKey: requireEnv('AWS_SECRET_ACCESS_KEY'),
},
forcePathStyle: true,
requestChecksumCalculation: 'WHEN_REQUIRED',
});

const files = await listFiles(studioDirectory);
for (const path of files) {
const file = Bun.file(path);
const key = relative(studioDirectory, path).split('\\').join('/');
const body =
key === 'index.html'
? new TextEncoder().encode(configureStudioHtml(await file.text()))
: await file.bytes();

await client.send(
new PutObjectCommand({
Bucket: STUDIO_BUCKET,
Key: key,
Body: body,
CacheControl: cacheControl(key),
ContentType: file.type || 'application/octet-stream',
}),
);
}

console.log(`Uploaded ${files.length} Mastra Studio assets.`);
7 changes: 7 additions & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
115 changes: 115 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { Hono } from 'hono';
import { MastraServer, type HonoBindings, type HonoVariables } from '@mastra/hono';
import { requireEnv } from './env';
import { mastra } from './mastra';
import { publicObjectUrl, resolveStudioAsset } from './studio-assets';

const app = new Hono<{ Bindings: HonoBindings; Variables: HonoVariables }>();
const storageEndpoint = requireEnv('AWS_ENDPOINT_URL_S3');

app.get('/health', (context) => context.json({ status: 'ok', revision: 3 }));
app.get('/refresh-events', (context) => {
const encoder = new TextEncoder();
let heartbeat: ReturnType<typeof setInterval> | undefined;
let closed = false;

const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(': connected\n\n'));
heartbeat = setInterval(() => {
controller.enqueue(encoder.encode(': heartbeat\n\n'));
}, 30_000);

context.req.raw.signal.addEventListener(
'abort',
() => {
if (heartbeat) clearInterval(heartbeat);
if (!closed) {
closed = true;
controller.close();
}
},
{ once: true },
);
},
cancel() {
if (heartbeat) clearInterval(heartbeat);
closed = true;
},
});

return new Response(body, {
headers: {
'cache-control': 'no-cache',
'content-type': 'text/event-stream',
'x-refresh-mode': 'stream',
},
});
});

const server = new MastraServer({ app, mastra });
await server.init();

mastra.loggerVNext.info('Mastra Studio ready', {
revision: 3,
runtime: 'neon-functions',
});
await mastra.observability.flush();

process.once('SIGINT', () => {
void (async () => {
try {
await mastra.observability.flush();
await mastra.shutdown();
process.exit(0);
} catch (error) {
console.error('Mastra Studio shutdown failed', error);
process.exit(1);
}
})();
});

app.all('/api', (context) => context.json({ error: 'Not found' }, 404));
app.all('/api/*', (context) => context.json({ error: 'Not found' }, 404));

app.all('*', async (context) => {
if (context.req.method !== 'GET' && context.req.method !== 'HEAD') {
return context.text('Method not allowed', 405);
}

const asset = resolveStudioAsset(context.req.path, context.req.header('accept'));
if (asset.type === 'invalid') {
return context.text('Invalid path', 400);
}
if (asset.type === 'not-found') {
return context.text('Not found', 404);
}

const key = asset.type === 'index' ? 'index.html' : asset.key;
const upstream = await fetch(publicObjectUrl(storageEndpoint, key), {
method: context.req.method,
});

if (!upstream.ok) {
if (upstream.status === 404 && asset.type === 'asset') {
return context.text('Not found', 404);
}
return context.text('Studio assets unavailable', 502);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- candidate files ---'
git ls-files | sed -n '1,120p'

printf '%s\n' '--- runtime and dependency declarations ---'
rg -n --hidden -S \
  '("engines"|"runtime"|"bun"|"`@hono`|hono|AbortSignal|Neon|fetch\()' \
  package.json bunfig.toml wrangler.toml tsconfig.json src 2>/dev/null || true

printf '%s\n' '--- src/index.ts structure and relevant code ---'
if command -v ast-grep >/dev/null 2>&1; then
  ast-grep outline src/index.ts
fi
cat -n src/index.ts | sed -n '1,150p'

printf '%s\n' '--- request context and route usage ---'
rg -n -S \
  'context\.req|req\.raw|raw\.signal|publicObjectUrl|storageEndpoint|fetch\(' \
  src package.json README.md 2>/dev/null || true

Repository: neon-solutions/mastra-studio

Length of output: 6200


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- package and deployment metadata ---'
cat -n package.json
printf '%s\n' '--- README runtime/deployment references ---'
rg -n -C 3 -i 'bun|runtime|neon function|deploy|timeout|fetch|object storage|S3' README.md neon.ts .github/workflows/ci.yml bun.lock tsconfig.json
printf '%s\n' '--- lockfile runtime/package metadata ---'
head -80 bun.lock
printf '%s\n' '--- environment and deployment scripts ---'
cat -n neon.ts
cat -n scripts/upload-studio.ts | sed -n '1,220p'

Repository: neon-solutions/mastra-studio

Length of output: 50386


🌐 Web query:

Bun 1.3.14 AbortSignal.any AbortSignal.timeout support documentation

💡 Result:

In Bun v1.3.14, both AbortSignal.any and AbortSignal.timeout are supported as part of its standard Web API implementation [1][2][3][4]. AbortSignal.any This static method creates a new AbortSignal that aborts when any of the provided input signals are aborted [1][5]. It was introduced in Bun v1.1.4 [6]. Subsequent maintenance, including bug fixes for event propagation [7] and garbage collection reachability [8], has ensured its stability in later versions, including the current v1.3.14 release. AbortSignal.timeout This static method returns an AbortSignal that automatically aborts after a specified number of milliseconds [9]. While it is a supported feature in Bun [3][4], users may occasionally encounter TypeScript definition issues where the property is not recognized by the compiler [10]. As of Bun's recent updates, such issues are addressed, and ensuring that your project's TypeScript configuration includes the necessary DOM types (e.g., adding "DOM" to your lib array in tsconfig.json) typically resolves compilation errors [10]. Both methods follow the standard Web API specifications [11][9][5]. You can access their official documentation at the Bun website's globals reference section [1][3].

Citations:


🌐 Web query:

Neon Functions runtime Bun request abort signal client disconnect fetch timeout documentation

💡 Result:

Neon Functions currently run on the Node.js runtime (specifically Node.js 24) and do not support the Bun runtime [1][2][3]. While you may be interested in Bun's fetch capabilities—such as AbortSignal.timeout, per-request connectTimeout, and socketTimeout [4][5][6]—these features are specific to the Bun runtime environment and are not applicable to Neon Functions [1]. In the context of Neon Functions: 1. Runtime Environment: Neon Functions are designed to run JavaScript or TypeScript on Node.js [1]. The runtime platform enforces limits such as a 15-minute time-to-first-byte limit [7]. 2. Connection Management: Since Neon Functions run on Node.js, you should manage request timeouts and client disconnects using standard Node.js patterns (e.g., handling the abort event on an AbortSignal or monitoring connection state) rather than relying on Bun-specific APIs [1]. 3. Client Disconnects: Neon Functions are long-running and support streaming; however, if the underlying platform stops a function (e.g., due to inactivity), it sends a SIGINT signal, which your code should handle to close connections and clean up resources [7]. For projects requiring the specific fetch and abort features you referenced, you would need to use an environment that supports the Bun runtime [4][5]. Neon Functions remain strictly Node.js-based during their current beta phase [1].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- concise deployment metadata ---'
sed -n '1,220p' neon.ts
printf '%s\n' '--- concise package and CI runtime pins ---'
sed -n '1,45p' package.json
sed -n '1,40p' .github/workflows/ci.yml
printf '%s\n' '--- all abort and timeout references ---'
rg -n -S 'AbortSignal|timeout|signal|fetch\(' --glob '!bun.lock' .

Repository: neon-solutions/mastra-studio

Length of output: 1975


Bound the Object Storage request.

fetch() can remain pending while Object Storage stalls, and it does not use context.req.raw.signal. Add a finite timeout, combine it with the client abort signal, and return 504 for timeouts or 502 for other fetch failures. Neon Functions use Node.js 24, not the Bun version used for local tooling.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/index.ts` around lines 89 - 98, Update the Object Storage fetch in the
upstream asset request flow to use a finite timeout combined with
context.req.raw.signal, using Node.js 24-compatible APIs rather than
Bun-specific tooling. Catch fetch failures, return 504 when the timeout aborts
the request, and return 502 for other failures; preserve the existing upstream
status handling for successful responses.


const headers = new Headers();
for (const name of ['cache-control', 'content-length', 'content-type', 'etag', 'last-modified']) {
const value = upstream.headers.get(name);
if (value) {
headers.set(name, value);
}
}
headers.set('x-content-type-options', 'nosniff');

return new Response(context.req.method === 'HEAD' ? null : upstream.body, {
status: upstream.status,
headers,
});
});

export default app;
Loading
Loading