forked from koshikraj/ottopus
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp.ts
More file actions
87 lines (79 loc) · 2.91 KB
/
Copy pathapp.ts
File metadata and controls
87 lines (79 loc) · 2.91 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
import { Hono } from 'hono'
import { config } from './config.js'
import { apiApp } from './routes/api.js'
import { mcpApp, wellKnownApp } from './routes/mcp.js'
/**
* One process, two surfaces, one core underneath.
*
* In production each surface gets its own hostname — mcp.ottopus.xyz and
* api.ottopus.xyz — both pointing at this same service. Locally there are no
* subdomains, so the same surfaces are also mounted at /mcp and /api. Both
* forms stay valid in both places, which keeps a URL copied from a log or a
* doc working wherever it is pasted.
*/
export const rootApp = new Hono()
rootApp.get('/health', (c) =>
c.json({
ok: true,
service: 'ottopus',
env: config.nodeEnv,
commit: config.gitCommit,
}),
)
/**
* Protected-resource metadata is anchored to the origin, not to the MCP mount:
* at localhost:8787/mcp the document belongs at the service root, under
* /.well-known/oauth-protected-resource/mcp. On the subdomain the two are the
* same place and this is simply a second route to the same handler.
*/
rootApp.route('/', wellKnownApp)
rootApp.route('/mcp', mcpApp)
rootApp.route('/api', apiApp)
/**
* Leftmost label of the request host, e.g. "mcp" for mcp.ottopus.xyz.
*
* Reads the Host header rather than parsing req.url — this runs on every
* request, and it avoids allocating a URL object per request.
*
* Any proxy in front must preserve the Host header. If one rewrites it, this
* returns something unrecognised and the request falls through to path-based
* routing, which still works — the surfaces degrade rather than disappear.
*/
function subdomain(req: Request): string {
const host = req.headers.get('host')
if (!host) return ''
const end = host.indexOf('.')
const label = end < 0 ? host : host.slice(0, end)
// Strip a port when the host has no dots, e.g. "localhost:8787".
const colon = label.indexOf(':')
return (colon < 0 ? label : label.slice(0, colon)).toLowerCase()
}
/**
* On a surface's own hostname the surface answers at the root, and also under
* its path prefix. Both forms resolve everywhere, so a URL copied from local
* development still works when pasted against the deployed host — which matters
* most for the MCP URL, the one address people copy by hand.
*/
function hostApp(surface: Hono, prefix: string): Hono {
const app = new Hono()
app.route('/', surface)
app.route(prefix, surface)
return app
}
const mcpHost = hostApp(mcpApp, '/mcp')
const apiHost = hostApp(apiApp, '/api')
/**
* Dispatch on hostname first, falling through to path-based routing. Railway's
* own health check hits the internal domain, which matches neither subdomain —
* so the root app must always answer /health.
*/
export function handler(req: Request): Response | Promise<Response> {
switch (subdomain(req)) {
case 'mcp':
return mcpHost.fetch(req)
case 'api':
return apiHost.fetch(req)
default:
return rootApp.fetch(req)
}
}