@pikku/bun-server 0.12.11 → 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.
@@ -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
- })
@@ -1,484 +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
- /**
115
- * Work the process owes its app on the way down, run around the service
116
- * teardown `enableExitOnSignals` already does. A failing hook is logged and
117
- * shutdown continues: a process that has been told to stop has to stop.
118
- */
119
- export type ShutdownHooks = {
120
- beforeStop?: () => void | Promise<void>
121
- afterStop?: () => void | Promise<void>
122
- }
123
-
124
- export class PikkuBunServer {
125
- private server: BunServer<WsData> | null = null
126
- private readonly eventHub: BunEventHubService
127
- private readonly options: RunHTTPWiringOptions
128
- private readonly mcpJson?: PikkuBunServerOptions['mcpJson']
129
- private readonly mcpPath: string
130
- private mcpHandler?: (request: Request) => Promise<Response>
131
- private readonly localContent?: LocalContentRequestHandler
132
-
133
- constructor(
134
- private readonly config: BunServerConfig,
135
- private readonly logger: Logger,
136
- options: PikkuBunServerOptions = {}
137
- ) {
138
- const { eventHub, mcpJson, mcpPath, contentSigningJWT, ...httpOptions } =
139
- options
140
- this.eventHub = eventHub ?? new BunEventHubService()
141
- this.mcpJson = mcpJson
142
- this.mcpPath = mcpPath ?? '/mcp'
143
- this.options = httpOptions
144
- this.localContent = config.content
145
- ? createLocalContentRequestHandler({
146
- content: config.content,
147
- logger,
148
- // Resolved per request: `singletonServices` is populated after the
149
- // server is constructed, so reading it here would always miss.
150
- getJWT: () =>
151
- contentSigningJWT ??
152
- pikkuState(null, 'package', 'singletonServices')?.jwt,
153
- })
154
- : undefined
155
- }
156
-
157
- public async init(): Promise<void> {
158
- compileAllSchemas(this.logger)
159
- logRegisterRoutes(this.logger)
160
- logChannels(this.logger)
161
- await this.initMCP()
162
- }
163
-
164
- private async initMCP(): Promise<void> {
165
- const mcpJson = this.mcpJson
166
- if (!mcpJson) return
167
- const { tools = [], resources = [], prompts = [] } = mcpJson
168
- if (tools.length + resources.length + prompts.length === 0) return
169
- try {
170
- const { PikkuMCPServer } = await import('@pikku/modelcontextprotocol')
171
- const mcpServer = new PikkuMCPServer(
172
- {
173
- name: 'pikku',
174
- version: '1.0.0',
175
- mcpJSON: mcpJson,
176
- capabilities: {
177
- ...(tools.length > 0 && { tools: {} }),
178
- ...(resources.length > 0 && { resources: {} }),
179
- ...(prompts.length > 0 && { prompts: {} }),
180
- },
181
- },
182
- this.logger
183
- )
184
- await mcpServer.init()
185
- const { handler } = mcpServer.createFetchHandler({ path: this.mcpPath })
186
- this.mcpHandler = handler
187
- this.logger.info(`pikku-bun-server: MCP mounted at ${this.mcpPath}`)
188
- } catch (err) {
189
- this.logger.warn(
190
- `pikku-bun-server: MCP could not be mounted — ${err instanceof Error ? err.message : String(err)}`
191
- )
192
- }
193
- }
194
-
195
- public async start(): Promise<void> {
196
- const { config, logger, options, eventHub, mcpHandler, mcpPath } = this
197
-
198
- this.server = Bun.serve<WsData>({
199
- port: config.port,
200
- hostname: config.hostname,
201
-
202
- fetch: async (req, server) => {
203
- if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
204
- const pikkuReq = new PikkuFetchHTTPRequest(req)
205
- const pikkuRes = new PikkuFetchHTTPResponse()
206
- const channelHandler = await runLocalChannel({
207
- channelId: crypto.randomUUID(),
208
- request: pikkuReq,
209
- response: pikkuRes,
210
- route: new URL(req.url).pathname,
211
- })
212
- if (!channelHandler) {
213
- return new Response('Forbidden', { status: 403 })
214
- }
215
- const upgraded = server.upgrade(req, { data: { channelHandler } })
216
- if (upgraded) return undefined as unknown as Response
217
- return new Response('WebSocket upgrade failed', { status: 500 })
218
- }
219
-
220
- if (
221
- config.healthCheckPath &&
222
- new URL(req.url).pathname === config.healthCheckPath
223
- ) {
224
- return new Response('{"ok":true}', {
225
- headers: { 'content-type': 'application/json' },
226
- })
227
- }
228
-
229
- if (mcpHandler) {
230
- const pathname = new URL(req.url).pathname
231
- if (pathname === mcpPath || pathname.startsWith(`${mcpPath}/`)) {
232
- return await mcpHandler(req)
233
- }
234
- }
235
-
236
- // Ahead of static mounts and routing: these prefixes belong to the
237
- // content service, and `fetchData` would answer them with a 404 since
238
- // no wiring claims them.
239
- const contentResponse = await this.localContent?.(req)
240
- if (contentResponse) {
241
- return contentResponse
242
- }
243
-
244
- const staticResponse = await this.serveStaticFiles(req)
245
- if (staticResponse) {
246
- return staticResponse
247
- }
248
-
249
- const pikkuReq = new PikkuFetchHTTPRequest(req)
250
- const pikkuRes = new PikkuFetchHTTPResponse()
251
- await fetchData(pikkuReq, pikkuRes, {
252
- respondWith404: true,
253
- ...options,
254
- })
255
- const response = pikkuRes.toResponse()
256
- if (response.status === 404) {
257
- const fallback = await this.serveSpaFallback(req)
258
- if (fallback) {
259
- return fallback
260
- }
261
- }
262
- return response
263
- },
264
-
265
- websocket: {
266
- open: (ws: ServerWebSocket<WsData>) => {
267
- const { channelHandler } = ws.data
268
- channelHandler.registerOnSend((data) => {
269
- ws.send(isSerializable(data) ? JSON.stringify(data) : (data as any))
270
- })
271
- channelHandler.registerOnSendBinary((data) => {
272
- ws.send(data, true)
273
- })
274
- channelHandler.registerOnClose(() => {
275
- ws.close()
276
- })
277
- eventHub.onChannelOpened(channelHandler.channelId, ws)
278
- channelHandler.open()
279
- },
280
-
281
- message: async (ws: ServerWebSocket<WsData>, message) => {
282
- const { channelHandler } = ws.data
283
- if (typeof message === 'string') {
284
- const result = await channelHandler.message(message)
285
- if (result) ws.send(JSON.stringify(result))
286
- } else {
287
- const bytes =
288
- message instanceof ArrayBuffer
289
- ? new Uint8Array(message)
290
- : new Uint8Array(
291
- message.buffer,
292
- message.byteOffset,
293
- message.byteLength
294
- )
295
- const result = await channelHandler.binaryMessage(bytes)
296
- if (result) channelHandler.sendBinary(result)
297
- }
298
- },
299
-
300
- close: (ws: ServerWebSocket<WsData>) => {
301
- const { channelHandler } = ws.data
302
- eventHub.onChannelClosed(channelHandler.channelId)
303
- channelHandler.close()
304
- },
305
- },
306
- })
307
-
308
- eventHub.setServer(this.server as BunServer<unknown>)
309
- logger.info(
310
- `pikku-bun-server: listening on http://${config.hostname ?? 'localhost'}:${this.port}`
311
- )
312
- }
313
-
314
- /**
315
- * The port the server is actually listening on.
316
- *
317
- * Not the same as `config.port` whenever that is `0`: the OS picks a free
318
- * port at bind time, and a parent process that was told `0` has no other way
319
- * to find out which one. Falls back to the requested port before `start()`.
320
- */
321
- public get port(): number {
322
- return this.server?.port ?? this.config.port
323
- }
324
-
325
- private matchesPrefix(pathname: string, prefix: string): boolean {
326
- // A root mount covers the whole tree; the generic test below would match
327
- // only `/` itself, since nothing starts with `//`.
328
- if (prefix === '' || prefix === '/') {
329
- return true
330
- }
331
- return pathname === prefix || pathname.startsWith(`${prefix}/`)
332
- }
333
-
334
- /**
335
- * Resolve the mounts a GET/HEAD request could be served from, or null when
336
- * static serving does not apply to this request at all.
337
- */
338
- private staticMountCandidates(
339
- req: Request
340
- ): { pathname: string; mounts: StaticMount[] } | null {
341
- const mounts = this.config.staticMounts
342
- if (!mounts?.length || (req.method !== 'GET' && req.method !== 'HEAD')) {
343
- return null
344
- }
345
- const pathname = decodeURIComponent(new URL(req.url).pathname)
346
- return {
347
- pathname,
348
- mounts: mounts.filter((mount) =>
349
- this.matchesPrefix(pathname, mount.urlPrefix)
350
- ),
351
- }
352
- }
353
-
354
- /**
355
- * Serve an exact file hit only. A miss falls through to route dispatch, so a
356
- * mount at `/` cannot swallow the API — the app shell is served afterwards by
357
- * `serveSpaFallback`, once dispatch has had its chance and produced a 404.
358
- */
359
- private async serveStaticFiles(req: Request): Promise<Response | null> {
360
- const candidates = this.staticMountCandidates(req)
361
- if (!candidates) {
362
- return null
363
- }
364
-
365
- for (const mount of candidates.mounts) {
366
- const key = candidates.pathname
367
- .slice(mount.urlPrefix.length)
368
- .replace(/^\/+/, '')
369
- const result = await this.resolveStaticFile(mount, key)
370
- if (result === 'rejected') {
371
- // A key that escaped the mount directory is refused outright rather
372
- // than falling through, which would answer a traversal attempt with a
373
- // 200 and the app shell.
374
- return new Response('Not Found', { status: 404 })
375
- }
376
- if (result) {
377
- return new Response(req.method === 'HEAD' ? null : result)
378
- }
379
- }
380
-
381
- return null
382
- }
383
-
384
- /**
385
- * Last resort for a client-side route: dispatch has already 404'd, so an
386
- * unmatched path under a `spaFallback` mount is the SPA's own routing.
387
- */
388
- private async serveSpaFallback(req: Request): Promise<Response | null> {
389
- const candidates = this.staticMountCandidates(req)
390
- if (!candidates) {
391
- return null
392
- }
393
-
394
- for (const mount of candidates.mounts) {
395
- if (!mount.spaFallback) {
396
- continue
397
- }
398
- const index = await this.resolveStaticFile(mount, 'index.html')
399
- if (index && index !== 'rejected') {
400
- return new Response(req.method === 'HEAD' ? null : index)
401
- }
402
- }
403
-
404
- return null
405
- }
406
-
407
- /**
408
- * `'rejected'` is kept distinct from `null` because the two must not share a
409
- * fate: a missing file is a candidate for the SPA fallback, while a key that
410
- * resolved outside the mount directory must never be. An `assets` mount never
411
- * produces `'rejected'` at all — a map lookup has no directory to escape.
412
- */
413
- private async resolveStaticFile(
414
- mount: StaticMount,
415
- key: string
416
- ): Promise<ReturnType<typeof Bun.file> | 'rejected' | null> {
417
- if (mount.assets) {
418
- const assetPath = mount.assets[key === '' ? 'index.html' : key]
419
- if (!assetPath) {
420
- return null
421
- }
422
- const asset = Bun.file(assetPath)
423
- return (await asset.exists()) ? asset : null
424
- }
425
-
426
- const directory = resolve(mount.directory)
427
- const targetPath =
428
- key === '' ? resolve(directory, 'index.html') : resolve(directory, key)
429
- if (targetPath !== directory && !targetPath.startsWith(`${directory}/`)) {
430
- return 'rejected'
431
- }
432
- let file = Bun.file(targetPath)
433
- if (!(await file.exists())) {
434
- const indexPath = resolve(targetPath, 'index.html')
435
- if (!indexPath.startsWith(`${directory}/`)) {
436
- return 'rejected'
437
- }
438
- file = Bun.file(indexPath)
439
- if (!(await file.exists())) {
440
- return null
441
- }
442
- }
443
- return file
444
- }
445
-
446
- public async stop(): Promise<void> {
447
- await this.server?.stop()
448
- this.server = null
449
- }
450
-
451
- public enableExitOnSignals(hooks?: ShutdownHooks): void {
452
- /**
453
- * Every step of the shutdown is isolated from the ones after it. Sharing
454
- * one catch means a hook that throws takes the service teardown and the
455
- * socket close down with it, leaving the process to exit with connections
456
- * still open and services still holding their handles.
457
- */
458
- const phase = async (name: string, run: () => void | Promise<void>) => {
459
- try {
460
- await run()
461
- } catch (error) {
462
- this.logger.error(
463
- `pikku-bun-server: ${name} failed during shutdown`,
464
- error
465
- )
466
- }
467
- }
468
- const shutdown = async (signal: string) => {
469
- this.logger.info(`pikku-bun-server: ${signal} received, stopping`)
470
- try {
471
- await phase('beforeStop', () => hooks?.beforeStop?.())
472
- await phase('stopping singleton services', () =>
473
- stopSingletonServices()
474
- )
475
- await phase('stopping the server', () => this.stop())
476
- await phase('afterStop', () => hooks?.afterStop?.())
477
- } finally {
478
- process.exit(0)
479
- }
480
- }
481
- process.once('SIGINT', () => shutdown('SIGINT'))
482
- process.once('SIGTERM', () => shutdown('SIGTERM'))
483
- }
484
- }