@pikku/bun-server 0.12.10 → 0.12.12
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/CHANGELOG.md +64 -0
- package/dist/pikku-bun-server.d.ts +9 -1
- package/dist/pikku-bun-server.js +24 -7
- package/dist/pikku-bun-server.js.map +1 -1
- package/package.json +12 -8
- package/run-tests.sh +35 -21
- package/src/bun-event-hub-service.test.ts +0 -77
- package/src/bun-event-hub-service.ts +0 -54
- package/src/index.ts +0 -2
- package/src/local-content.test.ts +0 -193
- package/src/pikku-bun-server.test.ts +0 -62
- package/src/pikku-bun-server.ts +0 -454
- package/src/static-mount.test.ts +0 -330
- package/tsconfig.json +0 -18
- package/tsconfig.tsbuildinfo +0 -1
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
import { describe, test, before, after } from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
import {
|
|
4
|
-
mkdtempSync,
|
|
5
|
-
mkdirSync,
|
|
6
|
-
writeFileSync,
|
|
7
|
-
readFileSync,
|
|
8
|
-
rmSync,
|
|
9
|
-
} from 'node:fs'
|
|
10
|
-
import { tmpdir } from 'node:os'
|
|
11
|
-
import { join } from 'node:path'
|
|
12
|
-
import { setSingletonServices } from '@pikku/core/state'
|
|
13
|
-
import { resetPikkuState } from '@pikku/core/state'
|
|
14
|
-
import { LocalContent } from '@pikku/core/services/local-content'
|
|
15
|
-
import type { JWTService, Logger } from '@pikku/core/services'
|
|
16
|
-
import { PikkuBunServer } from './pikku-bun-server.js'
|
|
17
|
-
|
|
18
|
-
const noopLogger = {
|
|
19
|
-
info: () => {},
|
|
20
|
-
error: () => {},
|
|
21
|
-
warn: () => {},
|
|
22
|
-
debug: () => {},
|
|
23
|
-
trace: () => {},
|
|
24
|
-
setLevel: () => {},
|
|
25
|
-
} as unknown as Logger
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Signs and verifies with a plain base64 round-trip. The point of these tests is
|
|
29
|
-
* that the server checks the SAME claims the signer wrote, not that any
|
|
30
|
-
* particular algorithm is used — a real JWT here would only slow them down.
|
|
31
|
-
*/
|
|
32
|
-
const fakeJWT = {
|
|
33
|
-
encode: async (_expiry: string, payload: unknown) =>
|
|
34
|
-
Buffer.from(JSON.stringify(payload)).toString('base64url'),
|
|
35
|
-
decode: async (token: string) =>
|
|
36
|
-
JSON.parse(Buffer.from(token, 'base64url').toString()),
|
|
37
|
-
} as unknown as JWTService
|
|
38
|
-
|
|
39
|
-
describe('PikkuBunServer local content', () => {
|
|
40
|
-
let server: PikkuBunServer
|
|
41
|
-
let content: LocalContent
|
|
42
|
-
let tmpDir: string
|
|
43
|
-
let origin: string
|
|
44
|
-
|
|
45
|
-
before(async () => {
|
|
46
|
-
resetPikkuState()
|
|
47
|
-
setSingletonServices({
|
|
48
|
-
logger: noopLogger,
|
|
49
|
-
schema: {
|
|
50
|
-
compileSchema: () => {},
|
|
51
|
-
getSchemaNames: () => new Set<string>(),
|
|
52
|
-
},
|
|
53
|
-
} as any)
|
|
54
|
-
tmpDir = mkdtempSync(join(tmpdir(), 'pikku-bun-content-'))
|
|
55
|
-
mkdirSync(join(tmpDir, 'bucket'), { recursive: true })
|
|
56
|
-
writeFileSync(join(tmpDir, 'bucket', 'existing.bin'), 'already here')
|
|
57
|
-
|
|
58
|
-
const contentConfig = {
|
|
59
|
-
localFileUploadPath: tmpDir,
|
|
60
|
-
uploadUrlPrefix: '/upload',
|
|
61
|
-
assetUrlPrefix: '/assets',
|
|
62
|
-
sizeLimit: '1mb',
|
|
63
|
-
}
|
|
64
|
-
// The service under test on the client side: the URLs it hands out are
|
|
65
|
-
// exactly what the server must answer, so they are not hand-written here.
|
|
66
|
-
content = new LocalContent(contentConfig, noopLogger, fakeJWT)
|
|
67
|
-
|
|
68
|
-
server = new PikkuBunServer(
|
|
69
|
-
{
|
|
70
|
-
port: 0,
|
|
71
|
-
hostname: '127.0.0.1',
|
|
72
|
-
content: contentConfig,
|
|
73
|
-
} as any,
|
|
74
|
-
noopLogger,
|
|
75
|
-
{ contentSigningJWT: fakeJWT }
|
|
76
|
-
)
|
|
77
|
-
await server.init()
|
|
78
|
-
await server.start()
|
|
79
|
-
origin = `http://127.0.0.1:${server.port}`
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
after(async () => {
|
|
83
|
-
await server.stop()
|
|
84
|
-
rmSync(tmpDir, { recursive: true, force: true })
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
test('answers the upload URL the content service hands out', async () => {
|
|
88
|
-
const { uploadUrl } = await content.getUploadURL({
|
|
89
|
-
bucket: 'bucket',
|
|
90
|
-
fileKey: 'nested/file.bin',
|
|
91
|
-
contentType: 'application/octet-stream',
|
|
92
|
-
} as any)
|
|
93
|
-
|
|
94
|
-
const response = await fetch(`${origin}${uploadUrl}`, {
|
|
95
|
-
method: 'PUT',
|
|
96
|
-
body: 'uploaded bytes',
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
assert.equal(response.status, 200)
|
|
100
|
-
assert.equal(
|
|
101
|
-
readFileSync(join(tmpDir, 'bucket', 'nested', 'file.bin'), 'utf8'),
|
|
102
|
-
'uploaded bytes'
|
|
103
|
-
)
|
|
104
|
-
})
|
|
105
|
-
|
|
106
|
-
test('serves an asset through the signed URL the content service produces', async () => {
|
|
107
|
-
const signed = await content.signContentKey({
|
|
108
|
-
bucket: 'bucket',
|
|
109
|
-
contentKey: 'existing.bin',
|
|
110
|
-
dateLessThan: new Date(Date.now() + 60_000),
|
|
111
|
-
} as any)
|
|
112
|
-
|
|
113
|
-
const response = await fetch(`${origin}${signed}`)
|
|
114
|
-
assert.equal(response.status, 200)
|
|
115
|
-
assert.equal(await response.text(), 'already here')
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
test('refuses an unsigned asset read', async () => {
|
|
119
|
-
const response = await fetch(`${origin}/assets/bucket/existing.bin`)
|
|
120
|
-
assert.equal(response.status, 403)
|
|
121
|
-
})
|
|
122
|
-
|
|
123
|
-
test('refuses a signature minted for a different asset', async () => {
|
|
124
|
-
// The signature is valid and unexpired — only the path differs. Without the
|
|
125
|
-
// path claim being checked, one signed URL would read the whole bucket.
|
|
126
|
-
const signed = await content.signContentKey({
|
|
127
|
-
bucket: 'bucket',
|
|
128
|
-
contentKey: 'existing.bin',
|
|
129
|
-
dateLessThan: new Date(Date.now() + 60_000),
|
|
130
|
-
} as any)
|
|
131
|
-
writeFileSync(join(tmpDir, 'bucket', 'other.bin'), 'other')
|
|
132
|
-
|
|
133
|
-
const query = signed.slice(signed.indexOf('?'))
|
|
134
|
-
const response = await fetch(`${origin}/assets/bucket/other.bin${query}`)
|
|
135
|
-
assert.equal(response.status, 403)
|
|
136
|
-
})
|
|
137
|
-
|
|
138
|
-
test('refuses an expired signature', async () => {
|
|
139
|
-
const signed = await content.signContentKey({
|
|
140
|
-
bucket: 'bucket',
|
|
141
|
-
contentKey: 'existing.bin',
|
|
142
|
-
dateLessThan: new Date(Date.now() - 1_000),
|
|
143
|
-
} as any)
|
|
144
|
-
|
|
145
|
-
const response = await fetch(`${origin}${signed}`)
|
|
146
|
-
assert.equal(response.status, 403)
|
|
147
|
-
})
|
|
148
|
-
|
|
149
|
-
// `fetch` normalises `..` out of the path before the request leaves the
|
|
150
|
-
// client, so this cannot drive the guard itself — that is covered directly in
|
|
151
|
-
// core's local-content-request-handler.test.ts. What it does prove is that
|
|
152
|
-
// nothing in the server path un-normalises it back into a write.
|
|
153
|
-
test('does not write outside the content directory', async () => {
|
|
154
|
-
writeFileSync(join(tmpDir, '..', 'pikku-bun-content-secret.txt'), 'secret')
|
|
155
|
-
try {
|
|
156
|
-
const response = await fetch(
|
|
157
|
-
`${origin}/upload/%2e%2e/pikku-bun-content-secret.txt`,
|
|
158
|
-
{ method: 'PUT', body: 'overwritten' }
|
|
159
|
-
)
|
|
160
|
-
assert.notEqual(response.status, 200)
|
|
161
|
-
assert.equal(
|
|
162
|
-
readFileSync(
|
|
163
|
-
join(tmpDir, '..', 'pikku-bun-content-secret.txt'),
|
|
164
|
-
'utf8'
|
|
165
|
-
),
|
|
166
|
-
'secret'
|
|
167
|
-
)
|
|
168
|
-
} finally {
|
|
169
|
-
rmSync(join(tmpDir, '..', 'pikku-bun-content-secret.txt'), {
|
|
170
|
-
force: true,
|
|
171
|
-
})
|
|
172
|
-
}
|
|
173
|
-
})
|
|
174
|
-
|
|
175
|
-
test('rejects an upload over the size limit', async () => {
|
|
176
|
-
const { uploadUrl } = await content.getUploadURL({
|
|
177
|
-
bucket: 'bucket',
|
|
178
|
-
fileKey: 'too-big.bin',
|
|
179
|
-
contentType: 'application/octet-stream',
|
|
180
|
-
} as any)
|
|
181
|
-
|
|
182
|
-
const response = await fetch(`${origin}${uploadUrl}`, {
|
|
183
|
-
method: 'PUT',
|
|
184
|
-
body: Buffer.alloc(1024 * 1024 + 1),
|
|
185
|
-
})
|
|
186
|
-
assert.equal(response.status, 413)
|
|
187
|
-
})
|
|
188
|
-
|
|
189
|
-
test('leaves non-content paths to the router', async () => {
|
|
190
|
-
const response = await fetch(`${origin}/uploadsomething`)
|
|
191
|
-
assert.equal(response.status, 404)
|
|
192
|
-
})
|
|
193
|
-
})
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import { describe, test, before, after } from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
import { setSingletonServices } from '@pikku/core/state'
|
|
4
|
-
import { resetPikkuState } from '@pikku/core/state'
|
|
5
|
-
import type { Logger } from '@pikku/core/services'
|
|
6
|
-
import { PikkuBunServer } from './pikku-bun-server.js'
|
|
7
|
-
|
|
8
|
-
const PORT = 47817
|
|
9
|
-
const HEALTH = '/__health'
|
|
10
|
-
|
|
11
|
-
const noopLogger = {
|
|
12
|
-
info: () => {},
|
|
13
|
-
error: () => {},
|
|
14
|
-
warn: () => {},
|
|
15
|
-
debug: () => {},
|
|
16
|
-
trace: () => {},
|
|
17
|
-
setLevel: () => {},
|
|
18
|
-
} as unknown as Logger
|
|
19
|
-
|
|
20
|
-
describe('PikkuBunServer', () => {
|
|
21
|
-
let server: PikkuBunServer
|
|
22
|
-
|
|
23
|
-
before(async () => {
|
|
24
|
-
setSingletonServices({
|
|
25
|
-
logger: noopLogger,
|
|
26
|
-
schema: {
|
|
27
|
-
compileSchema: () => {},
|
|
28
|
-
getSchemaNames: () => new Set<string>(),
|
|
29
|
-
},
|
|
30
|
-
} as any)
|
|
31
|
-
server = new PikkuBunServer(
|
|
32
|
-
{ port: PORT, hostname: 'localhost', healthCheckPath: HEALTH },
|
|
33
|
-
noopLogger
|
|
34
|
-
)
|
|
35
|
-
await server.init()
|
|
36
|
-
await server.start()
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
after(async () => {
|
|
40
|
-
await server.stop()
|
|
41
|
-
resetPikkuState()
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
test('serves the configured health-check path', async () => {
|
|
45
|
-
const res = await fetch(`http://localhost:${PORT}${HEALTH}`)
|
|
46
|
-
assert.equal(res.status, 200)
|
|
47
|
-
assert.equal(res.headers.get('content-type'), 'application/json')
|
|
48
|
-
assert.deepEqual(await res.json(), { ok: true })
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
test('returns 404 for unregistered routes', async () => {
|
|
52
|
-
const res = await fetch(`http://localhost:${PORT}/nothing/here`)
|
|
53
|
-
assert.equal(res.status, 404)
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
test('stop() is idempotent', async () => {
|
|
57
|
-
const extra = new PikkuBunServer({ port: PORT + 1 }, noopLogger)
|
|
58
|
-
await extra.start()
|
|
59
|
-
await extra.stop()
|
|
60
|
-
await assert.doesNotReject(extra.stop())
|
|
61
|
-
})
|
|
62
|
-
})
|
package/src/pikku-bun-server.ts
DELETED
|
@@ -1,454 +0,0 @@
|
|
|
1
|
-
import type { Server as BunServer, ServerWebSocket } from 'bun'
|
|
2
|
-
|
|
3
|
-
import type { CoreConfig } from '@pikku/core/types'
|
|
4
|
-
import { stopSingletonServices } from '@pikku/core/utils'
|
|
5
|
-
import type { JWTService, Logger } from '@pikku/core/services'
|
|
6
|
-
import { pikkuState } from '@pikku/core/state'
|
|
7
|
-
import type { LocalContentConfig } from '@pikku/core/services/local-content'
|
|
8
|
-
import {
|
|
9
|
-
createLocalContentRequestHandler,
|
|
10
|
-
type LocalContentRequestHandler,
|
|
11
|
-
} from '@pikku/core/services/local-content-request-handler'
|
|
12
|
-
import {
|
|
13
|
-
fetchData,
|
|
14
|
-
PikkuFetchHTTPRequest,
|
|
15
|
-
PikkuFetchHTTPResponse,
|
|
16
|
-
} from '@pikku/core/http'
|
|
17
|
-
import {
|
|
18
|
-
logRoutes as logRegisterRoutes,
|
|
19
|
-
type RunHTTPWiringOptions,
|
|
20
|
-
} from '@pikku/core/http'
|
|
21
|
-
import { logChannels } from '@pikku/core/channel'
|
|
22
|
-
import type { PikkuLocalChannelHandler } from '@pikku/core/channel/local'
|
|
23
|
-
import { runLocalChannel } from '@pikku/core/channel/local'
|
|
24
|
-
import { compileAllSchemas } from '@pikku/core/schema'
|
|
25
|
-
|
|
26
|
-
import { resolve } from 'node:path'
|
|
27
|
-
|
|
28
|
-
import { BunEventHubService } from './bun-event-hub-service.js'
|
|
29
|
-
|
|
30
|
-
export type StaticMount = {
|
|
31
|
-
urlPrefix: string
|
|
32
|
-
directory: string
|
|
33
|
-
spaFallback?: boolean
|
|
34
|
-
/**
|
|
35
|
-
* Serve from an explicit key → path map instead of from `directory`, which is
|
|
36
|
-
* then unused. Keys are mount-relative request paths (`index.html`,
|
|
37
|
-
* `assets/app-a1b2c3.js`).
|
|
38
|
-
*
|
|
39
|
-
* This exists for assets that are not laid out as a directory at all: a
|
|
40
|
-
* `bun build --compile` binary embeds each file and hands back an opaque
|
|
41
|
-
* `/$bunfs/…` path, so the only way to find one is to have recorded it at
|
|
42
|
-
* build time.
|
|
43
|
-
*/
|
|
44
|
-
assets?: Record<string, string>
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export type BunServerConfig = CoreConfig & {
|
|
48
|
-
port: number
|
|
49
|
-
hostname?: string
|
|
50
|
-
staticMounts?: StaticMount[]
|
|
51
|
-
healthCheckPath?: string
|
|
52
|
-
/**
|
|
53
|
-
* Serve the `LocalContent` upload and asset prefixes. Pass the same config the
|
|
54
|
-
* `LocalContent` service was built from — it is that service which hands the
|
|
55
|
-
* browser these URLs, and a prefix that disagrees produces a 404 naming
|
|
56
|
-
* nothing. Mirrors `PikkuNodeHTTPServerConfig.content`.
|
|
57
|
-
*/
|
|
58
|
-
content?: LocalContentConfig
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export type PikkuBunServerOptions = RunHTTPWiringOptions & {
|
|
62
|
-
/**
|
|
63
|
-
* Event hub backing channel pub/sub. Inject the SAME instance passed to
|
|
64
|
-
* `createSingletonServices` so functions and the WebSocket transport share
|
|
65
|
-
* one hub — otherwise a function's `eventHub.publish(...)` goes to a
|
|
66
|
-
* different hub than the one holding the live sockets and never reaches
|
|
67
|
-
* connected clients. Defaults to a fresh `BunEventHubService`.
|
|
68
|
-
*/
|
|
69
|
-
eventHub?: BunEventHubService
|
|
70
|
-
/**
|
|
71
|
-
* Parsed content of `.pikku/mcp/mcp.gen.json`. When provided and non-empty,
|
|
72
|
-
* `@pikku/modelcontextprotocol` is dynamically imported and the MCP server is
|
|
73
|
-
* mounted at `mcpPath` via the SDK's fetch-native (Web Standard) transport.
|
|
74
|
-
* Import the JSON statically so bundlers (esbuild) inline it — no file read.
|
|
75
|
-
* Mirrors `PikkuNodeHTTPServerOptions.mcpJson`.
|
|
76
|
-
*/
|
|
77
|
-
mcpJson?: { tools?: unknown[]; resources?: unknown[]; prompts?: unknown[] }
|
|
78
|
-
/**
|
|
79
|
-
* Path the MCP server is mounted at when `mcpJson` is provided. Default `/mcp`.
|
|
80
|
-
*/
|
|
81
|
-
mcpPath?: string
|
|
82
|
-
/**
|
|
83
|
-
* The JWT service `LocalContent` signs asset URLs with. Required to serve
|
|
84
|
-
* `config.content`'s asset prefix: without it every signed read is refused,
|
|
85
|
-
* since an unverifiable signature is no signature. Falls back to
|
|
86
|
-
* `singletonServices.jwt`. Mirrors
|
|
87
|
-
* `PikkuNodeHTTPServerOptions.contentSigningJWT`.
|
|
88
|
-
*/
|
|
89
|
-
contentSigningJWT?: JWTService
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
type WsData = { channelHandler: PikkuLocalChannelHandler }
|
|
93
|
-
|
|
94
|
-
const isSerializable = (data: unknown): boolean =>
|
|
95
|
-
!(
|
|
96
|
-
typeof data === 'string' ||
|
|
97
|
-
data instanceof ArrayBuffer ||
|
|
98
|
-
data instanceof Uint8Array ||
|
|
99
|
-
data instanceof Int8Array ||
|
|
100
|
-
data instanceof Uint16Array ||
|
|
101
|
-
data instanceof Int16Array ||
|
|
102
|
-
data instanceof Uint32Array ||
|
|
103
|
-
data instanceof Int32Array ||
|
|
104
|
-
data instanceof Float32Array ||
|
|
105
|
-
data instanceof Float64Array
|
|
106
|
-
)
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Bun-native Pikku server built on Bun.serve.
|
|
110
|
-
*
|
|
111
|
-
* Handles HTTP via the fetch handler and WebSocket via Bun.serve's native
|
|
112
|
-
* websocket handler (which is backed by uWebSockets internally).
|
|
113
|
-
*/
|
|
114
|
-
export class PikkuBunServer {
|
|
115
|
-
private server: BunServer<WsData> | null = null
|
|
116
|
-
private readonly eventHub: BunEventHubService
|
|
117
|
-
private readonly options: RunHTTPWiringOptions
|
|
118
|
-
private readonly mcpJson?: PikkuBunServerOptions['mcpJson']
|
|
119
|
-
private readonly mcpPath: string
|
|
120
|
-
private mcpHandler?: (request: Request) => Promise<Response>
|
|
121
|
-
private readonly localContent?: LocalContentRequestHandler
|
|
122
|
-
|
|
123
|
-
constructor(
|
|
124
|
-
private readonly config: BunServerConfig,
|
|
125
|
-
private readonly logger: Logger,
|
|
126
|
-
options: PikkuBunServerOptions = {}
|
|
127
|
-
) {
|
|
128
|
-
const { eventHub, mcpJson, mcpPath, contentSigningJWT, ...httpOptions } =
|
|
129
|
-
options
|
|
130
|
-
this.eventHub = eventHub ?? new BunEventHubService()
|
|
131
|
-
this.mcpJson = mcpJson
|
|
132
|
-
this.mcpPath = mcpPath ?? '/mcp'
|
|
133
|
-
this.options = httpOptions
|
|
134
|
-
this.localContent = config.content
|
|
135
|
-
? createLocalContentRequestHandler({
|
|
136
|
-
content: config.content,
|
|
137
|
-
logger,
|
|
138
|
-
// Resolved per request: `singletonServices` is populated after the
|
|
139
|
-
// server is constructed, so reading it here would always miss.
|
|
140
|
-
getJWT: () =>
|
|
141
|
-
contentSigningJWT ??
|
|
142
|
-
pikkuState(null, 'package', 'singletonServices')?.jwt,
|
|
143
|
-
})
|
|
144
|
-
: undefined
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
public async init(): Promise<void> {
|
|
148
|
-
compileAllSchemas(this.logger)
|
|
149
|
-
logRegisterRoutes(this.logger)
|
|
150
|
-
logChannels(this.logger)
|
|
151
|
-
await this.initMCP()
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
private async initMCP(): Promise<void> {
|
|
155
|
-
const mcpJson = this.mcpJson
|
|
156
|
-
if (!mcpJson) return
|
|
157
|
-
const { tools = [], resources = [], prompts = [] } = mcpJson
|
|
158
|
-
if (tools.length + resources.length + prompts.length === 0) return
|
|
159
|
-
try {
|
|
160
|
-
const { PikkuMCPServer } = await import('@pikku/modelcontextprotocol')
|
|
161
|
-
const mcpServer = new PikkuMCPServer(
|
|
162
|
-
{
|
|
163
|
-
name: 'pikku',
|
|
164
|
-
version: '1.0.0',
|
|
165
|
-
mcpJSON: mcpJson,
|
|
166
|
-
capabilities: {
|
|
167
|
-
...(tools.length > 0 && { tools: {} }),
|
|
168
|
-
...(resources.length > 0 && { resources: {} }),
|
|
169
|
-
...(prompts.length > 0 && { prompts: {} }),
|
|
170
|
-
},
|
|
171
|
-
},
|
|
172
|
-
this.logger
|
|
173
|
-
)
|
|
174
|
-
await mcpServer.init()
|
|
175
|
-
const { handler } = mcpServer.createFetchHandler({ path: this.mcpPath })
|
|
176
|
-
this.mcpHandler = handler
|
|
177
|
-
this.logger.info(`pikku-bun-server: MCP mounted at ${this.mcpPath}`)
|
|
178
|
-
} catch (err) {
|
|
179
|
-
this.logger.warn(
|
|
180
|
-
`pikku-bun-server: MCP could not be mounted — ${err instanceof Error ? err.message : String(err)}`
|
|
181
|
-
)
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
public async start(): Promise<void> {
|
|
186
|
-
const { config, logger, options, eventHub, mcpHandler, mcpPath } = this
|
|
187
|
-
|
|
188
|
-
this.server = Bun.serve<WsData>({
|
|
189
|
-
port: config.port,
|
|
190
|
-
hostname: config.hostname,
|
|
191
|
-
|
|
192
|
-
fetch: async (req, server) => {
|
|
193
|
-
if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
|
|
194
|
-
const pikkuReq = new PikkuFetchHTTPRequest(req)
|
|
195
|
-
const pikkuRes = new PikkuFetchHTTPResponse()
|
|
196
|
-
const channelHandler = await runLocalChannel({
|
|
197
|
-
channelId: crypto.randomUUID(),
|
|
198
|
-
request: pikkuReq,
|
|
199
|
-
response: pikkuRes,
|
|
200
|
-
route: new URL(req.url).pathname,
|
|
201
|
-
})
|
|
202
|
-
if (!channelHandler) {
|
|
203
|
-
return new Response('Forbidden', { status: 403 })
|
|
204
|
-
}
|
|
205
|
-
const upgraded = server.upgrade(req, { data: { channelHandler } })
|
|
206
|
-
if (upgraded) return undefined as unknown as Response
|
|
207
|
-
return new Response('WebSocket upgrade failed', { status: 500 })
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
if (
|
|
211
|
-
config.healthCheckPath &&
|
|
212
|
-
new URL(req.url).pathname === config.healthCheckPath
|
|
213
|
-
) {
|
|
214
|
-
return new Response('{"ok":true}', {
|
|
215
|
-
headers: { 'content-type': 'application/json' },
|
|
216
|
-
})
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
if (mcpHandler) {
|
|
220
|
-
const pathname = new URL(req.url).pathname
|
|
221
|
-
if (pathname === mcpPath || pathname.startsWith(`${mcpPath}/`)) {
|
|
222
|
-
return await mcpHandler(req)
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// Ahead of static mounts and routing: these prefixes belong to the
|
|
227
|
-
// content service, and `fetchData` would answer them with a 404 since
|
|
228
|
-
// no wiring claims them.
|
|
229
|
-
const contentResponse = await this.localContent?.(req)
|
|
230
|
-
if (contentResponse) {
|
|
231
|
-
return contentResponse
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
const staticResponse = await this.serveStaticFiles(req)
|
|
235
|
-
if (staticResponse) {
|
|
236
|
-
return staticResponse
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
const pikkuReq = new PikkuFetchHTTPRequest(req)
|
|
240
|
-
const pikkuRes = new PikkuFetchHTTPResponse()
|
|
241
|
-
await fetchData(pikkuReq, pikkuRes, {
|
|
242
|
-
respondWith404: true,
|
|
243
|
-
...options,
|
|
244
|
-
})
|
|
245
|
-
const response = pikkuRes.toResponse()
|
|
246
|
-
if (response.status === 404) {
|
|
247
|
-
const fallback = await this.serveSpaFallback(req)
|
|
248
|
-
if (fallback) {
|
|
249
|
-
return fallback
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
return response
|
|
253
|
-
},
|
|
254
|
-
|
|
255
|
-
websocket: {
|
|
256
|
-
open: (ws: ServerWebSocket<WsData>) => {
|
|
257
|
-
const { channelHandler } = ws.data
|
|
258
|
-
channelHandler.registerOnSend((data) => {
|
|
259
|
-
ws.send(isSerializable(data) ? JSON.stringify(data) : (data as any))
|
|
260
|
-
})
|
|
261
|
-
channelHandler.registerOnSendBinary((data) => {
|
|
262
|
-
ws.send(data, true)
|
|
263
|
-
})
|
|
264
|
-
channelHandler.registerOnClose(() => {
|
|
265
|
-
ws.close()
|
|
266
|
-
})
|
|
267
|
-
eventHub.onChannelOpened(channelHandler.channelId, ws)
|
|
268
|
-
channelHandler.open()
|
|
269
|
-
},
|
|
270
|
-
|
|
271
|
-
message: async (ws: ServerWebSocket<WsData>, message) => {
|
|
272
|
-
const { channelHandler } = ws.data
|
|
273
|
-
if (typeof message === 'string') {
|
|
274
|
-
const result = await channelHandler.message(message)
|
|
275
|
-
if (result) ws.send(JSON.stringify(result))
|
|
276
|
-
} else {
|
|
277
|
-
const bytes =
|
|
278
|
-
message instanceof ArrayBuffer
|
|
279
|
-
? new Uint8Array(message)
|
|
280
|
-
: new Uint8Array(
|
|
281
|
-
message.buffer,
|
|
282
|
-
message.byteOffset,
|
|
283
|
-
message.byteLength
|
|
284
|
-
)
|
|
285
|
-
const result = await channelHandler.binaryMessage(bytes)
|
|
286
|
-
if (result) channelHandler.sendBinary(result)
|
|
287
|
-
}
|
|
288
|
-
},
|
|
289
|
-
|
|
290
|
-
close: (ws: ServerWebSocket<WsData>) => {
|
|
291
|
-
const { channelHandler } = ws.data
|
|
292
|
-
eventHub.onChannelClosed(channelHandler.channelId)
|
|
293
|
-
channelHandler.close()
|
|
294
|
-
},
|
|
295
|
-
},
|
|
296
|
-
})
|
|
297
|
-
|
|
298
|
-
eventHub.setServer(this.server as BunServer<unknown>)
|
|
299
|
-
logger.info(
|
|
300
|
-
`pikku-bun-server: listening on http://${config.hostname ?? 'localhost'}:${this.port}`
|
|
301
|
-
)
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
/**
|
|
305
|
-
* The port the server is actually listening on.
|
|
306
|
-
*
|
|
307
|
-
* Not the same as `config.port` whenever that is `0`: the OS picks a free
|
|
308
|
-
* port at bind time, and a parent process that was told `0` has no other way
|
|
309
|
-
* to find out which one. Falls back to the requested port before `start()`.
|
|
310
|
-
*/
|
|
311
|
-
public get port(): number {
|
|
312
|
-
return this.server?.port ?? this.config.port
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
private matchesPrefix(pathname: string, prefix: string): boolean {
|
|
316
|
-
// A root mount covers the whole tree; the generic test below would match
|
|
317
|
-
// only `/` itself, since nothing starts with `//`.
|
|
318
|
-
if (prefix === '' || prefix === '/') {
|
|
319
|
-
return true
|
|
320
|
-
}
|
|
321
|
-
return pathname === prefix || pathname.startsWith(`${prefix}/`)
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
/**
|
|
325
|
-
* Resolve the mounts a GET/HEAD request could be served from, or null when
|
|
326
|
-
* static serving does not apply to this request at all.
|
|
327
|
-
*/
|
|
328
|
-
private staticMountCandidates(
|
|
329
|
-
req: Request
|
|
330
|
-
): { pathname: string; mounts: StaticMount[] } | null {
|
|
331
|
-
const mounts = this.config.staticMounts
|
|
332
|
-
if (!mounts?.length || (req.method !== 'GET' && req.method !== 'HEAD')) {
|
|
333
|
-
return null
|
|
334
|
-
}
|
|
335
|
-
const pathname = decodeURIComponent(new URL(req.url).pathname)
|
|
336
|
-
return {
|
|
337
|
-
pathname,
|
|
338
|
-
mounts: mounts.filter((mount) =>
|
|
339
|
-
this.matchesPrefix(pathname, mount.urlPrefix)
|
|
340
|
-
),
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
/**
|
|
345
|
-
* Serve an exact file hit only. A miss falls through to route dispatch, so a
|
|
346
|
-
* mount at `/` cannot swallow the API — the app shell is served afterwards by
|
|
347
|
-
* `serveSpaFallback`, once dispatch has had its chance and produced a 404.
|
|
348
|
-
*/
|
|
349
|
-
private async serveStaticFiles(req: Request): Promise<Response | null> {
|
|
350
|
-
const candidates = this.staticMountCandidates(req)
|
|
351
|
-
if (!candidates) {
|
|
352
|
-
return null
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
for (const mount of candidates.mounts) {
|
|
356
|
-
const key = candidates.pathname
|
|
357
|
-
.slice(mount.urlPrefix.length)
|
|
358
|
-
.replace(/^\/+/, '')
|
|
359
|
-
const result = await this.resolveStaticFile(mount, key)
|
|
360
|
-
if (result === 'rejected') {
|
|
361
|
-
// A key that escaped the mount directory is refused outright rather
|
|
362
|
-
// than falling through, which would answer a traversal attempt with a
|
|
363
|
-
// 200 and the app shell.
|
|
364
|
-
return new Response('Not Found', { status: 404 })
|
|
365
|
-
}
|
|
366
|
-
if (result) {
|
|
367
|
-
return new Response(req.method === 'HEAD' ? null : result)
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
return null
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
/**
|
|
375
|
-
* Last resort for a client-side route: dispatch has already 404'd, so an
|
|
376
|
-
* unmatched path under a `spaFallback` mount is the SPA's own routing.
|
|
377
|
-
*/
|
|
378
|
-
private async serveSpaFallback(req: Request): Promise<Response | null> {
|
|
379
|
-
const candidates = this.staticMountCandidates(req)
|
|
380
|
-
if (!candidates) {
|
|
381
|
-
return null
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
for (const mount of candidates.mounts) {
|
|
385
|
-
if (!mount.spaFallback) {
|
|
386
|
-
continue
|
|
387
|
-
}
|
|
388
|
-
const index = await this.resolveStaticFile(mount, 'index.html')
|
|
389
|
-
if (index && index !== 'rejected') {
|
|
390
|
-
return new Response(req.method === 'HEAD' ? null : index)
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
return null
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
/**
|
|
398
|
-
* `'rejected'` is kept distinct from `null` because the two must not share a
|
|
399
|
-
* fate: a missing file is a candidate for the SPA fallback, while a key that
|
|
400
|
-
* resolved outside the mount directory must never be. An `assets` mount never
|
|
401
|
-
* produces `'rejected'` at all — a map lookup has no directory to escape.
|
|
402
|
-
*/
|
|
403
|
-
private async resolveStaticFile(
|
|
404
|
-
mount: StaticMount,
|
|
405
|
-
key: string
|
|
406
|
-
): Promise<ReturnType<typeof Bun.file> | 'rejected' | null> {
|
|
407
|
-
if (mount.assets) {
|
|
408
|
-
const assetPath = mount.assets[key === '' ? 'index.html' : key]
|
|
409
|
-
if (!assetPath) {
|
|
410
|
-
return null
|
|
411
|
-
}
|
|
412
|
-
const asset = Bun.file(assetPath)
|
|
413
|
-
return (await asset.exists()) ? asset : null
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
const directory = resolve(mount.directory)
|
|
417
|
-
const targetPath =
|
|
418
|
-
key === '' ? resolve(directory, 'index.html') : resolve(directory, key)
|
|
419
|
-
if (targetPath !== directory && !targetPath.startsWith(`${directory}/`)) {
|
|
420
|
-
return 'rejected'
|
|
421
|
-
}
|
|
422
|
-
let file = Bun.file(targetPath)
|
|
423
|
-
if (!(await file.exists())) {
|
|
424
|
-
const indexPath = resolve(targetPath, 'index.html')
|
|
425
|
-
if (!indexPath.startsWith(`${directory}/`)) {
|
|
426
|
-
return 'rejected'
|
|
427
|
-
}
|
|
428
|
-
file = Bun.file(indexPath)
|
|
429
|
-
if (!(await file.exists())) {
|
|
430
|
-
return null
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
return file
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
public async stop(): Promise<void> {
|
|
437
|
-
await this.server?.stop()
|
|
438
|
-
this.server = null
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
public enableExitOnSignals(): void {
|
|
442
|
-
const shutdown = async (signal: string) => {
|
|
443
|
-
this.logger.info(`pikku-bun-server: ${signal} received, stopping`)
|
|
444
|
-
try {
|
|
445
|
-
await stopSingletonServices()
|
|
446
|
-
await this.stop()
|
|
447
|
-
} finally {
|
|
448
|
-
process.exit(0)
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
process.once('SIGINT', () => shutdown('SIGINT'))
|
|
452
|
-
process.once('SIGTERM', () => shutdown('SIGTERM'))
|
|
453
|
-
}
|
|
454
|
-
}
|