@briancray/belte 0.5.3 → 0.7.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 +1 -2
- package/src/controlServerWorker.ts +7 -6
- package/src/lib/browser/cache.ts +56 -6
- package/src/lib/server/runtime/createServer.ts +4 -1
- package/src/lib/server/runtime/findOpenPort.ts +35 -0
- package/src/lib/shared/types/CacheEntry.ts +5 -0
- package/src/lib/shared/types/CacheOptions.ts +4 -0
- package/src/lib/bundle/findFreePort.ts +0 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@briancray/belte",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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,
|
|
170
|
-
|
|
171
|
-
data-dir > binary-dir. A configured port is
|
|
172
|
-
if it's taken, the bind failure surfaces
|
|
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) ??
|
|
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> {
|
package/src/lib/browser/cache.ts
CHANGED
|
@@ -61,6 +61,16 @@ 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. Merge
|
|
67
|
+
rather than replace so a read tagging one group can't drop tags a
|
|
68
|
+
different read site already added.
|
|
69
|
+
*/
|
|
70
|
+
if (existing && options?.scope !== undefined) {
|
|
71
|
+
existing.scope = mergeScopes(existing.scope, options.scope)
|
|
72
|
+
}
|
|
73
|
+
/*
|
|
64
74
|
Snapshot warm path: hydration pre-decoded the SSR body onto the
|
|
65
75
|
entry, so the decoded variant returns it synchronously — the first
|
|
66
76
|
{#await} render resolves without a microtask suspension and matches
|
|
@@ -109,6 +119,7 @@ function invokeWithCache<Args>(
|
|
|
109
119
|
request,
|
|
110
120
|
ttl,
|
|
111
121
|
expiresAt: undefined as number | undefined,
|
|
122
|
+
scope: options?.scope === undefined ? undefined : toScopeSet(options.scope),
|
|
112
123
|
}
|
|
113
124
|
store.entries.set(key, entry)
|
|
114
125
|
function deleteIfCurrent() {
|
|
@@ -150,8 +161,18 @@ function shareable(promise: Promise<Response>): Promise<Response> {
|
|
|
150
161
|
return promise.then((response) => response.clone())
|
|
151
162
|
}
|
|
152
163
|
|
|
153
|
-
|
|
154
|
-
|
|
164
|
+
/*
|
|
165
|
+
Three call shapes:
|
|
166
|
+
invalidate() → drop everything
|
|
167
|
+
invalidate(fn) → drop one function's calls (method+url prefix)
|
|
168
|
+
invalidate({ key?, scope? }) → drop one entry by key and/or tagged groups
|
|
169
|
+
A selector with both fields drops the union; an empty or unmatched selector
|
|
170
|
+
is a no-op. `key` accepts the same string/array/object the cache() `key`
|
|
171
|
+
option does and is canonicalised the same way. `scope` accepts one tag or an
|
|
172
|
+
array; an entry is dropped when its tag set shares any tag with the request.
|
|
173
|
+
*/
|
|
174
|
+
function invalidate<Args, Return>(
|
|
175
|
+
arg?: AnyRemote<Args, Return> | Pick<CacheOptions, 'key' | 'scope'>,
|
|
155
176
|
): void {
|
|
156
177
|
const store = activeCacheStore()
|
|
157
178
|
if (arg === undefined) {
|
|
@@ -175,12 +196,26 @@ cache.invalidate = function invalidate<Args, Return>(
|
|
|
175
196
|
emit(store, affected)
|
|
176
197
|
return
|
|
177
198
|
}
|
|
178
|
-
const target = canonicalKey(arg)
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
199
|
+
const target = arg.key !== undefined ? canonicalKey(arg.key) : undefined
|
|
200
|
+
const byKey = target !== undefined && store.entries.has(target) ? [target] : []
|
|
201
|
+
const requestedScopes = arg.scope === undefined ? undefined : toScopeSet(arg.scope)
|
|
202
|
+
const byScope =
|
|
203
|
+
requestedScopes === undefined
|
|
204
|
+
? []
|
|
205
|
+
: Array.from(store.entries.values())
|
|
206
|
+
.filter(
|
|
207
|
+
(entry) =>
|
|
208
|
+
entry.scope !== undefined && intersects(entry.scope, requestedScopes),
|
|
209
|
+
)
|
|
210
|
+
.map((entry) => entry.key)
|
|
211
|
+
/* emit() dedupes via a Set, so a key matching both criteria is harmless. */
|
|
212
|
+
const affected = [...byKey, ...byScope]
|
|
213
|
+
affected.forEach((key) => store.entries.delete(key))
|
|
214
|
+
emit(store, affected)
|
|
182
215
|
}
|
|
183
216
|
|
|
217
|
+
cache.invalidate = invalidate
|
|
218
|
+
|
|
184
219
|
function resolveKey<Args>(
|
|
185
220
|
rawFn: RawRemoteFunction<Args>,
|
|
186
221
|
args: Args | undefined,
|
|
@@ -199,6 +234,21 @@ function canonicalKey(value: CacheOptions['key']): string {
|
|
|
199
234
|
return canonicalJson(value)
|
|
200
235
|
}
|
|
201
236
|
|
|
237
|
+
/* Normalizes a scope option (one tag or many) to a Set for O(1) membership. */
|
|
238
|
+
function toScopeSet(scope: string | string[]): Set<string> {
|
|
239
|
+
return new Set(typeof scope === 'string' ? [scope] : scope)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/* Folds new tags into an entry's existing set without duplicating them. */
|
|
243
|
+
function mergeScopes(existing: Set<string> | undefined, incoming: string | string[]): Set<string> {
|
|
244
|
+
return new Set([...(existing ?? []), ...toScopeSet(incoming)])
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/* True when an entry's tags and the requested tags overlap on any tag. */
|
|
248
|
+
function intersects(entryScopes: Set<string>, requestedScopes: Set<string>): boolean {
|
|
249
|
+
return Array.from(requestedScopes).some((scope) => entryScopes.has(scope))
|
|
250
|
+
}
|
|
251
|
+
|
|
202
252
|
/*
|
|
203
253
|
Detail is a Set so each subscriber's `has(key)` check is O(1) regardless of
|
|
204
254
|
how many keys a single invalidate touches.
|
|
@@ -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
|
-
|
|
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,10 @@ 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` holds the cache() call's scope tags as a Set so
|
|
16
|
+
`cache.invalidate({ scope })` can drop every entry sharing any tag with O(1)
|
|
17
|
+
membership; a re-read merges new tags in rather than replacing them.
|
|
14
18
|
*/
|
|
15
19
|
export type CacheEntry = {
|
|
16
20
|
key: string
|
|
@@ -19,4 +23,5 @@ export type CacheEntry = {
|
|
|
19
23
|
ttl: number | undefined
|
|
20
24
|
expiresAt: number | undefined
|
|
21
25
|
value?: unknown
|
|
26
|
+
scope?: Set<string>
|
|
22
27
|
}
|
|
@@ -3,8 +3,12 @@ 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 one or more free-form tags grouping unrelated calls so one
|
|
7
|
+
`cache.invalidate({ scope })` drops every entry sharing any of them — pass an
|
|
8
|
+
array when a call belongs to multiple invalidation groups.
|
|
6
9
|
*/
|
|
7
10
|
export type CacheOptions = {
|
|
8
11
|
key?: string | unknown[] | Record<string, unknown>
|
|
9
12
|
ttl?: number
|
|
13
|
+
scope?: string | string[]
|
|
10
14
|
}
|
|
@@ -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
|
-
}
|