@briancray/belte 0.1.0 → 0.2.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.
Files changed (103) hide show
  1. package/bin/belte.ts +25 -12
  2. package/package.json +2 -1
  3. package/src/appEntry.ts +124 -0
  4. package/src/belteResolverPlugin.ts +236 -202
  5. package/src/build.ts +6 -67
  6. package/src/buildCli.ts +36 -63
  7. package/src/buildDisconnected.ts +127 -0
  8. package/src/bundleApp.ts +123 -0
  9. package/src/bundleDisconnectedEntry.ts +17 -0
  10. package/src/cliEntry.ts +3 -9
  11. package/src/compile.ts +4 -15
  12. package/src/controlServerWorker.ts +261 -0
  13. package/src/dedupeSveltePlugin.ts +66 -0
  14. package/src/discoveryEntry.ts +12 -11
  15. package/src/lib/browser/cache.ts +3 -6
  16. package/src/lib/browser/page.svelte.ts +19 -21
  17. package/src/lib/browser/socketChannel.ts +11 -1
  18. package/src/lib/browser/types/Pages.ts +1 -1
  19. package/src/lib/bundle/BundleMenu.ts +11 -0
  20. package/src/lib/bundle/BundleMenuItem.ts +24 -0
  21. package/src/lib/bundle/BundleWindow.ts +20 -0
  22. package/src/lib/bundle/bindConnectedFlag.ts +29 -0
  23. package/src/lib/bundle/bindRequestNavigate.ts +31 -0
  24. package/src/lib/bundle/buildWebviewLib.ts +111 -0
  25. package/src/lib/bundle/disconnected.css +9 -0
  26. package/src/lib/bundle/disconnected.svelte +192 -0
  27. package/src/lib/bundle/ensureWebviewLib.ts +20 -0
  28. package/src/lib/bundle/exitWithParent.ts +28 -0
  29. package/src/lib/bundle/findFreePort.ts +14 -0
  30. package/src/lib/bundle/infoPlist.ts +46 -0
  31. package/src/lib/bundle/installMacMenu.ts +39 -0
  32. package/src/lib/bundle/listenLocalControlServer.ts +19 -0
  33. package/src/lib/bundle/native/belteMenu.mm +298 -0
  34. package/src/lib/bundle/native/webview.h +4557 -0
  35. package/src/lib/bundle/onMenu.ts +26 -0
  36. package/src/lib/bundle/openWebview.ts +81 -0
  37. package/src/lib/bundle/pngToIcns.ts +47 -0
  38. package/src/lib/bundle/probeBelteServer.ts +34 -0
  39. package/src/lib/bundle/resolveServerBinary.ts +12 -0
  40. package/src/lib/bundle/resolveWebviewLib.ts +51 -0
  41. package/src/lib/bundle/serverBinaryFilename.ts +8 -0
  42. package/src/lib/bundle/stableLocalPort.ts +19 -0
  43. package/src/lib/bundle/waitForServer.ts +23 -0
  44. package/src/lib/bundle/webviewBuildRevision.ts +9 -0
  45. package/src/lib/bundle/webviewCachePath.ts +23 -0
  46. package/src/lib/bundle/webviewLibName.ts +11 -0
  47. package/src/lib/bundle/webviewVersion.ts +7 -0
  48. package/src/lib/cli/createClient.ts +34 -36
  49. package/src/lib/cli/printHelp.ts +45 -2
  50. package/src/lib/cli/runCli.ts +12 -3
  51. package/src/lib/mcp/createMcpResourceServer.ts +1 -1
  52. package/src/lib/mcp/dispatchMcpRequest.ts +53 -73
  53. package/src/lib/server/AppModule.ts +2 -2
  54. package/src/lib/server/cli/handleCliDownload.ts +4 -5
  55. package/src/lib/server/cli/handleCliInstall.ts +17 -0
  56. package/src/lib/server/error.ts +23 -9
  57. package/src/lib/server/json.ts +5 -5
  58. package/src/lib/server/jsonl.ts +10 -5
  59. package/src/lib/server/prompts/definePrompt.ts +6 -6
  60. package/src/lib/server/prompts/renderPromptTemplate.ts +16 -0
  61. package/src/lib/server/prompts/types/Prompt.ts +8 -9
  62. package/src/lib/server/prompts/types/PromptOptions.ts +7 -12
  63. package/src/lib/server/prompts/types/PromptRegistryEntry.ts +3 -5
  64. package/src/lib/server/prompts/types/PromptRoutes.ts +4 -4
  65. package/src/lib/server/redirect.ts +13 -8
  66. package/src/lib/server/rpc/defineVerb.ts +4 -3
  67. package/src/lib/server/rpc/findVerbByCommandName.ts +18 -0
  68. package/src/lib/server/rpc/types/RemoteFunction.ts +1 -1
  69. package/src/lib/server/rpc/types/RemoteHandler.ts +4 -0
  70. package/src/lib/server/runtime/acceptsZstd.ts +8 -0
  71. package/src/lib/server/runtime/buildOpenApiSpec.ts +2 -0
  72. package/src/lib/server/runtime/cacheControlForAsset.ts +7 -2
  73. package/src/lib/server/runtime/createAssetHeaderCache.ts +35 -0
  74. package/src/lib/server/runtime/createPublicAssetServer.ts +6 -20
  75. package/src/lib/server/runtime/createServer.ts +50 -58
  76. package/src/lib/server/runtime/registryManifests.ts +33 -15
  77. package/src/lib/server/runtime/types/RequestStore.ts +2 -3
  78. package/src/lib/server/runtime/withResponseDefaults.ts +24 -0
  79. package/src/lib/server/sockets/createSocketDispatcher.ts +10 -7
  80. package/src/lib/server/sse.ts +10 -5
  81. package/src/lib/shared/belteImportName.test.ts +58 -0
  82. package/src/lib/shared/belteImportName.ts +45 -0
  83. package/src/lib/shared/beltePackageName.ts +7 -0
  84. package/src/lib/shared/cacheControlValues.ts +10 -2
  85. package/src/lib/shared/canonicalJson.ts +1 -5
  86. package/src/lib/shared/createCacheStore.ts +29 -20
  87. package/src/lib/shared/exitOnBuildFailure.ts +17 -0
  88. package/src/lib/shared/fileStem.ts +9 -0
  89. package/src/lib/shared/jsonSchemaForPromptArguments.ts +29 -0
  90. package/src/lib/shared/keyForRemoteCall.ts +7 -5
  91. package/src/lib/shared/parsePromptMarkdown.ts +34 -0
  92. package/src/lib/shared/prepareRpcModule.ts +14 -4
  93. package/src/lib/shared/prepareSocketModule.ts +16 -2
  94. package/src/lib/shared/promptNameForFile.ts +5 -5
  95. package/src/lib/shared/subscribableFromResponse.ts +104 -215
  96. package/src/lib/shared/types/PromptArgument.ts +12 -0
  97. package/src/lib/shared/writeRoutesDts.ts +5 -7
  98. package/src/serverBuildPlugins.ts +25 -0
  99. package/src/serverEntry.ts +4 -0
  100. package/template/package.json +3 -2
  101. package/src/lib/server/prompt.ts +0 -30
  102. package/src/lib/server/prompts/types/PromptMessage.ts +0 -10
  103. package/src/lib/shared/preparePromptModule.ts +0 -36
@@ -1,3 +1,8 @@
1
+ import {
2
+ IMMUTABLE_ASSET_CACHE_CONTROL,
3
+ REVALIDATE_ASSET_CACHE_CONTROL,
4
+ } from '../../shared/cacheControlValues.ts'
5
+
1
6
  /*
2
7
  Bun.build emits `[name]-[hash].[ext]` for chunks; hash is alnum and >=8 chars.
3
8
  Source maps inherit the same name (e.g. foo-abc12345.js.map), so the suffix may be `.map`.
@@ -11,7 +16,7 @@ Hashed chunk filenames are content-addressed and immutable; everything else
11
16
  */
12
17
  export function cacheControlForAsset(pathname: string): string {
13
18
  if (HASHED.test(pathname)) {
14
- return 'public, max-age=31536000, immutable'
19
+ return IMMUTABLE_ASSET_CACHE_CONTROL
15
20
  }
16
- return 'public, max-age=0, must-revalidate'
21
+ return REVALIDATE_ASSET_CACHE_CONTROL
17
22
  }
@@ -0,0 +1,35 @@
1
+ import { mimeForExtension } from './mimeForExtension.ts'
2
+
3
+ /*
4
+ A static-asset response's headers depend only on its pathname (extension →
5
+ Content-Type, path → Cache-Control), so each distinct pathname's header bundle
6
+ is built once and reused across every hit on that chunk — avoiding a per-request
7
+ allocation on a cold page load that pulls dozens of files. Each bundle carries
8
+ the plain `base` headers plus a `zstd` variant with `Content-Encoding: zstd`.
9
+ `cacheControlFor` lets callers vary the policy: hashed-aware for `/_app/`,
10
+ fixed for public/.
11
+ */
12
+ type AssetHeaderBundle = {
13
+ base: HeadersInit
14
+ zstd: HeadersInit
15
+ }
16
+
17
+ export function createAssetHeaderCache(
18
+ cacheControlFor: (pathname: string) => string,
19
+ ): (pathname: string) => AssetHeaderBundle {
20
+ const cache = new Map<string, AssetHeaderBundle>()
21
+ return function headersFor(pathname) {
22
+ const cached = cache.get(pathname)
23
+ if (cached) {
24
+ return cached
25
+ }
26
+ const base: HeadersInit = {
27
+ 'Content-Type': mimeForExtension(pathname),
28
+ Vary: 'Accept-Encoding',
29
+ 'Cache-Control': cacheControlFor(pathname),
30
+ }
31
+ const bundle: AssetHeaderBundle = { base, zstd: { ...base, 'Content-Encoding': 'zstd' } }
32
+ cache.set(pathname, bundle)
33
+ return bundle
34
+ }
35
+ }
@@ -1,9 +1,9 @@
1
+ import { PUBLIC_ASSET_CACHE_CONTROL } from '../../shared/cacheControlValues.ts'
2
+ import { acceptsZstd } from './acceptsZstd.ts'
1
3
  import { containsTraversal } from './containsTraversal.ts'
2
- import { mimeForExtension } from './mimeForExtension.ts'
4
+ import { createAssetHeaderCache } from './createAssetHeaderCache.ts'
3
5
  import type { Assets } from './types/Assets.ts'
4
6
 
5
- const PUBLIC_CACHE_CONTROL = 'public, max-age=3600'
6
-
7
7
  /*
8
8
  Serves files from the project's `public/` folder at the site root. Two
9
9
  sources, picked at construction:
@@ -25,27 +25,13 @@ export function createPublicAssetServer({
25
25
  publicDir: string
26
26
  publicAssets?: Assets
27
27
  }): (req: Request, url: URL) => Promise<Response | undefined> {
28
- const headerCache = new Map<string, { base: HeadersInit; zstd: HeadersInit }>()
29
- function headersFor(pathname: string): { base: HeadersInit; zstd: HeadersInit } {
30
- const cached = headerCache.get(pathname)
31
- if (cached) {
32
- return cached
33
- }
34
- const base: HeadersInit = {
35
- 'Content-Type': mimeForExtension(pathname),
36
- Vary: 'Accept-Encoding',
37
- 'Cache-Control': PUBLIC_CACHE_CONTROL,
38
- }
39
- const bundle = { base, zstd: { ...base, 'Content-Encoding': 'zstd' } }
40
- headerCache.set(pathname, bundle)
41
- return bundle
42
- }
28
+ const headersFor = createAssetHeaderCache(() => PUBLIC_ASSET_CACHE_CONTROL)
43
29
 
44
30
  return async function servePublicAsset(req, url) {
45
31
  if (containsTraversal(req.url)) {
46
32
  return undefined
47
33
  }
48
- const wantsZstd = (req.headers.get('accept-encoding') ?? '').toLowerCase().includes('zstd')
34
+ const wantsZstd = acceptsZstd(req)
49
35
  const { base, zstd } = headersFor(url.pathname)
50
36
  if (publicAssets) {
51
37
  const compressed = publicAssets[url.pathname]
@@ -55,7 +41,7 @@ export function createPublicAssetServer({
55
41
  if (wantsZstd) {
56
42
  return new Response(compressed, { headers: zstd })
57
43
  }
58
- return new Response(Bun.zstdDecompressSync(compressed), { headers: base })
44
+ return new Response(await Bun.zstdDecompress(compressed), { headers: base })
59
45
  }
60
46
  const file = Bun.file(publicDir + url.pathname)
61
47
  if (!(await file.exists())) {
@@ -23,11 +23,12 @@ import type { RemoteFunction } from '../rpc/types/RemoteFunction.ts'
23
23
  import type { RemoteRoutes } from '../rpc/types/RemoteRoutes.ts'
24
24
  import { createSocketDispatcher } from '../sockets/createSocketDispatcher.ts'
25
25
  import type { SocketRoutes } from '../sockets/types/SocketRoutes.ts'
26
+ import { acceptsZstd } from './acceptsZstd.ts'
26
27
  import { buildOpenApiSpec } from './buildOpenApiSpec.ts'
27
28
  import { cacheControlForAsset } from './cacheControlForAsset.ts'
28
29
  import { containsTraversal } from './containsTraversal.ts'
30
+ import { createAssetHeaderCache } from './createAssetHeaderCache.ts'
29
31
  import { createPublicAssetServer } from './createPublicAssetServer.ts'
30
- import { mimeForExtension } from './mimeForExtension.ts'
31
32
  import { ensureRegistriesLoaded, setRegistryManifests } from './registryManifests.ts'
32
33
  import { requestContext } from './requestContext.ts'
33
34
  import { safeJsonForScript } from './safeJsonForScript.ts'
@@ -36,18 +37,38 @@ import { setActiveServer } from './setActiveServer.ts'
36
37
  import type { Assets } from './types/Assets.ts'
37
38
  import type { RequestStore } from './types/RequestStore.ts'
38
39
 
39
- function acceptsZstd(req: Request): boolean {
40
- return (req.headers.get('accept-encoding') ?? '').toLowerCase().includes('zstd')
41
- }
42
-
43
40
  function wantsJson(req: Request): boolean {
44
41
  return (req.headers.get('accept') ?? '').includes('application/json')
45
42
  }
46
43
 
44
+ /*
45
+ The framework's default 500 response — a `<pre>` stack dump. Shared by the
46
+ per-request catch and Bun.serve's global error() fallback so the two can't
47
+ drift. Only reached when the app supplies no `handleError` hook.
48
+ */
49
+ function internalErrorResponse(err: unknown): Response {
50
+ return new Response(`<pre>${String((err as Error)?.stack ?? err)}</pre>`, {
51
+ status: 500,
52
+ headers: {
53
+ 'Content-Type': 'text/html; charset=utf-8',
54
+ 'Cache-Control': NO_STORE,
55
+ },
56
+ })
57
+ }
58
+
59
+ const IDENTITY_PATH = '/__belte/identity'
47
60
  const SOCKETS_PATH = '/__belte/sockets'
48
61
  const MCP_PATH = '/__belte/mcp'
49
62
  const CLI_PATH = '/__belte/cli'
50
63
  const CLI_DOWNLOAD_PREFIX = '/__belte/cli/'
64
+ /*
65
+ Unlike the framework's own plumbing routes above (the socket multiplex, MCP
66
+ endpoint, CLI download), the OpenAPI document describes the app's public HTTP
67
+ surface — the /rpc/* verbs — rather than belte internals, so it sits at the
68
+ conventional root path where external tooling and scanners expect to find it
69
+ (/openapi.json, alongside /swagger.json, /.well-known/*) rather than under the
70
+ /__belte/ namespace.
71
+ */
51
72
  const OPENAPI_PATH = '/openapi.json'
52
73
 
53
74
  type AnyRemoteFunction = RemoteFunction<unknown, unknown>
@@ -105,12 +126,6 @@ export async function createServer({
105
126
  const cliName = cliProgramName ?? 'app'
106
127
  const cliCwd = process.cwd()
107
128
  const servePublicAsset = createPublicAssetServer({ publicDir, publicAssets })
108
- /*
109
- Forward-declared so the per-request closures below can reference it. The
110
- value is assigned by Bun.serve() further down; closures only fire after
111
- that, so the read-before-write is safe at runtime.
112
- */
113
- let server!: Server<unknown>
114
129
  const layoutPrefixes = layouts ? normalizeLayoutPrefixes(Object.keys(layouts)) : []
115
130
 
116
131
  const diskZstdPaths = new Set<string>(
@@ -150,32 +165,8 @@ export async function createServer({
150
165
 
151
166
  const logRequests = isDebugEnabled('belte')
152
167
 
153
- /*
154
- Header objects for a pathname depend only on the pathname's extension
155
- and the immutable HASHED test. Cache them so repeat hits on the same
156
- chunk reuse a single frozen header bag instead of allocating per
157
- request.
158
- */
159
- type AssetHeaderBundle = {
160
- base: HeadersInit
161
- zstd: HeadersInit
162
- }
163
- const assetHeaderCache = new Map<string, AssetHeaderBundle>()
164
- function headersForAsset(pathname: string): AssetHeaderBundle {
165
- const cached = assetHeaderCache.get(pathname)
166
- if (cached) {
167
- return cached
168
- }
169
- const base: HeadersInit = {
170
- 'Content-Type': mimeForExtension(pathname),
171
- Vary: 'Accept-Encoding',
172
- 'Cache-Control': cacheControlForAsset(pathname),
173
- }
174
- const zstd: HeadersInit = { ...base, 'Content-Encoding': 'zstd' }
175
- const bundle = { base, zstd }
176
- assetHeaderCache.set(pathname, bundle)
177
- return bundle
178
- }
168
+ // Per-pathname asset header bundles, hashed-chunk-aware Cache-Control.
169
+ const headersForAsset = createAssetHeaderCache(cacheControlForAsset)
179
170
 
180
171
  async function serveStaticAsset(req: Request, url: URL): Promise<Response> {
181
172
  /*
@@ -206,7 +197,7 @@ export async function createServer({
206
197
  if (wantsZstd) {
207
198
  return new Response(compressed, { headers: zstdHeaders })
208
199
  }
209
- return new Response(Bun.zstdDecompressSync(compressed), { headers: baseHeaders })
200
+ return new Response(await Bun.zstdDecompress(compressed), { headers: baseHeaders })
210
201
  }
211
202
  const diskPath = distDir + url.pathname
212
203
  if (wantsZstd && diskZstdPaths.has(url.pathname)) {
@@ -374,9 +365,7 @@ export async function createServer({
374
365
  const store: RequestStore = {
375
366
  url,
376
367
  req,
377
- signal: req.signal,
378
368
  cache: createCacheStore(),
379
- server,
380
369
  }
381
370
  return requestContext.run(store, async () => {
382
371
  const start = logRequests ? Bun.nanoseconds() : 0
@@ -388,16 +377,7 @@ export async function createServer({
388
377
  response = await app.handleError(error, req)
389
378
  } else {
390
379
  log.error(error)
391
- response = new Response(
392
- `<pre>${String((error as Error)?.stack ?? error)}</pre>`,
393
- {
394
- status: 500,
395
- headers: {
396
- 'Content-Type': 'text/html; charset=utf-8',
397
- 'Cache-Control': NO_STORE,
398
- },
399
- },
400
- )
380
+ response = internalErrorResponse(error)
401
381
  }
402
382
  }
403
383
  if (logRequests) {
@@ -417,7 +397,8 @@ export async function createServer({
417
397
  a busy socket doesn't iterate JS per subscriber per message.
418
398
  */
419
399
  const socketDispatcher = createSocketDispatcher(sockets)
420
- server = Bun.serve({
400
+ // Server<unknown> pins Bun's WebSocketData generic so upgrade({ data: {} }) typechecks.
401
+ const server: Server<unknown> = Bun.serve({
421
402
  port,
422
403
 
423
404
  websocket: {
@@ -436,6 +417,23 @@ export async function createServer({
436
417
 
437
418
  async fetch(req, bunServer) {
438
419
  const url = new URL(req.url)
420
+ /*
421
+ Identity probe — answered directly, ahead of any app.handle middleware,
422
+ so the bundle's connect screen can confirm a URL really is a belte
423
+ server (and which app) before pointing the desktop window at it. It
424
+ must stay reachable even when the app guards everything behind auth,
425
+ hence the early return that bypasses dispatchRequest.
426
+ */
427
+ if (url.pathname === IDENTITY_PATH) {
428
+ return Response.json(
429
+ {
430
+ belte: true,
431
+ name: appInfo?.name ?? cliName,
432
+ version: appInfo?.version ?? '0.0.0',
433
+ },
434
+ { headers: { 'Cache-Control': NO_STORE } },
435
+ )
436
+ }
439
437
  if (url.pathname === SOCKETS_PATH) {
440
438
  if (bunServer.upgrade(req, { data: {} })) {
441
439
  return undefined as unknown as Response
@@ -515,13 +513,7 @@ export async function createServer({
515
513
 
516
514
  error(err) {
517
515
  log.error(err)
518
- return new Response(`<pre>${String(err.stack ?? err)}</pre>`, {
519
- status: 500,
520
- headers: {
521
- 'Content-Type': 'text/html; charset=utf-8',
522
- 'Cache-Control': NO_STORE,
523
- },
524
- })
516
+ return internalErrorResponse(err)
525
517
  },
526
518
  })
527
519
 
@@ -20,29 +20,47 @@ type RegistryManifests = {
20
20
  }
21
21
 
22
22
  let manifests: RegistryManifests | undefined
23
- let loadedAll = false
23
+ let loading: Promise<void> | undefined
24
24
 
25
25
  export function setRegistryManifests(value: RegistryManifests): void {
26
26
  manifests = value
27
- loadedAll = false
27
+ loading = undefined
28
28
  }
29
29
 
30
30
  /*
31
31
  On first call, eagerly imports every rpc + socket + prompt module so
32
32
  defineVerb / defineSocket / definePrompt fire and populate the
33
- registries. Idempotent — repeat calls are no-ops. Eager loading is
34
- acceptable here because enumeration (MCP tool/resource/prompt lists,
35
- the OpenAPI document) fundamentally requires the full surface; the
36
- alternative of per-call lazy loading produces flaky first-call latency.
33
+ registries. Idempotent — repeat calls reuse the same in-flight promise,
34
+ so concurrent first requests (e.g. /openapi.json + an MCP tools/list)
35
+ trigger exactly one load instead of racing to fire the full import set
36
+ each. Eager loading is acceptable here because enumeration (MCP
37
+ tool/resource/prompt lists, the OpenAPI document) fundamentally requires
38
+ the full surface; the alternative of per-call lazy loading produces flaky
39
+ first-call latency.
37
40
  */
38
- export async function ensureRegistriesLoaded(): Promise<void> {
39
- if (loadedAll || !manifests) {
40
- return
41
+ export function ensureRegistriesLoaded(): Promise<void> {
42
+ if (!manifests) {
43
+ return Promise.resolve()
41
44
  }
42
- await Promise.all([
43
- ...Object.values(manifests.rpc).map((loader) => loader()),
44
- ...Object.values(manifests.sockets).map((loader) => loader()),
45
- ...Object.values(manifests.prompts).map((loader) => loader()),
46
- ])
47
- loadedAll = true
45
+ if (!loading) {
46
+ const { rpc, sockets, prompts } = manifests
47
+ loading = Promise.all([
48
+ ...Object.values(rpc).map((loader) => loader()),
49
+ ...Object.values(sockets).map((loader) => loader()),
50
+ ...Object.values(prompts).map((loader) => loader()),
51
+ ])
52
+ .then(() => undefined)
53
+ /*
54
+ Clear the memo on failure so a transient import error (a
55
+ module that throws at load, fixed by the next HMR pass)
56
+ doesn't poison every later enumeration request for the
57
+ process lifetime. The rejection still propagates to this
58
+ caller; the reset only affects subsequent calls.
59
+ */
60
+ .catch((error) => {
61
+ loading = undefined
62
+ throw error
63
+ })
64
+ }
65
+ return loading
48
66
  }
@@ -1,15 +1,14 @@
1
- import type { Server } from 'bun'
2
1
  import type { CacheStore } from '../../../shared/types/CacheStore.ts'
3
2
 
4
3
  /*
5
4
  Per-request state propagated through AsyncLocalStorage. Every field is
6
5
  populated once at the server's fetch boundary; helpers and verb-defined
7
6
  remote functions read from it without threading arguments through user code.
7
+ The inbound request's AbortSignal is reached via `req.signal` rather than a
8
+ separate field.
8
9
  */
9
10
  export type RequestStore = {
10
11
  url: URL
11
12
  req: Request
12
- signal: AbortSignal
13
13
  cache: CacheStore
14
- server: Server<unknown>
15
14
  }
@@ -0,0 +1,24 @@
1
+ /*
2
+ Merges a caller's `ResponseInit` over a respond helper's default headers.
3
+ The helper's defaults seed the header set; the caller's headers overlay them
4
+ per-key (so an explicit `cache-control` wins over the helper's `no-store`),
5
+ and the rest of `init` (status, statusText) passes straight through. Shared
6
+ by `json` / `jsonl` / `sse` / `error` / `redirect` so every helper accepts a
7
+ final `ResponseInit` with identical override semantics.
8
+
9
+ A helper that owns the status itself (`error`, `redirect`) passes it as the
10
+ final `status` argument; it is applied last so it always wins over any
11
+ `init.status`, keeping that precedence inside the helper rather than relying
12
+ on each call site spreading in the right order.
13
+ */
14
+ export function withResponseDefaults(
15
+ init: ResponseInit | undefined,
16
+ defaultHeaders: Record<string, string>,
17
+ status?: number,
18
+ ): ResponseInit {
19
+ const headers = new Headers(defaultHeaders)
20
+ new Headers(init?.headers).forEach((value, key) => {
21
+ headers.set(key, value)
22
+ })
23
+ return { ...init, headers, ...(status !== undefined && { status }) }
24
+ }
@@ -3,6 +3,10 @@ import { log } from '../../shared/log.ts'
3
3
  import { lookupSocket } from './lookupSocket.ts'
4
4
  import type { SocketClientFrame } from './types/SocketClientFrame.ts'
5
5
  import type { SocketRoutes } from './types/SocketRoutes.ts'
6
+ import type { SocketServerFrame } from './types/SocketServerFrame.ts'
7
+
8
+ // Reused across every inbound binary frame rather than allocated per message.
9
+ const textDecoder = new TextDecoder()
6
10
 
7
11
  type SocketDispatcher = {
8
12
  open(ws: ServerWebSocket<unknown>): void
@@ -60,8 +64,8 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
60
64
  return promise
61
65
  }
62
66
 
63
- function send(ws: ServerWebSocket<unknown>, frame: unknown): void {
64
- if (ws.readyState !== 1) {
67
+ function send(ws: ServerWebSocket<unknown>, frame: SocketServerFrame): void {
68
+ if (ws.readyState !== WebSocket.OPEN) {
65
69
  return
66
70
  }
67
71
  ws.send(JSON.stringify(frame))
@@ -154,10 +158,9 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
154
158
  const replayCount =
155
159
  frame.replay === undefined ? history.length : Math.min(frame.replay, history.length)
156
160
  if (replayCount > 0) {
157
- const start = history.length - replayCount
158
- for (let index = start; index < history.length; index++) {
159
- send(ws, { type: 'msg', socket: frame.socket, message: history[index] })
160
- }
161
+ history.slice(history.length - replayCount).forEach((message) => {
162
+ send(ws, { type: 'msg', socket: frame.socket, message })
163
+ })
161
164
  }
162
165
  }
163
166
 
@@ -232,7 +235,7 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
232
235
  if (!state) {
233
236
  return
234
237
  }
235
- const text = typeof data === 'string' ? data : data.toString('utf8')
238
+ const text = typeof data === 'string' ? data : textDecoder.decode(data)
236
239
  let frame: SocketClientFrame
237
240
  try {
238
241
  frame = JSON.parse(text) as SocketClientFrame
@@ -26,22 +26,27 @@ maps it to the entry's `error` field.
26
26
  import { NO_STORE } from '../shared/cacheControlValues.ts'
27
27
  import type { TypedResponse } from './rpc/types/TypedResponse.ts'
28
28
  import { streamFromIterator } from './runtime/streamFromIterator.ts'
29
+ import { withResponseDefaults } from './runtime/withResponseDefaults.ts'
29
30
 
30
31
  const KEEPALIVE_INTERVAL_MS = 15000
31
32
 
32
- export function sse<Frame>(iterable: AsyncIterable<Frame>): TypedResponse<Frame> {
33
+ export function sse<Frame>(
34
+ iterable: AsyncIterable<Frame>,
35
+ init?: ResponseInit,
36
+ ): TypedResponse<Frame> {
33
37
  const body = streamFromIterator(iterable, {
34
38
  encodeFrame: (value) => `data: ${JSON.stringify(value)}\n\n`,
35
39
  encodeError: (message) => `event: error\ndata: ${JSON.stringify({ message })}\n\n`,
36
40
  keepaliveMs: KEEPALIVE_INTERVAL_MS,
37
41
  keepalivePayload: ': keepalive\n\n',
38
42
  })
39
- return new Response(body, {
40
- headers: {
43
+ return new Response(
44
+ body,
45
+ withResponseDefaults(init, {
41
46
  'Content-Type': 'text/event-stream; charset=utf-8',
42
47
  'Cache-Control': NO_STORE,
43
48
  'X-Content-Type-Options': 'nosniff',
44
49
  Connection: 'keep-alive',
45
- },
46
- }) as TypedResponse<Frame>
50
+ }),
51
+ ) as TypedResponse<Frame>
47
52
  }
@@ -0,0 +1,58 @@
1
+ import { afterAll, expect, test } from 'bun:test'
2
+ import { mkdtempSync, rmSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { belteImportName } from './belteImportName.ts'
5
+
6
+ const roots: string[] = []
7
+ afterAll(() => roots.forEach((root) => rmSync(root, { recursive: true, force: true })))
8
+
9
+ // Writes a package.json into a fresh temp dir and returns the dir.
10
+ async function projectWith(packageJson: unknown): Promise<string> {
11
+ const root = mkdtempSync(`${tmpdir()}/belte-import-name-`)
12
+ roots.push(root)
13
+ await Bun.write(`${root}/package.json`, JSON.stringify(packageJson))
14
+ return root
15
+ }
16
+
17
+ test('uses the canonical name for a direct dependency', async () => {
18
+ const cwd = await projectWith({ dependencies: { '@briancray/belte': '^0.2.0' } })
19
+ expect(await belteImportName(cwd)).toBe('@briancray/belte')
20
+ })
21
+
22
+ test('uses the `belte` alias key for an npm alias', async () => {
23
+ const cwd = await projectWith({ dependencies: { belte: 'npm:@briancray/belte@^0.2.0' } })
24
+ expect(await belteImportName(cwd)).toBe('belte')
25
+ })
26
+
27
+ test('uses the `belte` alias key for a workspace alias', async () => {
28
+ const cwd = await projectWith({ dependencies: { belte: 'workspace:@briancray/belte@*' } })
29
+ expect(await belteImportName(cwd)).toBe('belte')
30
+ })
31
+
32
+ test('uses a non-`belte` alias key when that is how belte is declared', async () => {
33
+ const cwd = await projectWith({ dependencies: { framework: 'npm:@briancray/belte' } })
34
+ expect(await belteImportName(cwd)).toBe('framework')
35
+ })
36
+
37
+ test('prefers the `belte` alias over a direct canonical dependency', async () => {
38
+ const cwd = await projectWith({
39
+ dependencies: { '@briancray/belte': '^0.2.0', belte: 'npm:@briancray/belte@^0.2.0' },
40
+ })
41
+ expect(await belteImportName(cwd)).toBe('belte')
42
+ })
43
+
44
+ test('finds the alias in devDependencies', async () => {
45
+ const cwd = await projectWith({ devDependencies: { belte: 'npm:@briancray/belte@^0.2.0' } })
46
+ expect(await belteImportName(cwd)).toBe('belte')
47
+ })
48
+
49
+ test('falls back to the canonical name when belte is absent', async () => {
50
+ const cwd = await projectWith({ dependencies: { svelte: '^5.0.0' } })
51
+ expect(await belteImportName(cwd)).toBe('@briancray/belte')
52
+ })
53
+
54
+ test('falls back to the canonical name when package.json is missing', async () => {
55
+ const root = mkdtempSync(`${tmpdir()}/belte-import-name-`)
56
+ roots.push(root)
57
+ expect(await belteImportName(root)).toBe('@briancray/belte')
58
+ })
@@ -0,0 +1,45 @@
1
+ import { beltePackageName } from './beltePackageName.ts'
2
+
3
+ /*
4
+ Resolves the bare specifier prefix a consuming project imports belte under —
5
+ the name belte is installed as in its package.json. A project may depend on
6
+ belte directly (`@briancray/belte`) or behind a package alias
7
+ (`"belte": "npm:@briancray/belte@..."`, or `workspace:@briancray/belte@*`
8
+ inside this repo). An alias-only install resolves only under the alias key and
9
+ a direct install only under the canonical name, so the generated rpc / socket
10
+ / prompt modules must import under whichever name the project actually
11
+ declared.
12
+
13
+ Prefers a `belte` alias (the ergonomic surface the docs use) when present, then
14
+ a direct canonical dependency, then any other alias targeting belte. Falls back
15
+ to the canonical name when belte isn't found in package.json — the build can't
16
+ resolve belte at all in that case, and the canonical name yields the clearest
17
+ resolution error.
18
+ */
19
+ export async function belteImportName(cwd: string): Promise<string> {
20
+ const packageJsonPath = `${cwd}/package.json`
21
+ if (!(await Bun.file(packageJsonPath).exists())) {
22
+ return beltePackageName
23
+ }
24
+ const packageJson = (await Bun.file(packageJsonPath).json()) as {
25
+ dependencies?: Record<string, string>
26
+ devDependencies?: Record<string, string>
27
+ }
28
+ const dependencies = { ...packageJson.devDependencies, ...packageJson.dependencies }
29
+ /*
30
+ Alias entries whose target is belte — `npm:` for a published install,
31
+ `workspace:` for the in-repo examples. The key is the name the project
32
+ imports under; the version suffix (`@^0.2.0`, `@*`) is optional.
33
+ */
34
+ const aliasPattern = new RegExp(`^(npm|workspace):${beltePackageName}(@.*)?$`)
35
+ const aliasNames = Object.entries(dependencies)
36
+ .filter(([, specifier]) => aliasPattern.test(specifier))
37
+ .map(([name]) => name)
38
+ if (aliasNames.includes('belte')) {
39
+ return 'belte'
40
+ }
41
+ if (beltePackageName in dependencies) {
42
+ return beltePackageName
43
+ }
44
+ return aliasNames[0] ?? beltePackageName
45
+ }
@@ -0,0 +1,7 @@
1
+ /*
2
+ The package's published npm name. The codegen and the import-name resolver
3
+ match a consuming project's dependency (direct or aliased) against this to
4
+ decide which specifier to emit, so keeping it in one place means a future
5
+ rename touches a single constant.
6
+ */
7
+ export const beltePackageName = '@briancray/belte'
@@ -1,8 +1,16 @@
1
1
  /*
2
2
  Cache-Control values used by belte's framework responses. Centralised so
3
3
  the framework's policy (no-store on errors and rpc dispatch helpers,
4
- private/no-cache on SSR HTML) lives in one place and can't drift between
5
- the server core and the respond helpers.
4
+ private/no-cache on SSR HTML, the static-asset policies) lives in one place
5
+ and can't drift between the server core, the asset servers, and the respond
6
+ helpers.
6
7
  */
7
8
  export const NO_STORE = 'no-store'
8
9
  export const SSR_CACHE_CONTROL = 'private, no-cache'
10
+
11
+ // Content-addressed `/_app/` chunks (name carries the hash) — cache forever.
12
+ export const IMMUTABLE_ASSET_CACHE_CONTROL = 'public, max-age=31536000, immutable'
13
+ // Unhashed `/_app/` entries (entry bundle, shell) — must revalidate each time.
14
+ export const REVALIDATE_ASSET_CACHE_CONTROL = 'public, max-age=0, must-revalidate'
15
+ // Files served from public/ at the site root — short shared cache.
16
+ export const PUBLIC_ASSET_CACHE_CONTROL = 'public, max-age=3600'
@@ -16,9 +16,5 @@ function sortedKeysReplacer(_key: string, value: unknown): unknown {
16
16
  }
17
17
  const record = value as Record<string, unknown>
18
18
  const sortedKeys = Object.keys(record).sort()
19
- const sorted: Record<string, unknown> = {}
20
- for (const key of sortedKeys) {
21
- sorted[key] = record[key]
22
- }
23
- return sorted
19
+ return Object.fromEntries(sortedKeys.map((key) => [key, record[key]]))
24
20
  }