@briancray/belte 0.5.2 → 0.5.3

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.5.2",
3
+ "version": "0.5.3",
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",
@@ -6,6 +6,7 @@ import { belteImportName } from './lib/shared/belteImportName.ts'
6
6
  import { fileStem } from './lib/shared/fileStem.ts'
7
7
  import { jsonSchemaForPromptArguments } from './lib/shared/jsonSchemaForPromptArguments.ts'
8
8
  import { log } from './lib/shared/log.ts'
9
+ import { manifestModule } from './lib/shared/manifestModule.ts'
9
10
  import { pageUrlForFile } from './lib/shared/pageUrlForFile.ts'
10
11
  import { parsePromptMarkdown } from './lib/shared/parsePromptMarkdown.ts'
11
12
  import { prepareRpcModule } from './lib/shared/prepareRpcModule.ts'
@@ -315,111 +316,56 @@ ${optionLines}
315
316
 
316
317
  build.onLoad({ filter: /.*/, namespace: NS }, async (args) => {
317
318
  if (args.path === 'belte:rpc') {
318
- const files = await scanRpcOnce()
319
- const byUrl = files
320
- .toSorted()
321
- .map((file) => ({ url: rpcUrlForFile(file), file }))
322
- const entries = byUrl
323
- .map(
324
- ({ url, file }) =>
325
- ` ${JSON.stringify(url)}: () => import(${JSON.stringify(`${rpcDir}/${file}`)}),`,
326
- )
327
- .join('\n')
328
- if (byUrl.length > 0) {
329
- log.info(
330
- `resolved ${byUrl.length} rpc modules: ${byUrl.map((b) => b.url).join(', ')}`,
331
- )
332
- }
333
- return {
334
- contents: `export const rpc = {\n${entries}\n}\n`,
335
- loader: 'js',
336
- }
319
+ return manifestModule({
320
+ files: await scanRpcOnce(),
321
+ keyForFile: rpcUrlForFile,
322
+ importDir: rpcDir,
323
+ exportName: 'rpc',
324
+ label: 'rpc modules',
325
+ })
337
326
  }
338
327
 
339
328
  if (args.path === 'belte:sockets') {
340
- const files = await scanSocketsOnce()
341
- const byName = files
342
- .toSorted()
343
- .map((file) => ({ name: socketNameForFile(file), file }))
344
- const entries = byName
345
- .map(
346
- ({ name, file }) =>
347
- ` ${JSON.stringify(name)}: () => import(${JSON.stringify(`${socketsDir}/${file}`)}),`,
348
- )
349
- .join('\n')
350
- if (byName.length > 0) {
351
- log.info(
352
- `resolved ${byName.length} socket modules: ${byName.map((b) => b.name).join(', ')}`,
353
- )
354
- }
355
- return {
356
- contents: `export const sockets = {\n${entries}\n}\n`,
357
- loader: 'js',
358
- }
329
+ return manifestModule({
330
+ files: await scanSocketsOnce(),
331
+ keyForFile: socketNameForFile,
332
+ importDir: socketsDir,
333
+ exportName: 'sockets',
334
+ label: 'socket modules',
335
+ })
359
336
  }
360
337
 
361
338
  if (args.path === 'belte:prompts') {
362
- const files = await scanPromptsOnce()
363
- const byName = files
364
- .toSorted()
365
- .map((file) => ({ name: promptNameForFile(file), file }))
366
- const entries = byName
367
- .map(
368
- ({ name, file }) =>
369
- ` ${JSON.stringify(name)}: () => import(${JSON.stringify(`${promptsDir}/${file}`)}),`,
370
- )
371
- .join('\n')
372
- if (byName.length > 0) {
373
- log.info(
374
- `resolved ${byName.length} prompt modules: ${byName.map((b) => b.name).join(', ')}`,
375
- )
376
- }
377
- return {
378
- contents: `export const prompts = {\n${entries}\n}\n`,
379
- loader: 'js',
380
- }
339
+ return manifestModule({
340
+ files: await scanPromptsOnce(),
341
+ keyForFile: promptNameForFile,
342
+ importDir: promptsDir,
343
+ exportName: 'prompts',
344
+ label: 'prompt modules',
345
+ })
381
346
  }
382
347
 
383
348
  if (args.path === 'belte:pages') {
384
- const { pageFiles: files } = await scanPagesOnce()
385
- const byUrl = files
386
- .toSorted()
387
- .map((file) => ({ url: pageUrlForFile(file), file }))
388
- const entries = byUrl
389
- .map(
390
- ({ url, file }) =>
391
- ` ${JSON.stringify(url)}: () => import(${JSON.stringify(`${pagesDir}/${file}`)}),`,
392
- )
393
- .join('\n')
394
- log.info(
395
- `resolved ${byUrl.length} pages: ${byUrl.map((b) => b.url).join(', ')}`,
396
- )
397
- return {
398
- contents: `export const pages = {\n${entries}\n}\n`,
399
- loader: 'js',
400
- }
349
+ const { pageFiles } = await scanPagesOnce()
350
+ return manifestModule({
351
+ files: pageFiles,
352
+ keyForFile: pageUrlForFile,
353
+ importDir: pagesDir,
354
+ exportName: 'pages',
355
+ label: 'pages',
356
+ logWhenEmpty: true,
357
+ })
401
358
  }
402
359
 
403
360
  if (args.path === 'belte:layouts') {
404
- const { layoutFiles: files } = await scanPagesOnce()
405
- const byPrefix = files
406
- .toSorted()
407
- .map((file) => ({ prefix: pageUrlForFile(file), file }))
408
- const entries = byPrefix
409
- .map(
410
- ({ prefix, file }) =>
411
- ` ${JSON.stringify(prefix)}: () => import(${JSON.stringify(`${pagesDir}/${file}`)}),`,
412
- )
413
- .join('\n')
414
- if (byPrefix.length > 0) {
415
- log.info(
416
- `resolved ${byPrefix.length} layouts: ${byPrefix.map((b) => b.prefix).join(', ')}`,
417
- )
418
- }
419
- return {
420
- contents: `export const layouts = {\n${entries}\n}\n`,
421
- loader: 'js',
422
- }
361
+ const { layoutFiles } = await scanPagesOnce()
362
+ return manifestModule({
363
+ files: layoutFiles,
364
+ keyForFile: pageUrlForFile,
365
+ importDir: pagesDir,
366
+ exportName: 'layouts',
367
+ label: 'layouts',
368
+ })
423
369
  }
424
370
 
425
371
  if (args.path === 'belte:app') {
package/src/buildCli.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  import type { CompileTarget } from './lib/server/runtime/types/CompileTarget.ts'
2
2
  import { detectTarget } from './lib/shared/detectTarget.ts'
3
+ import { exeSuffix } from './lib/shared/exeSuffix.ts'
3
4
  import { exitOnBuildFailure } from './lib/shared/exitOnBuildFailure.ts'
4
5
  import { loadSvelteConfig } from './lib/shared/loadSvelteConfig.ts'
5
6
  import { log } from './lib/shared/log.ts'
6
7
  import { programNameForPackage } from './lib/shared/programNameForPackage.ts'
8
+ import { readPackageJson } from './lib/shared/readPackageJson.ts'
7
9
  import { serverBuildPlugins } from './serverBuildPlugins.ts'
8
10
 
9
11
  const DISCOVERY_ENTRY = new URL('./discoveryEntry.ts', import.meta.url).pathname
@@ -92,7 +94,7 @@ export async function buildCli({
92
94
  return Promise.all(
93
95
  platforms.map(async (platformTarget) => {
94
96
  const shortName = platformTarget.replace(/^bun-/, '')
95
- const suffix = platformTarget.includes('windows') ? '.exe' : ''
97
+ const suffix = exeSuffix(platformTarget)
96
98
  const platformOut = `${distDir}/cli-thin/${shortName}/${programName}${suffix}`
97
99
  await Bun.$`mkdir -p ${`${distDir}/cli-thin/${shortName}`}`.quiet()
98
100
  const result = await Bun.build({
@@ -108,7 +110,7 @@ export async function buildCli({
108
110
  )
109
111
  }
110
112
 
111
- const suffix = target.includes('windows') ? '.exe' : ''
113
+ const suffix = exeSuffix(target)
112
114
  const outPath = outfile ?? `${distDir}/cli${suffix}`
113
115
 
114
116
  const cliResult = await Bun.build({
@@ -124,10 +126,6 @@ export async function buildCli({
124
126
  }
125
127
 
126
128
  async function readProgramName(cwd: string): Promise<string> {
127
- const pkgFile = Bun.file(`${cwd}/package.json`)
128
- if (!(await pkgFile.exists())) {
129
- return 'app'
130
- }
131
- const pkg = (await pkgFile.json()) as { name?: string }
132
- return programNameForPackage(pkg.name)
129
+ const pkg = (await readPackageJson(cwd)) as { name?: string } | undefined
130
+ return programNameForPackage(pkg?.name)
133
131
  }
package/src/bundleApp.ts CHANGED
@@ -7,12 +7,13 @@ import { pngToIcns } from './lib/bundle/pngToIcns.ts'
7
7
  import { serverBinaryFilename } from './lib/bundle/serverBinaryFilename.ts'
8
8
  import { signMacApp } from './lib/bundle/signMacApp.ts'
9
9
  import { webviewLibName } from './lib/bundle/webviewLibName.ts'
10
+ import { bundleLayout } from './lib/shared/bundleLayout.ts'
10
11
  import { detectTarget } from './lib/shared/detectTarget.ts'
11
12
  import { exitOnBuildFailure } from './lib/shared/exitOnBuildFailure.ts'
12
13
  import { loadSvelteConfig } from './lib/shared/loadSvelteConfig.ts'
13
14
  import { log } from './lib/shared/log.ts'
14
15
  import { programNameForPackage } from './lib/shared/programNameForPackage.ts'
15
- import { shippedEnvPath } from './lib/shared/shippedEnvPath.ts'
16
+ import { readPackageJson } from './lib/shared/readPackageJson.ts'
16
17
  import { serverBuildPlugins } from './serverBuildPlugins.ts'
17
18
 
18
19
  const APP_ENTRY = new URL('./appEntry.ts', import.meta.url).pathname
@@ -40,15 +41,16 @@ export async function bundleApp({ cwd = process.cwd() }: { cwd?: string } = {}):
40
41
  const svelteConfig = await loadSvelteConfig(cwd)
41
42
 
42
43
  /*
43
- Layout differs by OS: a macOS .app nests binaries under Contents/MacOS
44
- and the lib under Contents/Frameworks; elsewhere everything sits flat
45
- in one directory. binDir is where the launcher + server land, libDir
46
- where the webview lib landsmatching resolveWebviewLib's candidates.
44
+ Layout differs by OS: a macOS .app nests binaries under Contents/MacOS, the
45
+ lib under Contents/Frameworks, and data under Contents/Resources; elsewhere
46
+ everything sits flat in one directory. bundleLayout derives the rest from
47
+ binDir the same source the boot readers resolve from so build and runtime
48
+ agree on where the lib, resources, and shipped `.env` land.
47
49
  */
48
50
  const isMac = process.platform === 'darwin'
49
51
  const bundleRoot = isMac ? `${cwd}/dist/${programName}.app` : `${cwd}/dist/${programName}`
50
52
  const binDir = isMac ? `${bundleRoot}/Contents/MacOS` : bundleRoot
51
- const libDir = isMac ? `${bundleRoot}/Contents/Frameworks` : bundleRoot
53
+ const { libDir, resourcesDir, envPath } = bundleLayout(binDir)
52
54
 
53
55
  await Bun.$`rm -rf ${bundleRoot}`.quiet()
54
56
  await Bun.$`mkdir -p ${binDir} ${libDir}`.quiet()
@@ -63,13 +65,12 @@ export async function bundleApp({ cwd = process.cwd() }: { cwd?: string } = {}):
63
65
  server loads at boot (loadEnvFromBinaryDir) as its default config layer. A
64
66
  dedicated file, never the working `.env` — a compiled bundle is extractable,
65
67
  so only ship-safe defaults belong here; user-specific/secret values come from
66
- the data-dir `.env` instead. shippedEnvPath places it under Contents/Resources
68
+ the data-dir `.env` instead. bundleLayout places it under Contents/Resources
67
69
  in a macOS `.app` (sealed as a resource, so it survives codesign) and beside
68
70
  the binaries otherwise. Skipped when absent.
69
71
  */
70
72
  const bundleEnv = Bun.file(`${cwd}/.env.bundle`)
71
73
  if (await bundleEnv.exists()) {
72
- const envPath = shippedEnvPath(binDir)
73
74
  await Bun.$`mkdir -p ${dirname(envPath)}`.quiet()
74
75
  await Bun.write(envPath, bundleEnv)
75
76
  }
@@ -110,7 +111,6 @@ export async function bundleApp({ cwd = process.cwd() }: { cwd?: string } = {}):
110
111
  converted via sips + iconutil so authors don't need to make an .icns.
111
112
  */
112
113
  if (isMac) {
113
- const resourcesDir = `${bundleRoot}/Contents/Resources`
114
114
  const icnsSource = `${cwd}/src/bundle/icon.icns`
115
115
  const pngSource = `${cwd}/src/bundle/icon.png`
116
116
  let hasIcon = false
@@ -142,10 +142,6 @@ export async function bundleApp({ cwd = process.cwd() }: { cwd?: string } = {}):
142
142
 
143
143
  // Reads name + version from package.json, with fallbacks when absent.
144
144
  async function readPackage(cwd: string): Promise<{ name: string | undefined; version: string }> {
145
- const pkgFile = Bun.file(`${cwd}/package.json`)
146
- if (!(await pkgFile.exists())) {
147
- return { name: undefined, version: '0.0.0' }
148
- }
149
- const pkg = (await pkgFile.json()) as { name?: string; version?: string }
150
- return { name: pkg.name, version: pkg.version ?? '0.0.0' }
145
+ const pkg = (await readPackageJson(cwd)) as { name?: string; version?: string } | undefined
146
+ return { name: pkg?.name, version: pkg?.version ?? '0.0.0' }
151
147
  }
package/src/compile.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { build } from './build.ts'
2
2
  import type { CompileTarget } from './lib/server/runtime/types/CompileTarget.ts'
3
3
  import { detectTarget } from './lib/shared/detectTarget.ts'
4
+ import { exeSuffix } from './lib/shared/exeSuffix.ts'
4
5
  import { exitOnBuildFailure } from './lib/shared/exitOnBuildFailure.ts'
5
6
  import { loadSvelteConfig } from './lib/shared/loadSvelteConfig.ts'
6
7
  import { log } from './lib/shared/log.ts'
@@ -28,7 +29,7 @@ export async function compile({
28
29
  const svelteConfig = await loadSvelteConfig(cwd)
29
30
  await build({ cwd, svelteConfig })
30
31
 
31
- const outPath = outfile ?? `${cwd}/dist/app${target.includes('windows') ? '.exe' : ''}`
32
+ const outPath = outfile ?? `${cwd}/dist/app${exeSuffix(target)}`
32
33
 
33
34
  const result = await Bun.build({
34
35
  entrypoints: [SERVER_ENTRY],
@@ -11,10 +11,10 @@ import { stableLocalPort } from './lib/bundle/stableLocalPort.ts'
11
11
  import { waitForServer } from './lib/bundle/waitForServer.ts'
12
12
  import { parsePort } from './lib/server/runtime/parsePort.ts'
13
13
  import { appDataDir } from './lib/shared/appDataDir.ts'
14
+ import { bundleLayout } from './lib/shared/bundleLayout.ts'
14
15
  import { log } from './lib/shared/log.ts'
15
16
  import { readEnvFile } from './lib/shared/readEnvFile.ts'
16
17
  import { serializeEnv } from './lib/shared/serializeEnv.ts'
17
- import { shippedEnvPath } from './lib/shared/shippedEnvPath.ts'
18
18
 
19
19
  /*
20
20
  The bundle's control server, run in a Worker so it owns its own thread.
@@ -290,7 +290,7 @@ function dataDirEnvPath(): string {
290
290
  // directory — same source loadEnvFromBinaryDir reads at boot (dirname of the running
291
291
  // binary): beside the binary in the flat layout, under Resources in a `.app`.
292
292
  function binaryDirEnvPath(): string {
293
- return shippedEnvPath(dirname(process.execPath))
293
+ return bundleLayout(dirname(process.execPath)).envPath
294
294
  }
295
295
 
296
296
  /*
@@ -365,68 +365,81 @@ async function clearLastConnection(): Promise<void> {
365
365
  await rm(lastConnectionPath(), { force: true })
366
366
  }
367
367
 
368
- /*
369
- The control server's request handler. The connect screen owns localStorage +
370
- navigation; this worker owns the embedded-server process and the native flag.
371
- */
372
- async function handleControlRequest(request: Request): Promise<Response> {
373
- const url = new URL(request.url)
374
- if (request.method === 'GET' && url.pathname === '/') {
375
- return renderConnectScreen()
376
- }
377
- if (request.method === 'GET' && url.pathname === '/__belte/config') {
378
- // No schema declared → null tells the form to skip the gate entirely.
379
- if (!configSchema) {
380
- return Response.json({ schema: null, values: {} })
381
- }
382
- return Response.json({ schema: configSchema, values: await resolveConfigValues() })
368
+ // GET /__belte/config — the form's schema + current values, or null schema to skip the gate.
369
+ async function handleConfigGet(): Promise<Response> {
370
+ if (!configSchema) {
371
+ return Response.json({ schema: null, values: {} })
383
372
  }
384
- if (request.method === 'POST' && url.pathname === '/__belte/config') {
385
- const { values } = (await request.json()) as { values: Record<string, string> }
386
- await writeConfig(values)
387
- return new Response(undefined, { status: 204 })
373
+ return Response.json({ schema: configSchema, values: await resolveConfigValues() })
374
+ }
375
+
376
+ // POST /__belte/config persist the form's answers to the data-dir `.env`.
377
+ async function handleConfigPost(request: Request): Promise<Response> {
378
+ const { values } = (await request.json()) as { values: Record<string, string> }
379
+ await writeConfig(values)
380
+ return new Response(undefined, { status: 204 })
381
+ }
382
+
383
+ // POST /connect — point the window at a remote belte server after probing it.
384
+ async function handleConnect(request: Request): Promise<Response> {
385
+ const { url: target } = (await request.json()) as { url: string }
386
+ // Verify it's actually a belte server before pointing the window at it.
387
+ const identity = await probeBelteServer(target)
388
+ if (!identity) {
389
+ log.warn(`no belte server responded at ${target}`)
390
+ return Response.json({ error: `No belte server responded at ${target}` }, { status: 502 })
388
391
  }
389
- if (request.method === 'POST' && url.pathname === '/connect') {
390
- const { url: target } = (await request.json()) as { url: string }
391
- // Verify it's actually a belte server before pointing the window at it.
392
- const identity = await probeBelteServer(target)
393
- if (!identity) {
394
- log.warn(`no belte server responded at ${target}`)
395
- return Response.json(
396
- { error: `No belte server responded at ${target}` },
397
- { status: 502 },
398
- )
399
- }
392
+ flag?.setConnected(true)
393
+ startLivenessWatch(target)
394
+ // Record the choice so the next launch reconnects here before opening.
395
+ await writeLastConnection({ kind: 'url', url: target })
396
+ log.info(`connecting to ${identity.name} at ${target}`)
397
+ return Response.json({ redirect: target })
398
+ }
399
+
400
+ // POST /start — boot the embedded server and point the window at it.
401
+ async function handleStart(): Promise<Response> {
402
+ try {
403
+ const localUrl = await startEmbeddedServer()
400
404
  flag?.setConnected(true)
401
- startLivenessWatch(target)
402
- // Record the choice so the next launch reconnects here before opening.
403
- await writeLastConnection({ kind: 'url', url: target })
404
- log.info(`connecting to ${identity.name} at ${target}`)
405
- return Response.json({ redirect: target })
406
- }
407
- if (request.method === 'POST' && url.pathname === '/start') {
408
- try {
409
- const localUrl = await startEmbeddedServer()
410
- flag?.setConnected(true)
411
- startLivenessWatch(localUrl)
412
- // Record the choice so the next launch boots the embedded server first.
413
- await writeLastConnection({ kind: 'embedded' })
414
- log.info(`started embedded server at ${localUrl}`)
415
- return Response.json({ redirect: localUrl })
416
- } catch (error) {
417
- killServerChild()
418
- return Response.json({ error: String(error) }, { status: 500 })
419
- }
420
- }
421
- if (request.method === 'GET' && url.pathname === '/__belte/disconnect') {
422
- stopLivenessWatch()
405
+ startLivenessWatch(localUrl)
406
+ // Record the choice so the next launch boots the embedded server first.
407
+ await writeLastConnection({ kind: 'embedded' })
408
+ log.info(`started embedded server at ${localUrl}`)
409
+ return Response.json({ redirect: localUrl })
410
+ } catch (error) {
423
411
  killServerChild()
424
- flag?.setConnected(false)
425
- // Forget the auto-resume choice so the next launch lands on the connect screen.
426
- await clearLastConnection()
427
- return new Response(undefined, { status: 204 })
412
+ return Response.json({ error: String(error) }, { status: 500 })
428
413
  }
429
- return new Response('not found', { status: 404 })
414
+ }
415
+
416
+ // GET /__belte/disconnect — tear down the embedded server and forget the auto-resume choice.
417
+ async function handleDisconnect(): Promise<Response> {
418
+ stopLivenessWatch()
419
+ killServerChild()
420
+ flag?.setConnected(false)
421
+ await clearLastConnection()
422
+ return new Response(undefined, { status: 204 })
423
+ }
424
+
425
+ /*
426
+ The control server's routes, keyed by `${method} ${pathname}` (exact match). The
427
+ connect screen owns localStorage + navigation; this worker owns the embedded-
428
+ server process and the native flag.
429
+ */
430
+ const controlRoutes: Record<string, (request: Request) => Promise<Response> | Response> = {
431
+ 'GET /': () => renderConnectScreen(),
432
+ 'GET /__belte/config': handleConfigGet,
433
+ 'POST /__belte/config': handleConfigPost,
434
+ 'POST /connect': handleConnect,
435
+ 'POST /start': handleStart,
436
+ 'GET /__belte/disconnect': handleDisconnect,
437
+ }
438
+
439
+ function handleControlRequest(request: Request): Promise<Response> | Response {
440
+ const { pathname } = new URL(request.url)
441
+ const route = controlRoutes[`${request.method} ${pathname}`]
442
+ return route ? route(request) : new Response('not found', { status: 404 })
430
443
  }
431
444
 
432
445
  /*
@@ -118,7 +118,7 @@ function applyState(
118
118
  const mutable = page as PageStateFor<string>
119
119
  mutable.route = route
120
120
  mutable.params = params
121
- mutable.url = new URL(window.location.href)
121
+ syncUrl()
122
122
  }
123
123
 
124
124
  function syncUrl(): void {
@@ -1,16 +1,9 @@
1
1
  import type { HttpVerb } from '../server/rpc/types/HttpVerb.ts'
2
2
  import type { RemoteFunction } from '../server/rpc/types/RemoteFunction.ts'
3
+ import { browserClientFlags } from '../shared/browserClientFlags.ts'
3
4
  import { buildRpcRequest } from '../shared/buildRpcRequest.ts'
4
5
  import { createRemoteFunction } from '../shared/createRemoteFunction.ts'
5
6
 
6
- /*
7
- The browser stub is only emitted when the verb has `clients.browser:
8
- true`, so the value is always true here. mcp/cli flags are server-only
9
- discovery state and the browser bundle has no use for them; default
10
- false so the public RemoteFunction shape stays the same on both sides.
11
- */
12
- const BROWSER_CLIENT_FLAGS = { browser: true, mcp: false, cli: false } as const
13
-
14
7
  /*
15
8
  Client-side substitute for a verb-defined handler. The bundler emits one
16
9
  call per verb export inside an `$rpc/**` module (GET / POST / …): server
@@ -31,7 +24,7 @@ export function remoteProxy<Args, Return>(
31
24
  return createRemoteFunction<Args, Return>({
32
25
  method,
33
26
  url,
34
- clients: BROWSER_CLIENT_FLAGS,
27
+ clients: browserClientFlags,
35
28
  buildRequest: (args) =>
36
29
  buildRpcRequest({ method, url, args, baseUrl: window.location.href }),
37
30
  /*
@@ -1,17 +1,10 @@
1
1
  import type { Socket } from '../server/sockets/types/Socket.ts'
2
+ import { browserClientFlags } from '../shared/browserClientFlags.ts'
2
3
  import { createPushIterator } from '../shared/createPushIterator.ts'
3
4
  import { getSocketChannel } from './socketChannel.ts'
4
5
 
5
6
  let nextId = 0
6
7
 
7
- /*
8
- Browser stub is only emitted when `clients.browser: true`, so the value
9
- is always true here. mcp/cli flags are server-only discovery state; the
10
- browser bundle has no use for them. Default false so the public Socket
11
- shape stays consistent on both sides.
12
- */
13
- const BROWSER_CLIENT_FLAGS = { browser: true, mcp: false, cli: false } as const
14
-
15
8
  /*
16
9
  Client-side substitute for a server-declared Socket. The bundler emits
17
10
  one call per socket export under `src/server/sockets/`: server target uses
@@ -54,7 +47,7 @@ export function socketProxy<T>(name: string): Socket<T> {
54
47
 
55
48
  return {
56
49
  name,
57
- clients: BROWSER_CLIENT_FLAGS,
50
+ clients: browserClientFlags,
58
51
  publish(message: T) {
59
52
  getSocketChannel().publish(name, message)
60
53
  },
@@ -1,4 +1,5 @@
1
1
  import { dirname, join } from 'node:path'
2
+ import { bundleLayout } from '../shared/bundleLayout.ts'
2
3
  import { webviewCachePath } from './webviewCachePath.ts'
3
4
  import { webviewLibName } from './webviewLibName.ts'
4
5
 
@@ -29,10 +30,11 @@ export async function resolveWebviewLib(cwd: string = process.cwd()): Promise<st
29
30
  /*
30
31
  Bundle-relative candidates. In dev `process.execPath` is the `bun`
31
32
  binary, so these miss and we fall through to the build cache; in a
32
- shipped bundle the launcher's own directory holds the lib.
33
+ shipped bundle the launcher's own directory holds the lib (flat layout)
34
+ or its sibling Frameworks dir (macOS `.app`) — bundleLayout knows which.
33
35
  */
34
- const binDir = dirname(process.execPath)
35
- const bundledCandidates = [join(binDir, libName), join(binDir, '..', 'Frameworks', libName)]
36
+ const { binDir, libDir } = bundleLayout(dirname(process.execPath))
37
+ const bundledCandidates = [join(binDir, libName), join(libDir, libName)]
36
38
  for (const candidate of bundledCandidates) {
37
39
  if (await Bun.file(candidate).exists()) {
38
40
  return candidate
@@ -1,10 +1,10 @@
1
1
  import { dirname } from 'node:path'
2
+ import { bundleLayout } from '../shared/bundleLayout.ts'
2
3
  import { loadEnvFile } from '../shared/loadEnvFile.ts'
3
- import { shippedEnvPath } from '../shared/shippedEnvPath.ts'
4
4
 
5
5
  /*
6
6
  Loads the bundle's shipped `.env` into `process.env`, resolved from the running
7
- binary's directory (`process.execPath`) via shippedEnvPath — beside the binary in
7
+ binary's directory (`process.execPath`) via bundleLayout — beside the binary in
8
8
  the flat layout, under `Contents/Resources` in a macOS `.app`. This is the file the
9
9
  install tarball ships beside the executable — and, for a bundle, the one `bundleApp`
10
10
  copies from the project's `.env.bundle`. It carries the app's shipped defaults; the
@@ -12,5 +12,5 @@ fill-when-unset merge (see loadEnvFile) lets per-shell exports, Bun's CWD
12
12
  `.env`, and the user's data-dir config all override it.
13
13
  */
14
14
  export async function loadEnvFromBinaryDir(): Promise<void> {
15
- await loadEnvFile(shippedEnvPath(dirname(process.execPath)))
15
+ await loadEnvFile(bundleLayout(dirname(process.execPath)).envPath)
16
16
  }
@@ -8,6 +8,8 @@ import { parseArgvForRpc } from './parseArgvForRpc.ts'
8
8
  import { printCommandHelp, printTopLevelHelp } from './printHelp.ts'
9
9
  import type { CliManifest } from './types/CliManifest.ts'
10
10
 
11
+ const isHelpFlag = (arg: string): boolean => arg === '--help' || arg === '-h'
12
+
11
13
  // String results print verbatim (with a trailing newline); everything else as a JSON line.
12
14
  function printValue(value: unknown, pretty: boolean): void {
13
15
  if (typeof value === 'string') {
@@ -54,12 +56,12 @@ export async function runCli({
54
56
  await loadEnvFromBinaryDir()
55
57
 
56
58
  const first = argv[0]
57
- if (!first || first === '--help' || first === '-h') {
59
+ if (!first || isHelpFlag(first)) {
58
60
  printTopLevelHelp(programName, manifest, banner, footer)
59
61
  return 0
60
62
  }
61
63
 
62
- if (argv.includes('--help') || argv.includes('-h')) {
64
+ if (argv.some(isHelpFlag)) {
63
65
  printCommandHelp(programName, first, manifest)
64
66
  return 0
65
67
  }
@@ -224,8 +224,7 @@ function dispatchVerb(
224
224
  args: Record<string, unknown> | undefined,
225
225
  inbound: Request,
226
226
  ): Promise<Response> {
227
- const inboundUrl = new URL(inbound.url)
228
- const baseUrl = `${inboundUrl.protocol}//${inboundUrl.host}/`
227
+ const baseUrl = `${new URL(inbound.url).origin}/`
229
228
  const request = buildRpcRequest({
230
229
  method: entry.remote.method,
231
230
  url: entry.remote.url,
@@ -1,3 +1,5 @@
1
+ import { serializeEnv } from '../../shared/serializeEnv.ts'
2
+
1
3
  /*
2
4
  Generates the `.env` file content shipped alongside the CLI binary in
3
5
  the download tarball. APP_URL is always present (derived from the
@@ -10,9 +12,8 @@ user's auth code at the actual RPC endpoints validates whatever value
10
12
  arrives back in subsequent calls.
11
13
  */
12
14
  export function buildEnvContent(appUrl: string, bearerToken: string | undefined): string {
13
- const lines = [`APP_URL=${appUrl}`]
14
- if (bearerToken) {
15
- lines.push(`APP_TOKEN=${bearerToken}`)
16
- }
17
- return `${lines.join('\n')}\n`
15
+ return serializeEnv({
16
+ APP_URL: appUrl,
17
+ ...(bearerToken ? { APP_TOKEN: bearerToken } : {}),
18
+ })
18
19
  }
@@ -101,8 +101,7 @@ export async function handleCliDownload(
101
101
  headers: { 'Cache-Control': NO_STORE },
102
102
  })
103
103
  }
104
- const url = new URL(request.url)
105
- const appUrl = `${url.protocol}//${url.host}`
104
+ const appUrl = new URL(request.url).origin
106
105
  const auth = request.headers.get('authorization')
107
106
  const bearer =
108
107
  auth && auth.toLowerCase().startsWith('bearer ') ? auth.slice('bearer '.length) : undefined
@@ -26,7 +26,7 @@ export function handleCliInstall(request: Request, programName: string): Respons
26
26
  headers: { 'Cache-Control': NO_STORE },
27
27
  })
28
28
  }
29
- const appUrl = `${url.protocol}//${url.host}`
29
+ const appUrl = url.origin
30
30
  const script = installScript(appUrl, programName)
31
31
  return new Response(script, {
32
32
  headers: {
@@ -1,4 +1,4 @@
1
- import { existsSync, statSync } from 'node:fs'
1
+ import { existsSync } from 'node:fs'
2
2
  import { Glob } from 'bun'
3
3
 
4
4
  /*
@@ -6,22 +6,21 @@ Returns the most-recent mtime across every rpc + socket source file in
6
6
  the project, or 0 when both directories are absent. The lazy CLI
7
7
  download path compares this to the binary's mtime to decide whether to
8
8
  rebuild — covers the common dev iteration of "user edited an rpc
9
- handler" without needing to scan transitively-imported modules.
9
+ handler" without needing to scan transitively-imported modules. Globs and
10
+ stats run concurrently since each file is independent.
10
11
  */
11
12
  export async function maxSourceMtime(cwd: string): Promise<number> {
12
- const roots = [`${cwd}/src/server/rpc`, `${cwd}/src/server/sockets`]
13
- let newest = 0
14
- for (const root of roots) {
15
- if (!existsSync(root)) {
16
- continue
17
- }
18
- const files = new Glob('**/*.ts').scan({ cwd: root, onlyFiles: true })
19
- for await (const file of files) {
20
- const stat = statSync(`${root}/${file}`)
21
- if (stat.mtimeMs > newest) {
22
- newest = stat.mtimeMs
23
- }
24
- }
25
- }
26
- return newest
13
+ const roots = [`${cwd}/src/server/rpc`, `${cwd}/src/server/sockets`].filter(existsSync)
14
+ const perRoot = await Promise.all(
15
+ roots.map(async (root) => {
16
+ const files = await Array.fromAsync(
17
+ new Glob('**/*.ts').scan({ cwd: root, onlyFiles: true }),
18
+ )
19
+ return files.map((file) => `${root}/${file}`)
20
+ }),
21
+ )
22
+ const mtimes = await Promise.all(
23
+ perRoot.flat().map(async (path) => (await Bun.file(path).stat()).mtimeMs),
24
+ )
25
+ return mtimes.reduce((newest, mtime) => Math.max(newest, mtime), 0)
27
26
  }
@@ -11,6 +11,7 @@ import { NO_STORE, SSR_CACHE_CONTROL } from '../../shared/cacheControlValues.ts'
11
11
  import { createCacheStore } from '../../shared/createCacheStore.ts'
12
12
  import { isDebugEnabled } from '../../shared/isDebugEnabled.ts'
13
13
  import { log } from '../../shared/log.ts'
14
+ import { memoizeByKey } from '../../shared/memoizeByKey.ts'
14
15
  import { nearestLayoutPrefix, normalizeLayoutPrefixes } from '../../shared/nearestLayoutPrefix.ts'
15
16
  import { toBunRoutePattern } from '../../shared/toBunRoutePattern.ts'
16
17
  import type { AppModule } from '../AppModule.ts'
@@ -43,6 +44,9 @@ function wantsJson(req: Request): boolean {
43
44
  return (req.headers.get('accept') ?? '').includes('application/json')
44
45
  }
45
46
 
47
+ // SSR placeholders the shell carries; filled in a single pass per render.
48
+ const SSR_MARKER = /<!--ssr:(head|body|state)-->/g
49
+
46
50
  /*
47
51
  The framework's default 500 response — a `<pre>` stack dump. Shared by the
48
52
  per-request catch and Bun.serve's global error() fallback so the two can't
@@ -146,12 +150,7 @@ export async function createServer({
146
150
  (file) => `/_app/${file.replace(/\.zst$/, '')}`,
147
151
  )
148
152
 
149
- const rpcModuleCache = new Map<string, Promise<AnyRemoteFunction | undefined>>()
150
- function loadRpc(url: string): Promise<AnyRemoteFunction | undefined> | undefined {
151
- const existing = rpcModuleCache.get(url)
152
- if (existing) {
153
- return existing
154
- }
153
+ const loadRpc = memoizeByKey((url): Promise<AnyRemoteFunction | undefined> | undefined => {
155
154
  const loader = rpc[url]
156
155
  if (!loader) {
157
156
  return undefined
@@ -161,7 +160,7 @@ export async function createServer({
161
160
  time. Pick the first export that looks like a RemoteFunction so the
162
161
  framework stays tolerant of incidental re-exports.
163
162
  */
164
- const promise = loader().then((mod) => {
163
+ return loader().then((mod) => {
165
164
  for (const value of Object.values(mod)) {
166
165
  if (typeof value === 'function' && 'method' in value && 'url' in value) {
167
166
  return value as AnyRemoteFunction
@@ -169,9 +168,7 @@ export async function createServer({
169
168
  }
170
169
  return undefined
171
170
  })
172
- rpcModuleCache.set(url, promise)
173
- return promise
174
- }
171
+ })
175
172
 
176
173
  const logRequests = isDebugEnabled('belte')
177
174
 
@@ -258,10 +255,12 @@ export async function createServer({
258
255
  params,
259
256
  cache: cacheSnapshot,
260
257
  })};</script>`
261
- const html = shell
262
- .replace('<!--ssr:head-->', rendered.head)
263
- .replace('<!--ssr:body-->', rendered.body)
264
- .replace('<!--ssr:state-->', stateTag)
258
+ const fills: Record<string, string> = {
259
+ head: rendered.head,
260
+ body: rendered.body,
261
+ state: stateTag,
262
+ }
263
+ const html = shell.replace(SSR_MARKER, (_match, key: string) => fills[key])
265
264
  return new Response(html, {
266
265
  headers: {
267
266
  'Content-Type': 'text/html; charset=utf-8',
@@ -1,5 +1,6 @@
1
1
  import type { ServerWebSocket } from 'bun'
2
2
  import { log } from '../../shared/log.ts'
3
+ import { memoizeByKey } from '../../shared/memoizeByKey.ts'
3
4
  import { error } from '../error.ts'
4
5
  import { json } from '../json.ts'
5
6
  import { sse } from '../sse.ts'
@@ -52,22 +53,12 @@ module triggers its `defineSocket` call, which inserts into the
52
53
  registry. After that the dispatcher just reads the registry.
53
54
  */
54
55
  export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher {
55
- const moduleCache = new Map<string, Promise<void>>()
56
56
  const connections = new WeakMap<ServerWebSocket<unknown>, ConnectionState>()
57
57
 
58
- function ensureLoaded(name: string): Promise<void> | undefined {
59
- const existing = moduleCache.get(name)
60
- if (existing) {
61
- return existing
62
- }
58
+ const ensureLoaded = memoizeByKey((name): Promise<void> | undefined => {
63
59
  const loader = sockets[name]
64
- if (!loader) {
65
- return undefined
66
- }
67
- const promise = loader().then(() => undefined)
68
- moduleCache.set(name, promise)
69
- return promise
70
- }
60
+ return loader ? loader().then(() => undefined) : undefined
61
+ })
71
62
 
72
63
  function send(ws: ServerWebSocket<unknown>, frame: SocketServerFrame): void {
73
64
  if (ws.readyState !== WebSocket.OPEN) {
@@ -111,37 +102,24 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
111
102
  state: ConnectionState,
112
103
  frame: Extract<SocketClientFrame, { type: 'sub' }>,
113
104
  ): Promise<void> {
105
+ // Reject this sub: emit the error then the terminal end frame for its id.
106
+ function fail(message: string): void {
107
+ send(ws, { type: 'err', sub: frame.sub, message })
108
+ send(ws, { type: 'end', sub: frame.sub })
109
+ }
114
110
  const loader = ensureLoaded(frame.socket)
115
111
  if (!loader) {
116
- send(ws, {
117
- type: 'err',
118
- sub: frame.sub,
119
- message: `[belte] no socket registered at ${frame.socket}`,
120
- })
121
- send(ws, { type: 'end', sub: frame.sub })
122
- return
112
+ return fail(`[belte] no socket registered at ${frame.socket}`)
123
113
  }
124
114
  try {
125
115
  await loader
126
116
  } catch (error) {
127
117
  log.error(error)
128
- send(ws, {
129
- type: 'err',
130
- sub: frame.sub,
131
- message: error instanceof Error ? error.message : String(error),
132
- })
133
- send(ws, { type: 'end', sub: frame.sub })
134
- return
118
+ return fail(error instanceof Error ? error.message : String(error))
135
119
  }
136
120
  const entry = lookupSocket(frame.socket)
137
121
  if (!entry) {
138
- send(ws, {
139
- type: 'err',
140
- sub: frame.sub,
141
- message: `[belte] socket module at ${frame.socket} did not register a Socket export`,
142
- })
143
- send(ws, { type: 'end', sub: frame.sub })
144
- return
122
+ return fail(`[belte] socket module at ${frame.socket} did not register a Socket export`)
145
123
  }
146
124
  const isFirstLocalSub = addSub(state, frame.socket, frame.sub)
147
125
  if (isFirstLocalSub) {
@@ -1,4 +1,5 @@
1
1
  import { beltePackageName } from './beltePackageName.ts'
2
+ import { readPackageJson } from './readPackageJson.ts'
2
3
 
3
4
  /*
4
5
  Resolves the bare specifier prefix a consuming project imports belte under —
@@ -17,14 +18,12 @@ resolve belte at all in that case, and the canonical name yields the clearest
17
18
  resolution error.
18
19
  */
19
20
  export async function belteImportName(cwd: string): Promise<string> {
20
- const packageJsonPath = `${cwd}/package.json`
21
- if (!(await Bun.file(packageJsonPath).exists())) {
21
+ const packageJson = (await readPackageJson(cwd)) as
22
+ | { dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
23
+ | undefined
24
+ if (!packageJson) {
22
25
  return beltePackageName
23
26
  }
24
- const packageJson = (await Bun.file(packageJsonPath).json()) as {
25
- dependencies?: Record<string, string>
26
- devDependencies?: Record<string, string>
27
- }
28
27
  const dependencies = { ...packageJson.devDependencies, ...packageJson.dependencies }
29
28
  /*
30
29
  Alias entries whose target is belte — `npm:` for a published install,
@@ -0,0 +1,10 @@
1
+ import type { ClientFlags } from './types/ClientFlags.ts'
2
+
3
+ /*
4
+ Client surface flags for a browser-emitted RPC/socket stub. The bundler only
5
+ emits the browser proxy when clients.browser is true, so browser is always true
6
+ here; mcp/cli are server-only discovery state with no meaning in the browser
7
+ bundle, defaulted false so the public RemoteFunction/Socket shape matches the
8
+ server side. Single source shared by remoteProxy and socketProxy.
9
+ */
10
+ export const browserClientFlags: ClientFlags = { browser: true, mcp: false, cli: false }
@@ -0,0 +1,36 @@
1
+ import { basename, dirname, join } from 'node:path'
2
+
3
+ /*
4
+ Where a bundle's files live relative to its binary directory (the dir holding the
5
+ launcher + server — `dirname(process.execPath)` at runtime). A macOS `.app` nests
6
+ binaries under `Contents/MacOS`, the webview lib under `Contents/Frameworks`, and
7
+ data under `Contents/Resources` — the only one `codesign` seals as a *resource*,
8
+ so the shipped `.env` survives signing. Every other platform keeps everything
9
+ flat beside the binaries. The single source the bundler writes against and the
10
+ boot readers resolve from, so build and runtime can't disagree. Pure: computes
11
+ paths, never touches disk.
12
+ */
13
+ export function bundleLayout(binaryDir: string): {
14
+ binDir: string
15
+ libDir: string
16
+ resourcesDir: string
17
+ envPath: string
18
+ } {
19
+ const isMacApp = basename(binaryDir) === 'MacOS' && basename(dirname(binaryDir)) === 'Contents'
20
+ if (isMacApp) {
21
+ const contents = dirname(binaryDir)
22
+ const resourcesDir = join(contents, 'Resources')
23
+ return {
24
+ binDir: binaryDir,
25
+ libDir: join(contents, 'Frameworks'),
26
+ resourcesDir,
27
+ envPath: join(resourcesDir, '.env'),
28
+ }
29
+ }
30
+ return {
31
+ binDir: binaryDir,
32
+ libDir: binaryDir,
33
+ resourcesDir: binaryDir,
34
+ envPath: join(binaryDir, '.env'),
35
+ }
36
+ }
@@ -0,0 +1,9 @@
1
+ import type { CompileTarget } from '../server/runtime/types/CompileTarget.ts'
2
+
3
+ /*
4
+ Executable filename suffix for a compile target — `.exe` on Windows targets,
5
+ empty elsewhere. Single source so every cross-compile output path agrees.
6
+ */
7
+ export function exeSuffix(target: CompileTarget): string {
8
+ return target.includes('windows') ? '.exe' : ''
9
+ }
@@ -0,0 +1,34 @@
1
+ import { log } from './log.ts'
2
+
3
+ /*
4
+ Builds one of belte's virtual manifest modules — the `{ key: () => import(...) }`
5
+ map the bundler emits for rpc / sockets / prompts / pages / layouts. They differ
6
+ only in their files, the key derived per file, the import dir, the export name,
7
+ and the log label; this is the single shape they share.
8
+ */
9
+ export function manifestModule(options: {
10
+ files: string[]
11
+ keyForFile: (file: string) => string
12
+ importDir: string
13
+ exportName: string
14
+ label: string
15
+ // pages logs its count even at zero (a route-less app is worth surfacing);
16
+ // the other manifests stay quiet when empty.
17
+ logWhenEmpty?: boolean
18
+ }): { contents: string; loader: 'js' } {
19
+ const entries = options.files
20
+ .toSorted()
21
+ .map((file) => ({ key: options.keyForFile(file), file }))
22
+ const lines = entries
23
+ .map(
24
+ ({ key, file }) =>
25
+ ` ${JSON.stringify(key)}: () => import(${JSON.stringify(`${options.importDir}/${file}`)}),`,
26
+ )
27
+ .join('\n')
28
+ if (entries.length > 0 || options.logWhenEmpty) {
29
+ log.info(
30
+ `resolved ${entries.length} ${options.label}: ${entries.map((entry) => entry.key).join(', ')}`,
31
+ )
32
+ }
33
+ return { contents: `export const ${options.exportName} = {\n${lines}\n}\n`, loader: 'js' }
34
+ }
@@ -0,0 +1,24 @@
1
+ /*
2
+ Memoises an async loader keyed by string: the first call for a key starts the
3
+ load and caches its promise; later calls reuse it. `load` returns undefined when
4
+ the key has no loader, which is passed through (and not cached) so the caller can
5
+ treat "unknown key" distinctly from "loaded value". Used by the rpc-module and
6
+ socket-module load caches, which share this exact shape.
7
+ */
8
+ export function memoizeByKey<T>(
9
+ load: (key: string) => Promise<T> | undefined,
10
+ ): (key: string) => Promise<T> | undefined {
11
+ const cache = new Map<string, Promise<T>>()
12
+ return (key) => {
13
+ const existing = cache.get(key)
14
+ if (existing) {
15
+ return existing
16
+ }
17
+ const started = load(key)
18
+ if (!started) {
19
+ return undefined
20
+ }
21
+ cache.set(key, started)
22
+ return started
23
+ }
24
+ }
@@ -0,0 +1,9 @@
1
+ /*
2
+ Reads and parses a project's `package.json`, or undefined when absent. Callers
3
+ apply their own field defaults — this centralizes only the exists-check + parse
4
+ boilerplate. Bun.file().json() so no Node fs.
5
+ */
6
+ export async function readPackageJson(cwd: string): Promise<Record<string, unknown> | undefined> {
7
+ const file = Bun.file(`${cwd}/package.json`)
8
+ return (await file.exists()) ? ((await file.json()) as Record<string, unknown>) : undefined
9
+ }
@@ -1,21 +0,0 @@
1
- import { basename, dirname, join } from 'node:path'
2
-
3
- /*
4
- Path of the bundle's shipped `.env` — the default config layer written at build
5
- time and read back at boot. Given the directory holding the binaries, returns
6
- where that `.env` lives.
7
-
8
- A macOS `.app` nests binaries under `Contents/MacOS`, but `codesign` seals that
9
- directory as *code*: a data file there can't survive signing and reloading. So
10
- for the `.app` layout the `.env` belongs beside the icon in `Contents/Resources`,
11
- which is sealed as a resource. Every other platform keeps the flat layout, with
12
- the `.env` next to the binaries. Pure: computes the path, never touches disk.
13
- */
14
- export function shippedEnvPath(binaryDir: string): string {
15
- const isMacAppBinaryDir =
16
- basename(binaryDir) === 'MacOS' && basename(dirname(binaryDir)) === 'Contents'
17
- if (isMacAppBinaryDir) {
18
- return join(dirname(binaryDir), 'Resources', '.env')
19
- }
20
- return join(binaryDir, '.env')
21
- }