-
Notifications
You must be signed in to change notification settings - Fork 0
Add a self-hosted Mastra Studio example #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
717f27d
28280a0
5c8520d
409c86a
4dec253
ab72e6e
dd77205
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| 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 | ||
| - uses: oven-sh/setup-bun@v2 | ||
| with: | ||
| bun-version: 1.3.14 | ||
| - run: bun install --frozen-lockfile | ||
| - run: bun run typecheck | ||
| - run: bun test | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| 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 |
| 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. | ||
|
|
||
|  | ||
|
|
||
| ## 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. |
Large diffs are not rendered by default.
| 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 ?? '', | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| }); |
| 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" | ||
| } | ||
| } |
| 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.`); |
| 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; | ||
| } |
| 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); | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 || trueRepository: 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:
💡 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:
💡 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 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.
🤖 Prompt for AI Agents |
||
|
|
||
| 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; | ||
Uh oh!
There was an error while loading. Please reload this page.