@briancray/belte 0.5.3 → 0.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@briancray/belte",
3
- "version": "0.5.3",
3
+ "version": "0.6.0",
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",
@@ -42,7 +42,6 @@
42
42
  "./browser/navigate": "./src/lib/browser/page.svelte.ts",
43
43
  "./browser/HttpError": "./src/lib/server/HttpError.ts",
44
44
  "./mcp/*": "./src/lib/mcp/*.ts",
45
- "./cli/*": "./src/lib/cli/*.ts",
46
45
  "./bundle/*": "./src/lib/bundle/*.ts",
47
46
  "./shared/*": "./src/lib/shared/*.ts",
48
47
  "./build": "./src/build.ts",
@@ -2,13 +2,13 @@ import { mkdir, rm } from 'node:fs/promises'
2
2
  import { dirname, join } from 'node:path'
3
3
  import { bindConnectedFlag } from './lib/bundle/bindConnectedFlag.ts'
4
4
  import { bindRequestNavigate } from './lib/bundle/bindRequestNavigate.ts'
5
- import { findFreePort } from './lib/bundle/findFreePort.ts'
6
5
  import { listenLocalControlServer } from './lib/bundle/listenLocalControlServer.ts'
7
6
  import { probeBelteServer } from './lib/bundle/probeBelteServer.ts'
8
7
  import { resolveServerBinary } from './lib/bundle/resolveServerBinary.ts'
9
8
  import { resolveWebviewLib } from './lib/bundle/resolveWebviewLib.ts'
10
9
  import { stableLocalPort } from './lib/bundle/stableLocalPort.ts'
11
10
  import { waitForServer } from './lib/bundle/waitForServer.ts'
11
+ import { findOpenPort } from './lib/server/runtime/findOpenPort.ts'
12
12
  import { parsePort } from './lib/server/runtime/parsePort.ts'
13
13
  import { appDataDir } from './lib/shared/appDataDir.ts'
14
14
  import { bundleLayout } from './lib/shared/bundleLayout.ts'
@@ -166,17 +166,18 @@ only one embedded server runs at a time.
166
166
  The port the embedded server binds. A `PORT` configured in the data-dir `.env`
167
167
  (where the config form writes), the shipped binary-dir `.env`, or the launcher's
168
168
  own env is honored — so the server answers at a fixed, known address another
169
- machine can reliably connect to. With none set, a free port is chosen (the
170
- historical behaviour). Precedence matches the server's own env stack: shell >
171
- data-dir > binary-dir. A configured port is used as-is and not second-guessed —
172
- if it's taken, the bind failure surfaces rather than silently moving.
169
+ machine can reliably connect to. With none set, the first open port at/above
170
+ 3000 is chosen (matching the standalone server's default). Precedence matches
171
+ the server's own env stack: shell > data-dir > binary-dir. A configured port is
172
+ used as-is and not second-guessed — if it's taken, the bind failure surfaces
173
+ rather than silently moving.
173
174
  */
174
175
  async function resolveEmbeddedPort(): Promise<number> {
175
176
  const [dataDirEnv, binaryDirEnv] = await Promise.all([
176
177
  readEnvFile(dataDirEnvPath()),
177
178
  readEnvFile(binaryDirEnvPath()),
178
179
  ])
179
- return parsePort(process.env.PORT ?? dataDirEnv.PORT ?? binaryDirEnv.PORT) ?? findFreePort()
180
+ return parsePort(process.env.PORT ?? dataDirEnv.PORT ?? binaryDirEnv.PORT) ?? findOpenPort(3000)
180
181
  }
181
182
 
182
183
  async function startEmbeddedServer(timeoutMs?: number): Promise<string> {
@@ -61,6 +61,14 @@ export function cache<Args, Return>(
61
61
  store.subscribe(key)
62
62
  const existing = store.entries.get(key)
63
63
  /*
64
+ Tag an existing entry with this call's scope so a later
65
+ cache.invalidate({ scope }) reaches entries hydrated from the SSR
66
+ snapshot (which carry a value but no scope) without a refetch.
67
+ */
68
+ if (existing && options?.scope !== undefined) {
69
+ existing.scope = options.scope
70
+ }
71
+ /*
64
72
  Snapshot warm path: hydration pre-decoded the SSR body onto the
65
73
  entry, so the decoded variant returns it synchronously — the first
66
74
  {#await} render resolves without a microtask suspension and matches
@@ -109,6 +117,7 @@ function invokeWithCache<Args>(
109
117
  request,
110
118
  ttl,
111
119
  expiresAt: undefined as number | undefined,
120
+ scope: options?.scope,
112
121
  }
113
122
  store.entries.set(key, entry)
114
123
  function deleteIfCurrent() {
@@ -150,8 +159,17 @@ function shareable(promise: Promise<Response>): Promise<Response> {
150
159
  return promise.then((response) => response.clone())
151
160
  }
152
161
 
153
- cache.invalidate = function invalidate<Args, Return>(
154
- arg?: AnyRemote<Args, Return> | CacheOptions['key'],
162
+ /*
163
+ Three call shapes:
164
+ invalidate() → drop everything
165
+ invalidate(fn) → drop one function's calls (method+url prefix)
166
+ invalidate({ key?, scope? }) → drop one entry by key and/or a tagged group
167
+ A selector with both fields drops the union; an empty or unmatched selector
168
+ is a no-op. `key` accepts the same string/array/object the cache() `key`
169
+ option does and is canonicalised the same way.
170
+ */
171
+ function invalidate<Args, Return>(
172
+ arg?: AnyRemote<Args, Return> | Pick<CacheOptions, 'key' | 'scope'>,
155
173
  ): void {
156
174
  const store = activeCacheStore()
157
175
  if (arg === undefined) {
@@ -175,12 +193,22 @@ cache.invalidate = function invalidate<Args, Return>(
175
193
  emit(store, affected)
176
194
  return
177
195
  }
178
- const target = canonicalKey(arg)
179
- if (store.entries.delete(target)) {
180
- emit(store, [target])
181
- }
196
+ const target = arg.key !== undefined ? canonicalKey(arg.key) : undefined
197
+ const byKey = target !== undefined && store.entries.has(target) ? [target] : []
198
+ const byScope =
199
+ arg.scope === undefined
200
+ ? []
201
+ : Array.from(store.entries.values())
202
+ .filter((entry) => entry.scope === arg.scope)
203
+ .map((entry) => entry.key)
204
+ /* emit() dedupes via a Set, so a key matching both criteria is harmless. */
205
+ const affected = [...byKey, ...byScope]
206
+ affected.forEach((key) => store.entries.delete(key))
207
+ emit(store, affected)
182
208
  }
183
209
 
210
+ cache.invalidate = invalidate
211
+
184
212
  function resolveKey<Args>(
185
213
  rawFn: RawRemoteFunction<Args>,
186
214
  args: Args | undefined,
@@ -29,6 +29,7 @@ import { cacheControlForAsset } from './cacheControlForAsset.ts'
29
29
  import { containsTraversal } from './containsTraversal.ts'
30
30
  import { createAssetHeaderCache } from './createAssetHeaderCache.ts'
31
31
  import { createPublicAssetServer } from './createPublicAssetServer.ts'
32
+ import { findOpenPort } from './findOpenPort.ts'
32
33
  import { globToPathSet } from './globToPathSet.ts'
33
34
  import { logBrowserOnlyRoutes } from './logBrowserOnlyRoutes.ts'
34
35
  import { parsePort } from './parsePort.ts'
@@ -108,7 +109,9 @@ export async function createServer({
108
109
  distDir = `${process.cwd()}/dist`,
109
110
  publicDir = `${process.cwd()}/src/browser/public`,
110
111
  resourcesDir = `${process.cwd()}/src/mcp/resources`,
111
- port = parsePort(process.env.PORT) ?? 3000,
112
+ // No PORT set → scan for the first open port at/above 3000 rather than
113
+ // hardcoding 3000, so a second app boots cleanly instead of colliding.
114
+ port = parsePort(process.env.PORT) ?? findOpenPort(3000),
112
115
  }: {
113
116
  pages: Pages
114
117
  rpc: RemoteRoutes
@@ -0,0 +1,35 @@
1
+ // Ports probed upward from `start` before giving up and letting the kernel assign one.
2
+ const SCAN_RANGE = 100
3
+
4
+ /*
5
+ Returns the first bindable TCP port at or above `start`, probing upward.
6
+ Used when no PORT is configured so the server lands on a predictable
7
+ 3000+ port (3000, then 3001, …) instead of a random kernel-assigned one —
8
+ running a second app just steps to the next free port. Each probe binds a
9
+ throwaway server and stops it; like any release-then-rebind there's a tiny
10
+ race before the real listener takes the port, negligible for a local boot.
11
+ After SCAN_RANGE occupied ports it gives up scanning and lets the kernel
12
+ assign any free port (bind to 0).
13
+ */
14
+ export function findOpenPort(start: number): number {
15
+ for (let port = start; port < start + SCAN_RANGE; port++) {
16
+ try {
17
+ return bindAndRelease(port)
18
+ } catch {
19
+ // port in use — try the next one up
20
+ }
21
+ }
22
+ // every candidate was taken; bind to 0 so the kernel picks a free port
23
+ return bindAndRelease(0)
24
+ }
25
+
26
+ /*
27
+ Binds a throwaway server to `port` (0 = let the kernel assign one), reads the
28
+ actual bound port, and releases it. Throws if the port is already in use.
29
+ */
30
+ function bindAndRelease(port: number): number {
31
+ const probe = Bun.serve({ port, fetch: () => new Response() })
32
+ const bound = probe.port as number
33
+ probe.stop(true)
34
+ return bound
35
+ }
@@ -11,6 +11,9 @@ layer hands callers a decoded view derived from this same promise.
11
11
  snapshot body is pre-decoded synchronously so the first client render can
12
12
  read it without a microtask hop and byte-match the SSR DOM. Live fetches
13
13
  leave it undefined and take the async decode path.
14
+
15
+ `scope` mirrors the cache() call's `scope` option so
16
+ `cache.invalidate({ scope })` can drop every entry sharing the tag.
14
17
  */
15
18
  export type CacheEntry = {
16
19
  key: string
@@ -19,4 +22,5 @@ export type CacheEntry = {
19
22
  ttl: number | undefined
20
23
  expiresAt: number | undefined
21
24
  value?: unknown
25
+ scope?: string
22
26
  }
@@ -3,8 +3,11 @@ Options for cache(). `key` overrides the auto-derived WeakMap key — useful
3
3
  when sharing entries across calls or stripping noisy args. `ttl` is the
4
4
  milliseconds-past-resolve that the entry stays live: omitted = forever, 0 =
5
5
  dedupe only (entry dropped once the promise settles), any other number = TTL.
6
+ `scope` is a free-form tag grouping unrelated calls so one
7
+ `cache.invalidate({ scope })` drops them together.
6
8
  */
7
9
  export type CacheOptions = {
8
10
  key?: string | unknown[] | Record<string, unknown>
9
11
  ttl?: number
12
+ scope?: string
10
13
  }
@@ -1,14 +0,0 @@
1
- /*
2
- Asks the OS for an unused TCP port by binding a throwaway Bun server to
3
- port 0 (the kernel assigns a free port), reading the assigned port, then
4
- stopping it immediately. There is an unavoidable race between releasing
5
- the port here and the server child re-binding it, but for a
6
- single-user bundle launch the window is negligible.
7
- */
8
- export function findFreePort(): number {
9
- const probe = Bun.serve({ port: 0, fetch: () => new Response() })
10
- // A TCP server bound to port 0 always reports a numeric assigned port.
11
- const port = probe.port as number
12
- probe.stop(true)
13
- return port
14
- }