@briancray/belte 0.8.0 → 0.9.0

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.
@@ -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
+ }
@@ -0,0 +1,7 @@
1
+ import { rm } from 'node:fs/promises'
2
+ import { lastConnectionPath } from './lastConnectionPath.ts'
3
+
4
+ // Forgets the saved connection (the `/disconnect` reset). Missing file is a no-op.
5
+ export async function clearLastConnection(programName: string): Promise<void> {
6
+ await rm(lastConnectionPath(programName), { force: true })
7
+ }
@@ -0,0 +1,7 @@
1
+ import { join } from 'node:path'
2
+ import { appDataDir } from './appDataDir.ts'
3
+
4
+ // Path to the per-program last-connection record, beside the data-dir `.env`.
5
+ export function lastConnectionPath(programName: string): string {
6
+ return join(appDataDir(programName), 'last-connection.json')
7
+ }
@@ -0,0 +1,18 @@
1
+ import { lastConnectionPath } from './lastConnectionPath.ts'
2
+ import type { LastConnection } from './types/LastConnection.ts'
3
+
4
+ /*
5
+ Reads the saved connection intent, or undefined when none is recorded or the file
6
+ is unreadable/corrupt — callers treat undefined as "nothing to resume".
7
+ */
8
+ export async function readLastConnection(programName: string): Promise<LastConnection | undefined> {
9
+ const file = Bun.file(lastConnectionPath(programName))
10
+ if (!(await file.exists())) {
11
+ return undefined
12
+ }
13
+ try {
14
+ return (await file.json()) as LastConnection
15
+ } catch {
16
+ return undefined
17
+ }
18
+ }
@@ -0,0 +1,13 @@
1
+ /*
2
+ True when this code runs inside a `bun build --compile` standalone executable
3
+ (the bundle's embedded server, or an install-tarball server binary) rather than
4
+ under the `bun` CLI. Bun mounts a compiled binary's embedded modules under a
5
+ synthetic root — `/$bunfs/…` on posix, `…~BUN…` on Windows — so `Bun.main`
6
+ carries that marker only in a standalone binary; under `bun dev`/`bun start`
7
+ it's a real on-disk path. Used to scope the bundle's data-dir/binary-dir `.env`
8
+ loading to the shipped app, so `bun dev`/`bun start` keep to their project-local
9
+ CWD `.env` alone.
10
+ */
11
+ export function runningAsStandaloneBinary(): boolean {
12
+ return Bun.main.includes('$bunfs') || Bun.main.includes('~BUN')
13
+ }
@@ -0,0 +1,9 @@
1
+ /*
2
+ The launcher/CLI-owned record of the last connection, kept in the data dir so it
3
+ survives relaunch and is readable before any window or session opens. It records
4
+ the *intent*, not a concrete embedded URL — an embedded server picks a fresh port
5
+ each launch, so only `{ kind: 'embedded' }` is durable; a remote connection keeps
6
+ its url. resolveLaunchTarget / resolveCliTarget read it; /connect and /start write
7
+ it; /disconnect clears it.
8
+ */
9
+ export type LastConnection = { kind: 'embedded' } | { kind: 'url'; url: string }
@@ -0,0 +1,13 @@
1
+ import { mkdir } from 'node:fs/promises'
2
+ import { appDataDir } from './appDataDir.ts'
3
+ import { lastConnectionPath } from './lastConnectionPath.ts'
4
+ import type { LastConnection } from './types/LastConnection.ts'
5
+
6
+ // Persists the connection intent, creating the data dir on first write.
7
+ export async function writeLastConnection(
8
+ programName: string,
9
+ value: LastConnection,
10
+ ): Promise<void> {
11
+ await mkdir(appDataDir(programName), { recursive: true })
12
+ await Bun.write(lastConnectionPath(programName), JSON.stringify(value))
13
+ }
@@ -29,17 +29,23 @@ import { loadEnvFromBinaryDir } from './lib/cli/loadEnvFromBinaryDir.ts'
29
29
  import { createServer } from './lib/server/runtime/createServer.ts'
30
30
  import { requestContext } from './lib/server/runtime/requestContext.ts'
31
31
  import { loadEnvFromDataDir } from './lib/shared/loadEnvFromDataDir.ts'
32
+ import { runningAsStandaloneBinary } from './lib/shared/runningAsStandaloneBinary.ts'
32
33
  import { setCacheStoreResolver } from './lib/shared/setCacheStoreResolver.ts'
33
34
 
34
35
  /*
35
36
  Resolve config into process.env before anything reads it (createServer reads
36
- PORT, app code reads Bun.env.*). Data-dir first so the user's saved config wins
37
- over the binary-dir shipped default; both back-fill only what the shell or Bun's
38
- CWD `.env` didn't already set. A bundle launched via `open` has cwd `/`, so the
39
- data-dir `.env` is how it gets its config at all.
37
+ PORT, app code reads Bun.env.*). Standalone-only: data-dir first so the user's
38
+ saved config wins over the binary-dir shipped default; both back-fill only what
39
+ the shell didn't already set. A bundle launched via `open` has cwd `/`, so the
40
+ data-dir `.env` is how it gets its config at all. Under `bun dev`/`bun start`
41
+ these bundle layers don't apply — the project's own CWD `.env` (Bun-autoloaded)
42
+ is the config — so loading them would let a stray data-dir `PORT` defeat dev's
43
+ port scan.
40
44
  */
41
- await loadEnvFromDataDir(cliProgramName)
42
- await loadEnvFromBinaryDir()
45
+ if (runningAsStandaloneBinary()) {
46
+ await loadEnvFromDataDir(cliProgramName)
47
+ await loadEnvFromBinaryDir()
48
+ }
43
49
 
44
50
  // In a bundle, tie this server's life to the launcher's (no-op standalone).
45
51
  exitWithParent()