@briancray/belte 0.8.0 → 0.8.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@briancray/belte",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "type": "module",
5
5
  "description": "Isomorphic multimodal HTTP framework built for humans and machines in a single Bun runtime",
6
6
  "license": "MIT",
@@ -27,9 +27,9 @@ import { createAssetHeaderCache } from './createAssetHeaderCache.ts'
27
27
  import { createPublicAssetServer } from './createPublicAssetServer.ts'
28
28
  import { createRouteDispatcher } from './createRouteDispatcher.ts'
29
29
  import { disableIdleTimeoutForStream } from './disableIdleTimeoutForStream.ts'
30
- import { findOpenPort } from './findOpenPort.ts'
31
30
  import { globToPathSet } from './globToPathSet.ts'
32
31
  import { internalErrorResponse } from './internalErrorResponse.ts'
32
+ import { listenOnOpenPort } from './listenOnOpenPort.ts'
33
33
  import { logBrowserOnlyRoutes } from './logBrowserOnlyRoutes.ts'
34
34
  import { parseIdleTimeout } from './parseIdleTimeout.ts'
35
35
  import { parsePort } from './parsePort.ts'
@@ -92,9 +92,9 @@ export async function createServer({
92
92
  distDir = `${process.cwd()}/dist`,
93
93
  publicDir = `${process.cwd()}/src/browser/public`,
94
94
  resourcesDir = `${process.cwd()}/src/mcp/resources`,
95
- // No PORT set scan for the first open port at/above 3000 rather than
96
- // hardcoding 3000, so a second app boots cleanly instead of colliding.
97
- port = parsePort(process.env.PORT) ?? findOpenPort(3000),
95
+ // A configured PORT is honored as-is; left undefined, the real listener
96
+ // scans upward from 3000 at bind time (see buildServer / listenOnOpenPort).
97
+ port = parsePort(process.env.PORT),
98
98
  /*
99
99
  Bun's per-connection idle timeout in seconds (its own default is 10).
100
100
  Surfaced for apps whose unary handlers legitimately compute longer than
@@ -310,137 +310,155 @@ export async function createServer({
310
310
  a busy socket doesn't iterate JS per subscriber per message.
311
311
  */
312
312
  const socketDispatcher = createSocketDispatcher(sockets)
313
- // Server<unknown> pins Bun's WebSocketData generic so upgrade({ data: {} }) typechecks.
314
- const server: Server<unknown> = Bun.serve({
315
- port,
316
- idleTimeout,
317
313
 
318
- websocket: {
319
- open(ws) {
320
- socketDispatcher.open(ws)
321
- },
322
- message(ws, data) {
323
- socketDispatcher.message(ws, data)
324
- },
325
- close(ws) {
326
- socketDispatcher.close(ws)
314
+ /*
315
+ Bind the real server on `boundPort`. Only the port varies between scan
316
+ attempts, so the rest of the config lives inline and just the port is spread
317
+ in — passing the literal straight to Bun.serve keeps contextual typing of the
318
+ websocket handlers (and Server<unknown> pins Bun's WebSocketData generic so
319
+ upgrade({ data: {} }) typechecks).
320
+ */
321
+ const bindAt = (boundPort: number): Server<unknown> =>
322
+ Bun.serve({
323
+ port: boundPort,
324
+ idleTimeout,
325
+
326
+ websocket: {
327
+ open(ws) {
328
+ socketDispatcher.open(ws)
329
+ },
330
+ message(ws, data) {
331
+ socketDispatcher.message(ws, data)
332
+ },
333
+ close(ws) {
334
+ socketDispatcher.close(ws)
335
+ },
327
336
  },
328
- },
329
337
 
330
- routes,
338
+ routes,
331
339
 
332
- async fetch(req, bunServer) {
333
- const url = new URL(req.url)
334
- /*
335
- Identity probe — answered directly, ahead of any app.handle middleware,
336
- so the bundle's connect screen can confirm a URL really is a belte
337
- server (and which app) before pointing the desktop window at it. It
338
- must stay reachable even when the app guards everything behind auth,
339
- hence the early return that bypasses dispatchRequest.
340
- */
341
- if (url.pathname === IDENTITY_PATH) {
342
- return Response.json(
343
- {
344
- belte: true,
345
- name: appInfo?.name ?? cliName,
346
- version: appInfo?.version ?? '0.0.0',
347
- },
348
- { headers: { 'Cache-Control': NO_STORE } },
349
- )
350
- }
351
- if (url.pathname === SOCKETS_PATH) {
352
- if (bunServer.upgrade(req, { data: {} })) {
353
- return undefined as unknown as Response
340
+ async fetch(req, bunServer) {
341
+ const url = new URL(req.url)
342
+ /*
343
+ Identity probe — answered directly, ahead of any app.handle middleware,
344
+ so the bundle's connect screen can confirm a URL really is a belte
345
+ server (and which app) before pointing the desktop window at it. It
346
+ must stay reachable even when the app guards everything behind auth,
347
+ hence the early return that bypasses dispatchRequest.
348
+ */
349
+ if (url.pathname === IDENTITY_PATH) {
350
+ return Response.json(
351
+ {
352
+ belte: true,
353
+ name: appInfo?.name ?? cliName,
354
+ version: appInfo?.version ?? '0.0.0',
355
+ },
356
+ { headers: { 'Cache-Control': NO_STORE } },
357
+ )
354
358
  }
355
- return new Response('Upgrade failed', { status: 400 })
356
- }
357
- /*
358
- HTTP face of a socket (`/__belte/sockets/<name>`) — tail over
359
- SSE / JSON and publish for the CLI and MCP. Runs through
360
- dispatchRequest so app.handle auth applies, like the rpc paths.
361
- The socket name may contain `/` (nested files), so it's the
362
- whole remaining pathname, percent-decoded.
363
- */
364
- if (url.pathname.startsWith(SOCKETS_REST_PREFIX)) {
365
- const name = decodeURIComponent(url.pathname.slice(SOCKETS_REST_PREFIX.length))
366
- return dispatchRequest(req, {}, async () => socketDispatcher.rest(req, name))
367
- }
368
- if (url.pathname === MCP_PATH && mcp) {
369
- return dispatchRequest(req, {}, async () => mcp.handle(req))
370
- }
371
- if (url.pathname === CLI_PATH) {
372
- return dispatchRequest(req, {}, async () => handleCliInstall(req, cliName))
373
- }
374
- if (url.pathname.startsWith(CLI_DOWNLOAD_PREFIX)) {
375
- const platform = url.pathname.slice(CLI_DOWNLOAD_PREFIX.length)
376
- return dispatchRequest(req, {}, async () =>
377
- handleCliDownload(req, platform, cliName, cliCwd),
378
- )
379
- }
380
- if (url.pathname === OPENAPI_PATH) {
381
- return dispatchRequest(req, {}, async () => {
382
- await ensureRegistriesLoaded()
383
- const spec = buildOpenApiSpec({
384
- title: appInfo?.name ?? cliName,
385
- version: appInfo?.version ?? '0.0.0',
386
- })
387
- return Response.json(spec, { headers: { 'Cache-Control': NO_STORE } })
388
- })
389
- }
390
- /*
391
- Static assets sidestep ALS + the per-request CacheStore + the
392
- app.handle middleware: they have no need for cache() and the
393
- allocation overhead matters on a cold page load that pulls
394
- dozens of chunks. The global server.error() handler still
395
- catches anything that goes wrong inside serveStaticAsset.
396
- */
397
- if (url.pathname.startsWith('/_app/')) {
398
- if (!logRequests) {
399
- return serveStaticAsset(req, url)
359
+ if (url.pathname === SOCKETS_PATH) {
360
+ if (bunServer.upgrade(req, { data: {} })) {
361
+ return undefined as unknown as Response
362
+ }
363
+ return new Response('Upgrade failed', { status: 400 })
400
364
  }
401
- const start = Bun.nanoseconds()
402
- const response = await serveStaticAsset(req, url)
403
- const ms = (Bun.nanoseconds() - start) / 1e6
404
- log.request(req.method, `${url.pathname}${url.search}`, response.status, ms)
405
- return response
406
- }
407
- /*
408
- Files under public/ are served at the site root, sidestepping
409
- ALS + middleware like the /_app/ assets do. A miss returns
410
- undefined so the request falls through to the 404 / middleware
411
- path below.
412
- */
413
- const publicResponse = await servePublicAsset(req, url)
414
- if (publicResponse) {
415
- if (logRequests) {
416
- log.request(
417
- req.method,
418
- `${url.pathname}${url.search}`,
419
- publicResponse.status,
420
- 0,
365
+ /*
366
+ HTTP face of a socket (`/__belte/sockets/<name>`) — tail over
367
+ SSE / JSON and publish for the CLI and MCP. Runs through
368
+ dispatchRequest so app.handle auth applies, like the rpc paths.
369
+ The socket name may contain `/` (nested files), so it's the
370
+ whole remaining pathname, percent-decoded.
371
+ */
372
+ if (url.pathname.startsWith(SOCKETS_REST_PREFIX)) {
373
+ const name = decodeURIComponent(url.pathname.slice(SOCKETS_REST_PREFIX.length))
374
+ return dispatchRequest(req, {}, async () => socketDispatcher.rest(req, name))
375
+ }
376
+ if (url.pathname === MCP_PATH && mcp) {
377
+ return dispatchRequest(req, {}, async () => mcp.handle(req))
378
+ }
379
+ if (url.pathname === CLI_PATH) {
380
+ return dispatchRequest(req, {}, async () => handleCliInstall(req, cliName))
381
+ }
382
+ if (url.pathname.startsWith(CLI_DOWNLOAD_PREFIX)) {
383
+ const platform = url.pathname.slice(CLI_DOWNLOAD_PREFIX.length)
384
+ return dispatchRequest(req, {}, async () =>
385
+ handleCliDownload(req, platform, cliName, cliCwd),
421
386
  )
422
387
  }
423
- return publicResponse
424
- }
425
- /*
426
- Unknown routes still run through dispatchRequest so user-defined
427
- app.handle middleware can rewrite the request, serve a custom
428
- 404, or branch on the URL. The inner handler returns the
429
- framework's default 404 when nothing intervenes.
430
- */
431
- return dispatchRequest(req, {}, async () => {
432
- return new Response('Not Found', {
433
- status: 404,
434
- headers: { 'Cache-Control': NO_STORE },
388
+ if (url.pathname === OPENAPI_PATH) {
389
+ return dispatchRequest(req, {}, async () => {
390
+ await ensureRegistriesLoaded()
391
+ const spec = buildOpenApiSpec({
392
+ title: appInfo?.name ?? cliName,
393
+ version: appInfo?.version ?? '0.0.0',
394
+ })
395
+ return Response.json(spec, { headers: { 'Cache-Control': NO_STORE } })
396
+ })
397
+ }
398
+ /*
399
+ Static assets sidestep ALS + the per-request CacheStore + the
400
+ app.handle middleware: they have no need for cache() and the
401
+ allocation overhead matters on a cold page load that pulls
402
+ dozens of chunks. The global server.error() handler still
403
+ catches anything that goes wrong inside serveStaticAsset.
404
+ */
405
+ if (url.pathname.startsWith('/_app/')) {
406
+ if (!logRequests) {
407
+ return serveStaticAsset(req, url)
408
+ }
409
+ const start = Bun.nanoseconds()
410
+ const response = await serveStaticAsset(req, url)
411
+ const ms = (Bun.nanoseconds() - start) / 1e6
412
+ log.request(req.method, `${url.pathname}${url.search}`, response.status, ms)
413
+ return response
414
+ }
415
+ /*
416
+ Files under public/ are served at the site root, sidestepping
417
+ ALS + middleware like the /_app/ assets do. A miss returns
418
+ undefined so the request falls through to the 404 / middleware
419
+ path below.
420
+ */
421
+ const publicResponse = await servePublicAsset(req, url)
422
+ if (publicResponse) {
423
+ if (logRequests) {
424
+ log.request(
425
+ req.method,
426
+ `${url.pathname}${url.search}`,
427
+ publicResponse.status,
428
+ 0,
429
+ )
430
+ }
431
+ return publicResponse
432
+ }
433
+ /*
434
+ Unknown routes still run through dispatchRequest so user-defined
435
+ app.handle middleware can rewrite the request, serve a custom
436
+ 404, or branch on the URL. The inner handler returns the
437
+ framework's default 404 when nothing intervenes.
438
+ */
439
+ return dispatchRequest(req, {}, async () => {
440
+ return new Response('Not Found', {
441
+ status: 404,
442
+ headers: { 'Cache-Control': NO_STORE },
443
+ })
435
444
  })
436
- })
437
- },
445
+ },
446
+
447
+ error(err) {
448
+ log.error(err)
449
+ return internalErrorResponse(err)
450
+ },
451
+ })
438
452
 
439
- error(err) {
440
- log.error(err)
441
- return internalErrorResponse(err)
442
- },
443
- })
453
+ /*
454
+ A configured PORT binds that exact port — a collision surfaces loudly rather
455
+ than silently moving, since something connecting to the app needs a known
456
+ address. With none set, scan upward from 3000 binding the real listener, so
457
+ whichever server wins the port keeps it (no probe-release gap to lose it in,
458
+ which used to crash boot on EADDRINUSE instead of stepping to the next port).
459
+ */
460
+ const server: Server<unknown> =
461
+ port === undefined ? listenOnOpenPort(bindAt, 3000) : bindAt(port)
444
462
 
445
463
  /*
446
464
  Publishes the live server through `belte/server` before invoking the
@@ -0,0 +1,36 @@
1
+ import type { Server } from 'bun'
2
+
3
+ // Ports tried upward from `start` before giving up and letting the kernel assign one.
4
+ const SCAN_RANGE = 100
5
+
6
+ /*
7
+ Binds the real server, scanning upward from `start` for the first free port.
8
+ The listener that wins a port is the one that keeps it: unlike probing a
9
+ throwaway server and releasing it before the real bind, this leaves no window
10
+ for the chosen port to be stolen in between — the gap that crashed boot on
11
+ EADDRINUSE instead of stepping to the next port. `bindAt` does the actual
12
+ Bun.serve; only an in-use port is retried, any other failure propagates. After
13
+ SCAN_RANGE occupied ports it binds port 0 so the kernel assigns any free port.
14
+ */
15
+ export function listenOnOpenPort(
16
+ bindAt: (port: number) => Server<unknown>,
17
+ start: number,
18
+ ): Server<unknown> {
19
+ for (let port = start; port < start + SCAN_RANGE; port++) {
20
+ try {
21
+ return bindAt(port)
22
+ } catch (error) {
23
+ if (!isAddressInUse(error)) {
24
+ throw error
25
+ }
26
+ // port in use — try the next one up
27
+ }
28
+ }
29
+ // every candidate was taken; bind to 0 so the kernel picks a free port
30
+ return bindAt(0)
31
+ }
32
+
33
+ // Bun reports a taken port as an Error carrying code 'EADDRINUSE'.
34
+ function isAddressInUse(error: unknown): boolean {
35
+ return error instanceof Error && (error as { code?: string }).code === 'EADDRINUSE'
36
+ }