@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
|
@@ -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
|
-
//
|
|
96
|
-
//
|
|
97
|
-
port = parsePort(process.env.PORT)
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
338
|
+
routes,
|
|
331
339
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
if (
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
+
}
|