@abide/abide 0.110.3 → 0.111.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/CHANGELOG.md +19 -0
- package/docs/REGISTRY.md +22 -21
- package/docs/RULEBOOK.md +85 -19
- package/docs/abide.md +38 -24
- package/package.json +1 -1
- package/src/compiler/compileApp.ts +2 -1
- package/src/server/BRIDGE_VERSION.ts +1 -1
- package/src/server/HEARTBEAT_MILLISECONDS.ts +2 -0
- package/src/server/IDLE_TIMEOUT_SECONDS.ts +6 -0
- package/src/server/answerRequest.ts +44 -3
- package/src/server/answerRpc.ts +14 -41
- package/src/server/answerSocket.ts +2 -2
- package/src/server/answerValue.ts +15 -3
- package/src/server/answerWorker.ts +195 -0
- package/src/server/appManifest.ts +48 -0
- package/src/server/assistant.ts +1 -1
- package/src/server/bridgeRelay.ts +4 -50
- package/src/server/claudeApi.ts +1 -1
- package/src/server/claudeCode.ts +3 -3
- package/src/server/clientBundle.ts +7 -0
- package/src/server/config.ts +14 -0
- package/src/server/createApp.ts +28 -4
- package/src/server/devWorker.ts +4 -1
- package/src/server/generatedSurfaces.ts +4 -1
- package/src/server/requestScope.ts +31 -11
- package/src/server/responseRelay.ts +7 -7
- package/src/server/serveBuild.ts +2 -0
- package/src/server/serveDev.ts +4 -1
- package/src/server/streamEvent.ts +60 -1
- package/src/shared/ADDRESSES.ts +11 -0
- package/src/shared/LONGEST_DELAY.ts +2 -0
- package/src/shared/OFFLINE_CACHES.ts +12 -0
- package/src/shared/OFFLINE_META.ts +7 -0
- package/src/shared/answerOf.ts +29 -4
- package/src/shared/attributeText.ts +1 -1
- package/src/shared/reactive/Expiry.ts +13 -1
- package/src/shared/reactive/Reactive.ts +13 -3
- package/src/shared/safeUrl.ts +8 -1
- package/src/ui/Router.ts +25 -0
- package/src/ui/registerWorker.ts +34 -0
- package/src/ui/routing.ts +1 -0
- package/src/ui/socketWire.ts +33 -2
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// SECTION 45 — THE SERVICE WORKER, written by the server rather than bundled: what it saves is the
|
|
2
|
+
// build's own file list, which the server holds (38.5), and a script whose bytes change with the
|
|
3
|
+
// build is how a browser learns there is a new one to install.
|
|
4
|
+
//
|
|
5
|
+
// WRITTEN AS TEXT, NOT AS A FUNCTION'S SOURCE: an executable `abide compile` writes carries its code
|
|
6
|
+
// as bytecode (38.93), where a function has no source to read back.
|
|
7
|
+
|
|
8
|
+
import { CLIENT_ADDRESS } from '#shared/ADDRESSES.ts'
|
|
9
|
+
import { MOUNT, mounted } from '#shared/mount.ts'
|
|
10
|
+
import { NAVIGATION_HEADER } from '#shared/NAVIGATION_HEADER.ts'
|
|
11
|
+
import {
|
|
12
|
+
OFFLINE_BUNDLE,
|
|
13
|
+
OFFLINE_CACHE_PREFIX,
|
|
14
|
+
OFFLINE_SAVED,
|
|
15
|
+
} from '#shared/OFFLINE_CACHES.ts'
|
|
16
|
+
import { PRINCIPAL_HEADER } from '#shared/PRINCIPAL_HEADER.ts'
|
|
17
|
+
import type { Manifest } from './manifestOf.ts'
|
|
18
|
+
|
|
19
|
+
// What `framingOf` reads as a framed stream (16.32), and a binary's bytes (16.34), which may be one —
|
|
20
|
+
// spelled again as a list because `framingOf`, in every page's client closure, reads them as branches.
|
|
21
|
+
const STREAMED_TYPES = [
|
|
22
|
+
'text/event-stream',
|
|
23
|
+
'application/jsonl',
|
|
24
|
+
'application/x-ndjson',
|
|
25
|
+
'application/octet-stream',
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
const CACHES = `const OURS = ${JSON.stringify(OFFLINE_CACHE_PREFIX)}
|
|
29
|
+
const SAVED = ${JSON.stringify(OFFLINE_SAVED)}
|
|
30
|
+
const BUNDLE = ${JSON.stringify(OFFLINE_BUNDLE)}
|
|
31
|
+
|
|
32
|
+
async function dropOurs(keeping) {
|
|
33
|
+
for (const name of await caches.keys())
|
|
34
|
+
if (name.startsWith(OURS) && name !== keeping) await caches.delete(name)
|
|
35
|
+
}
|
|
36
|
+
`
|
|
37
|
+
|
|
38
|
+
// 45.3 through 45.8, and 45.13.
|
|
39
|
+
const SAVING = `
|
|
40
|
+
self.addEventListener('install', (event) => {
|
|
41
|
+
event.waitUntil(
|
|
42
|
+
caches
|
|
43
|
+
.open(BUNDLE)
|
|
44
|
+
.then((cache) => cache.addAll([...FILES]))
|
|
45
|
+
.then(() => self.skipWaiting()),
|
|
46
|
+
)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
self.addEventListener('activate', (event) => {
|
|
50
|
+
event.waitUntil(
|
|
51
|
+
(async () => {
|
|
52
|
+
await dropOurs(BUNDLE)
|
|
53
|
+
const cache = await caches.open(BUNDLE)
|
|
54
|
+
for (const request of await cache.keys())
|
|
55
|
+
if (!FILES.has(request.url)) await cache.delete(request)
|
|
56
|
+
await self.registration.navigationPreload?.enable()
|
|
57
|
+
await self.clients.claim()
|
|
58
|
+
for (const client of await self.clients.matchAll({ type: 'window' }))
|
|
59
|
+
await fetch(client.url).then(
|
|
60
|
+
(response) =>
|
|
61
|
+
kept(response)
|
|
62
|
+
? keep(new Request(client.url), response)
|
|
63
|
+
: response.body?.cancel(),
|
|
64
|
+
() => {},
|
|
65
|
+
)
|
|
66
|
+
})(),
|
|
67
|
+
)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
self.addEventListener('message', (event) => {
|
|
71
|
+
if (event.data === 'rotated') event.waitUntil(caches.delete(SAVED))
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
self.addEventListener('fetch', (event) => {
|
|
75
|
+
const request = event.request
|
|
76
|
+
if (request.mode === 'navigate') {
|
|
77
|
+
if (request.method === 'GET') event.respondWith(answer(event, true))
|
|
78
|
+
else event.waitUntil(caches.delete(SAVED))
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
if (request.method !== 'GET') return
|
|
82
|
+
if (FILES.has(request.url)) {
|
|
83
|
+
event.respondWith(
|
|
84
|
+
caches
|
|
85
|
+
.match(request, { cacheName: BUNDLE })
|
|
86
|
+
.then((saved) => saved ?? fetch(request)),
|
|
87
|
+
)
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
if (new URL(request.url).origin !== self.location.origin) return
|
|
91
|
+
if (request.headers.has(NAVIGATION) || request.headers.has('range')) return
|
|
92
|
+
if ((request.headers.get('accept') ?? '').includes('text/event-stream'))
|
|
93
|
+
return
|
|
94
|
+
event.respondWith(answer(event, false))
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
async function answer(event, page) {
|
|
98
|
+
let response
|
|
99
|
+
try {
|
|
100
|
+
response =
|
|
101
|
+
(page ? await event.preloadResponse : undefined) ??
|
|
102
|
+
(await fetch(event.request))
|
|
103
|
+
} catch {
|
|
104
|
+
const saved = await caches.match(event.request, {
|
|
105
|
+
cacheName: SAVED,
|
|
106
|
+
ignoreVary: true,
|
|
107
|
+
})
|
|
108
|
+
return saved ?? (page ? offline() : Response.error())
|
|
109
|
+
}
|
|
110
|
+
// A copy nothing reads is a tee holding every chunk, so one is cloned only to be saved.
|
|
111
|
+
const copy = kept(response) ? response.clone() : undefined
|
|
112
|
+
const rotated = response.headers.has(PRINCIPAL)
|
|
113
|
+
event.waitUntil(
|
|
114
|
+
(rotated ? caches.delete(SAVED) : Promise.resolve()).then(() =>
|
|
115
|
+
copy === undefined ? undefined : keep(event.request, copy),
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
return response
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function kept(response) {
|
|
122
|
+
if (response.status !== 200) return false
|
|
123
|
+
const type = response.headers.get('content-type') ?? ''
|
|
124
|
+
return !STREAMED.some((streamed) => type.startsWith(streamed))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function keep(request, response) {
|
|
128
|
+
const cache = await caches.open(SAVED)
|
|
129
|
+
await cache.put(request, response)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function offline() {
|
|
133
|
+
return new Response(OFFLINE, {
|
|
134
|
+
status: 503,
|
|
135
|
+
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
`
|
|
139
|
+
|
|
140
|
+
// 45.11 — the page a navigation with no network and no saved copy is answered with.
|
|
141
|
+
const OFFLINE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Offline</title></head><body><p>You're offline, and this page wasn't saved on this device. It loads once you're back online.</p></body></html>`
|
|
142
|
+
|
|
143
|
+
// 45.9 — THE WORKER THAT REMOVES ITSELF, for a browser still holding one an earlier build registered.
|
|
144
|
+
const REMOVING = `${CACHES}
|
|
145
|
+
self.addEventListener('install', () => self.skipWaiting())
|
|
146
|
+
|
|
147
|
+
self.addEventListener('activate', (event) => {
|
|
148
|
+
event.waitUntil(
|
|
149
|
+
(async () => {
|
|
150
|
+
await dropOurs(undefined)
|
|
151
|
+
await self.registration.unregister()
|
|
152
|
+
})(),
|
|
153
|
+
)
|
|
154
|
+
})
|
|
155
|
+
`
|
|
156
|
+
|
|
157
|
+
/** Every file of the build's bundle, by name: the entry, its stylesheet and each route's chunks. */
|
|
158
|
+
function bundleFiles(manifest: Manifest): Set<string> {
|
|
159
|
+
const files = new Set<string>()
|
|
160
|
+
if (manifest.entry !== undefined) files.add(manifest.entry)
|
|
161
|
+
if (manifest.stylesheet !== undefined) files.add(manifest.stylesheet)
|
|
162
|
+
for (const chunks of Object.values(manifest.chunks))
|
|
163
|
+
for (const chunk of chunks) files.add(chunk)
|
|
164
|
+
return files
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The service worker's script: the one saving the build `manifest` names, or, with none, the one that
|
|
169
|
+
* removes itself.
|
|
170
|
+
*
|
|
171
|
+
* 45.2 — ALLOWED THE MOUNT'S WHOLE SCOPE from below `/__abide`, where a worker's own directory would
|
|
172
|
+
* be all it could reach.
|
|
173
|
+
*/
|
|
174
|
+
export function answerWorker(manifest: Manifest | undefined): Response {
|
|
175
|
+
let script = REMOVING
|
|
176
|
+
if (manifest !== undefined) {
|
|
177
|
+
const paths: string[] = []
|
|
178
|
+
for (const name of bundleFiles(manifest))
|
|
179
|
+
paths.push(mounted(`${CLIENT_ADDRESS}/${name}`))
|
|
180
|
+
script = `${CACHES}const PRINCIPAL = ${JSON.stringify(PRINCIPAL_HEADER)}
|
|
181
|
+
const NAVIGATION = ${JSON.stringify(NAVIGATION_HEADER)}
|
|
182
|
+
// 45.4 — the types a stream 16.32 frames, which has no end to save.
|
|
183
|
+
const STREAMED = ${JSON.stringify(STREAMED_TYPES)}
|
|
184
|
+
const OFFLINE = ${JSON.stringify(OFFLINE)}
|
|
185
|
+
const FILES = new Set(${JSON.stringify(paths)}.map((path) => new URL(path, self.location).href))
|
|
186
|
+
${SAVING}`
|
|
187
|
+
}
|
|
188
|
+
return new Response(script, {
|
|
189
|
+
headers: {
|
|
190
|
+
'content-type': 'text/javascript; charset=utf-8',
|
|
191
|
+
'cache-control': 'no-cache',
|
|
192
|
+
'service-worker-allowed': `${MOUNT.current()}/`,
|
|
193
|
+
},
|
|
194
|
+
})
|
|
195
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// 45.15 — THE WEB APP MANIFEST, which makes the app one a phone can put on its home screen: where the
|
|
2
|
+
// browser keeps what the service worker saved for as long as the app stays installed, rather than
|
|
3
|
+
// clearing it after a spell unvisited.
|
|
4
|
+
|
|
5
|
+
import { APP_MANIFEST_ADDRESS } from '#shared/ADDRESSES.ts'
|
|
6
|
+
import { ICON_ADDRESSES } from '#shared/LAUNCHER_FILES.ts'
|
|
7
|
+
import { appName } from '#shared/log.ts'
|
|
8
|
+
import { MOUNT, mounted } from '#shared/mount.ts'
|
|
9
|
+
|
|
10
|
+
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The tags a document links the manifest for the icon at `iconPath` by, and the manifest itself, or
|
|
14
|
+
* `undefined` where it is not a PNG.
|
|
15
|
+
*
|
|
16
|
+
* DECLARED AT ITS OWN SIZE, read off the PNG's header: nothing on a server draws it at another
|
|
17
|
+
* (`abide bundle` has `sips`, and a Linux host does not).
|
|
18
|
+
*/
|
|
19
|
+
export async function appManifest(
|
|
20
|
+
iconPath: string,
|
|
21
|
+
): Promise<{ tags: string; body: string } | undefined> {
|
|
22
|
+
const head = await Bun.file(iconPath).slice(0, 24).bytes()
|
|
23
|
+
// A file cut off before the header's width and height is no PNG to size.
|
|
24
|
+
if (head.length < 24) return undefined
|
|
25
|
+
for (let at = 0; at < PNG_SIGNATURE.length; at += 1)
|
|
26
|
+
if (head[at] !== PNG_SIGNATURE[at]) return undefined
|
|
27
|
+
const header = new DataView(head.buffer, head.byteOffset, head.byteLength)
|
|
28
|
+
const icon = mounted(ICON_ADDRESSES.png)
|
|
29
|
+
const scope = `${MOUNT.current()}/`
|
|
30
|
+
const name = appName()
|
|
31
|
+
return {
|
|
32
|
+
tags: `<link rel="manifest" href="${mounted(APP_MANIFEST_ADDRESS)}"><link rel="apple-touch-icon" href="${icon}">`,
|
|
33
|
+
body: JSON.stringify({
|
|
34
|
+
name,
|
|
35
|
+
short_name: name,
|
|
36
|
+
start_url: scope,
|
|
37
|
+
scope,
|
|
38
|
+
display: 'standalone',
|
|
39
|
+
icons: [
|
|
40
|
+
{
|
|
41
|
+
src: icon,
|
|
42
|
+
sizes: `${header.getUint32(16)}x${header.getUint32(20)}`,
|
|
43
|
+
type: 'image/png',
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
}),
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/server/assistant.ts
CHANGED
|
@@ -93,7 +93,7 @@ export async function* assistant(
|
|
|
93
93
|
yield* ASSISTANT_LOG.enabled() ? traced(events, messages.length) : events
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
/** `events`, and one line on `abide:assistant` once they end, however they end. 44.
|
|
96
|
+
/** `events`, and one line on `abide:assistant` once they end, however they end. 44.31. */
|
|
97
97
|
async function* traced(
|
|
98
98
|
events: AsyncGenerator<AssistantEvent>,
|
|
99
99
|
messageCount: number,
|
|
@@ -15,11 +15,10 @@
|
|
|
15
15
|
import { isFailed } from '#shared/Failed.ts'
|
|
16
16
|
import { refuse } from '#shared/refuse.ts'
|
|
17
17
|
import { answered } from './answerFailure.ts'
|
|
18
|
-
import { bodyWithin
|
|
18
|
+
import { bodyWithin } from './answerRpc.ts'
|
|
19
19
|
import { framed } from './answerValue.ts'
|
|
20
20
|
import { Inbox } from './Inbox.ts'
|
|
21
21
|
import { randomToken } from './randomToken.ts'
|
|
22
|
-
import { EVENT_STREAM_OPENER } from './streamEvent.ts'
|
|
23
22
|
|
|
24
23
|
/**
|
|
25
24
|
* What a turn hears from the bridge, in the order the bridge posted it: an event, an MCP request, the
|
|
@@ -53,9 +52,6 @@ const CODE_LIFETIME = 10 * 60_000
|
|
|
53
52
|
const IDLE_GRACE = 30_000
|
|
54
53
|
/** How long a pairing whose stream dropped waits to be resumed. 44.26. */
|
|
55
54
|
export const RESUME_MILLISECONDS = 60_000
|
|
56
|
-
/** How often a pairing's stream carries a comment while it has nothing else to send. 44.27. */
|
|
57
|
-
export const HEARTBEAT_MILLISECONDS = 15_000
|
|
58
|
-
const HEARTBEAT = new TextEncoder().encode(EVENT_STREAM_OPENER)
|
|
59
55
|
|
|
60
56
|
const CODES = new Map<string, { caller: string; expires: number }>()
|
|
61
57
|
const BY_CALLER = new Map<string, Bridge>()
|
|
@@ -246,56 +242,14 @@ function resume(request: Request): Response {
|
|
|
246
242
|
/** `bridge`'s frames as this request's stream, held for `RESUME_MILLISECONDS` once it drops. */
|
|
247
243
|
function attach(bridge: Bridge, request: Request): Response {
|
|
248
244
|
const frames = bridge.frames
|
|
249
|
-
// 16.57 — a pairing stays open for as long as the bridge holds it.
|
|
250
|
-
raiseTimeout(Number.POSITIVE_INFINITY)
|
|
251
245
|
request.signal.addEventListener('abort', () => {
|
|
252
246
|
if (bridge.frames !== frames || !BY_TOKEN.has(bridge.token)) return
|
|
253
247
|
bridge.frames = frames.handOff()
|
|
254
248
|
bridge.held = setTimeout(() => dropBridge(bridge), RESUME_MILLISECONDS)
|
|
255
249
|
})
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
new Headers(),
|
|
260
|
-
200,
|
|
261
|
-
)
|
|
262
|
-
return new Response(
|
|
263
|
-
withHeartbeat(answer.body as ReadableStream<Uint8Array>),
|
|
264
|
-
answer,
|
|
265
|
-
)
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// 44.27 — A COMMENT EVERY HEARTBEAT, so a bridge can tell a connection that went silent without
|
|
269
|
-
// ending from a quiet one. A comment rather than a frame: a bridge reads only `data:` lines, and a
|
|
270
|
-
// frame would sit in the pairing's inbox through a drop.
|
|
271
|
-
function withHeartbeat(
|
|
272
|
-
body: ReadableStream<Uint8Array>,
|
|
273
|
-
): ReadableStream<Uint8Array> {
|
|
274
|
-
const reader = body.getReader()
|
|
275
|
-
let beat: ReturnType<typeof setInterval> | undefined
|
|
276
|
-
return new ReadableStream<Uint8Array>({
|
|
277
|
-
start(controller) {
|
|
278
|
-
beat = setInterval(
|
|
279
|
-
() => controller.enqueue(HEARTBEAT),
|
|
280
|
-
HEARTBEAT_MILLISECONDS,
|
|
281
|
-
)
|
|
282
|
-
},
|
|
283
|
-
async pull(controller) {
|
|
284
|
-
try {
|
|
285
|
-
const next = await reader.read()
|
|
286
|
-
if (!next.done) return controller.enqueue(next.value)
|
|
287
|
-
clearInterval(beat)
|
|
288
|
-
controller.close()
|
|
289
|
-
} catch (thrown) {
|
|
290
|
-
clearInterval(beat)
|
|
291
|
-
controller.error(thrown)
|
|
292
|
-
}
|
|
293
|
-
},
|
|
294
|
-
cancel(reason) {
|
|
295
|
-
clearInterval(beat)
|
|
296
|
-
return reader.cancel(reason)
|
|
297
|
-
},
|
|
298
|
-
})
|
|
250
|
+
// 16.57 — `App.fetch` beats it, which is how a bridge tells a connection
|
|
251
|
+
// gone silent from a quiet one (44.27).
|
|
252
|
+
return framed(frames[Symbol.asyncIterator](), 'sse', new Headers(), 200)
|
|
299
253
|
}
|
|
300
254
|
|
|
301
255
|
type BridgeReport = {
|
package/src/server/claudeApi.ts
CHANGED
|
@@ -122,7 +122,7 @@ export function claudeApi(config: ClaudeApiConfig): Provider {
|
|
|
122
122
|
const block = event.content_block
|
|
123
123
|
blocks[event.index] = block
|
|
124
124
|
partials[event.index] = ''
|
|
125
|
-
// 44.
|
|
125
|
+
// 44.28 — announced as the model starts writing it.
|
|
126
126
|
if (block.type === 'tool_use')
|
|
127
127
|
yield {
|
|
128
128
|
type: 'tool',
|
package/src/server/claudeCode.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// CONFIGURED BY CLAUDE CODE'S OWN OPTION NAMES, which the bridge lays on as their flags, and only the ones
|
|
7
7
|
// that shape the answer. What would give a site power over the visitor's machine — permissions,
|
|
8
8
|
// directory, settings — is not an option here at all, and its built-in `tools` are a request the visitor
|
|
9
|
-
// grants or does not: the bridge decides (44.15, 44.16, 44.
|
|
9
|
+
// grants or does not: the bridge decides (44.15, 44.16, 44.29).
|
|
10
10
|
|
|
11
11
|
import { mounted } from '#shared/mount.ts'
|
|
12
12
|
import { refuse } from '#shared/refuse.ts'
|
|
@@ -42,7 +42,7 @@ export type ClaudeCodeConfig = {
|
|
|
42
42
|
tools?: string[]
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
/** The built-in tools a pairing command grants, as the visitor would after its `--`. 44.
|
|
45
|
+
/** The built-in tools a pairing command grants, as the visitor would after its `--`. 44.32. */
|
|
46
46
|
type PairingOptions = Pick<ClaudeCodeConfig, 'tools'>
|
|
47
47
|
|
|
48
48
|
/** Whether this browser's bridge is connected, and the command that pairs one where it is not. */
|
|
@@ -128,7 +128,7 @@ function askingCaller(): string {
|
|
|
128
128
|
|
|
129
129
|
/**
|
|
130
130
|
* The command that pairs a bridge with this browser, and when its code stops standing. 44.14. The
|
|
131
|
-
* `tools` it is given are written after its `--`, so the visitor running it grants them (44.
|
|
131
|
+
* `tools` it is given are written after its `--`, so the visitor running it grants them (44.29, 44.32).
|
|
132
132
|
*/
|
|
133
133
|
function bridgeCommand({ tools }: PairingOptions = {}): {
|
|
134
134
|
command: string
|
|
@@ -71,6 +71,13 @@ export async function clientBundle(
|
|
|
71
71
|
// app serves is still worth a build error.
|
|
72
72
|
external: [...published.values()],
|
|
73
73
|
throw: false,
|
|
74
|
+
// 36.16 — THE MODE, NOT THE ENVIRONMENT: the bundler writes in the `NODE_ENV` this process
|
|
75
|
+
// STARTED under, so the `production` `abide build` assigns once running never reached it.
|
|
76
|
+
define: {
|
|
77
|
+
'process.env.NODE_ENV': JSON.stringify(
|
|
78
|
+
mode === 'build' ? 'production' : 'development',
|
|
79
|
+
),
|
|
80
|
+
},
|
|
74
81
|
...(mode === 'build'
|
|
75
82
|
? {
|
|
76
83
|
// 38.5 — MINIFIED, and EVERY NAME HASHED, the entry's included: chunks and assets are
|
package/src/server/config.ts
CHANGED
|
@@ -156,6 +156,20 @@ export function positiveSetting<Fallback extends number | undefined>(
|
|
|
156
156
|
return Number.isFinite(declared) && declared > 0 ? declared : fallback
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Whether the switch `name` is on: `true` or `false`, or text reading `true` or `1`, `false` or `0`
|
|
161
|
+
* in any case, as 27.8 coerces one over a boolean default — and `fallback` where it is unset or reads
|
|
162
|
+
* as neither.
|
|
163
|
+
*
|
|
164
|
+
* 45.14.
|
|
165
|
+
*/
|
|
166
|
+
export function settingOn(name: string, fallback: boolean): boolean {
|
|
167
|
+
const declared = config.peek()?.[name]
|
|
168
|
+
if (typeof declared === 'boolean') return declared
|
|
169
|
+
if (typeof declared !== 'string') return fallback
|
|
170
|
+
return (coerce(declared, fallback) as boolean | undefined) ?? fallback
|
|
171
|
+
}
|
|
172
|
+
|
|
159
173
|
// 36.2 — `APP_URL`, parsed once per text it holds, and `undefined` where there is none or it will not
|
|
160
174
|
// parse. Read through the configuration, so an environment that moves it moves every address.
|
|
161
175
|
let appUrlFor: string | undefined
|
package/src/server/createApp.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
import { existsSync } from 'node:fs' // bun exposes no sync existence check of its own
|
|
7
7
|
import { resolve } from 'node:path' // bun exposes no path helpers of its own
|
|
8
8
|
import { FAILURE_STATUS_TEXT } from '#shared/FAILURE_STATUS_TEXT.ts'
|
|
9
|
+
import { framingOf } from '#shared/framing.ts'
|
|
10
|
+
import { LONGEST_DELAY } from '#shared/LONGEST_DELAY.ts'
|
|
9
11
|
import { namedLogger } from '#shared/log.ts'
|
|
10
12
|
import { compose } from '#shared/Middleware.ts'
|
|
11
13
|
import { MOUNT, unmounted } from '#shared/mount.ts'
|
|
@@ -26,6 +28,7 @@ import {
|
|
|
26
28
|
} from './config.ts'
|
|
27
29
|
import { McpSurface } from './generatedSurfaces.ts'
|
|
28
30
|
import { HOOKS, inOrder, reportUnexpected, rootRungs } from './hooks.ts'
|
|
31
|
+
import { IDLE_TIMEOUT_SECONDS } from './IDLE_TIMEOUT_SECONDS.ts'
|
|
29
32
|
import { installServer } from './installServer.ts'
|
|
30
33
|
import { admitsMux, muxSocketOptions, upgraded } from './muxUpgrade.ts'
|
|
31
34
|
import { plainAnswer } from './plainAnswer.ts'
|
|
@@ -34,11 +37,13 @@ import {
|
|
|
34
37
|
type AnsweringApp,
|
|
35
38
|
applyWritten,
|
|
36
39
|
currentRequestScope,
|
|
40
|
+
holdScope,
|
|
37
41
|
inReleasedScope,
|
|
38
42
|
LISTENING,
|
|
39
43
|
rungsTook,
|
|
40
44
|
serverTiming,
|
|
41
45
|
} from './requestScope.ts'
|
|
46
|
+
import { heldBody } from './streamEvent.ts'
|
|
42
47
|
import { testBuild } from './testBuild.ts'
|
|
43
48
|
import { traceResponse } from './trace.ts'
|
|
44
49
|
|
|
@@ -299,7 +304,7 @@ export async function bootEntered(
|
|
|
299
304
|
try {
|
|
300
305
|
// 42.6 — `run`, with dispatch over it — and the headers every answer carries stamped
|
|
301
306
|
// inside it, where the trace the request was answered under is still the current one.
|
|
302
|
-
// 16.
|
|
307
|
+
// 16.23 — the scope keeps `given` too, the one request bun will set a timeout on.
|
|
303
308
|
return await inReleasedScope(
|
|
304
309
|
request,
|
|
305
310
|
async () => {
|
|
@@ -312,7 +317,7 @@ export async function bootEntered(
|
|
|
312
317
|
reportUnexpected(thrown)
|
|
313
318
|
answered = plainAnswer('internal server error', 500)
|
|
314
319
|
}
|
|
315
|
-
const stamped = stamp(answered)
|
|
320
|
+
const stamped = beating(stamp(answered))
|
|
316
321
|
// 28.7 — a closed namespace does not pay for the line it would have written.
|
|
317
322
|
if (REQUEST_LOG.enabled())
|
|
318
323
|
REQUEST_LOG.debug(
|
|
@@ -388,10 +393,9 @@ export async function bootEntered(
|
|
|
388
393
|
new Promise<false>((done) => {
|
|
389
394
|
deadline = setTimeout(
|
|
390
395
|
() => done(false),
|
|
391
|
-
// setTimeout reads anything past a signed 32-bit count as 1 ms.
|
|
392
396
|
Math.min(
|
|
393
397
|
positiveSetting('ABIDE_STOP_TIMEOUT', STOP_TIMEOUT),
|
|
394
|
-
|
|
398
|
+
LONGEST_DELAY,
|
|
395
399
|
),
|
|
396
400
|
)
|
|
397
401
|
}),
|
|
@@ -423,6 +427,9 @@ export async function bootEntered(
|
|
|
423
427
|
...(options?.unix !== undefined
|
|
424
428
|
? { unix: options.unix }
|
|
425
429
|
: {
|
|
430
|
+
// 16.56 — the bound a request received whole lifts and its answer puts
|
|
431
|
+
// back (16.23). A unix socket takes bun's own.
|
|
432
|
+
idleTimeout: IDLE_TIMEOUT_SECONDS,
|
|
426
433
|
...(port === undefined ? {} : { port }),
|
|
427
434
|
...(options?.hostname === undefined
|
|
428
435
|
? {}
|
|
@@ -505,6 +512,23 @@ function stamp(answered: Response): Response {
|
|
|
505
512
|
return stamped
|
|
506
513
|
}
|
|
507
514
|
|
|
515
|
+
/**
|
|
516
|
+
* `answered`, beating where it is an event stream, and holding its request's scope until it ends.
|
|
517
|
+
*
|
|
518
|
+
* 16.57 — EVERY `text/event-stream`, whoever wrote it: an sse body, a followed value, a pairing's
|
|
519
|
+
* stream, a `Response` a handler built itself. 16.23 — and the hold is what keeps the last of those
|
|
520
|
+
* from being reaped once `fetch` returns, where nothing else outlives it.
|
|
521
|
+
*/
|
|
522
|
+
function beating(answered: Response): Response {
|
|
523
|
+
// The type first: reading `body` turns a string answer into a stream.
|
|
524
|
+
if (
|
|
525
|
+
framingOf(answered.headers.get('content-type')) !== 'sse' ||
|
|
526
|
+
answered.body === null
|
|
527
|
+
)
|
|
528
|
+
return answered
|
|
529
|
+
return new Response(heldBody(answered.body, holdScope(), true), answered)
|
|
530
|
+
}
|
|
531
|
+
|
|
508
532
|
/**
|
|
509
533
|
* The request with the mount taken off its address, where it is under the mount — or the redirect
|
|
510
534
|
* to the mount's root, where it asked for the mount with a `/` after it.
|
package/src/server/devWorker.ts
CHANGED
|
@@ -128,7 +128,7 @@ const relay = serveOver(
|
|
|
128
128
|
},
|
|
129
129
|
)
|
|
130
130
|
|
|
131
|
-
// 16.
|
|
131
|
+
// 16.23 — THE LISTENER HOLDS THE CONNECTION, so a request's idle timeout is set there, on
|
|
132
132
|
// the request bun handed it.
|
|
133
133
|
LISTENING.timeout = relay.timeout
|
|
134
134
|
|
|
@@ -239,6 +239,9 @@ async function open(
|
|
|
239
239
|
client: bundling.then((bundle) => ({
|
|
240
240
|
manifest: bundle.manifest,
|
|
241
241
|
head: `<script type="module" src="${RELOAD_CLIENT_ADDRESS}"></script>`,
|
|
242
|
+
// 45.9 — a worker `abide start` registered on this origin is removed rather than
|
|
243
|
+
// left answering a dev server from what it saved.
|
|
244
|
+
offline: false,
|
|
242
245
|
framework: (pathname, request) =>
|
|
243
246
|
pathname === RELOAD_CLIENT_ADDRESS
|
|
244
247
|
? new Response(RELOAD_CLIENT, {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { isFailed } from '#shared/Failed.ts'
|
|
17
17
|
import { namedLogger } from '#shared/log.ts'
|
|
18
|
+
import { mounted } from '#shared/mount.ts'
|
|
18
19
|
import { type Reactive, reactive } from '#shared/reactive/index.ts'
|
|
19
20
|
import { parseRoutePattern } from '#shared/routePattern.ts'
|
|
20
21
|
import { argsInURL, rpcRequest } from '#shared/rpcRequest.ts'
|
|
@@ -510,7 +511,9 @@ function callOf(
|
|
|
510
511
|
// 38.68 — the answer is read here rather than sent on, and nothing in process decodes it.
|
|
511
512
|
headers.delete('accept-encoding')
|
|
512
513
|
if (call.body !== undefined) headers.set('content-type', 'application/json')
|
|
513
|
-
|
|
514
|
+
// 16.10 — under the mount, as `rpc.url` is: the app takes the mount off what it is asked, and a
|
|
515
|
+
// route whose first segment is the mount's name would lose that segment instead.
|
|
516
|
+
return new Request(new URL(mounted(call.url), asked.url), {
|
|
514
517
|
method,
|
|
515
518
|
headers,
|
|
516
519
|
body: call.body ?? null,
|
|
@@ -17,6 +17,7 @@ import type { Principal } from '#shared/principal.ts'
|
|
|
17
17
|
import { SCOPING, type Scope } from '#shared/reactive/scoping.ts'
|
|
18
18
|
import type { RpcRequest } from '#shared/rpcRequest.ts'
|
|
19
19
|
import type { McpSurface } from './generatedSurfaces.ts'
|
|
20
|
+
import { IDLE_TIMEOUT_SECONDS } from './IDLE_TIMEOUT_SECONDS.ts'
|
|
20
21
|
|
|
21
22
|
/** The three framings a stream is answered in. 17.6. */
|
|
22
23
|
export type Framing = Framed | 'bytes'
|
|
@@ -37,12 +38,8 @@ export type RequestScope = Scope & {
|
|
|
37
38
|
caller: string | undefined
|
|
38
39
|
/** This request's one resolve of the principal. 26.14. */
|
|
39
40
|
principal: Promise<Principal> | undefined
|
|
40
|
-
/**
|
|
41
|
-
|
|
42
|
-
*
|
|
43
|
-
* 16.23 — and `Infinity` for one that is never reaped (16.57).
|
|
44
|
-
*/
|
|
45
|
-
timeout: number
|
|
41
|
+
/** 16.23 — whether the received request's idle timeout is off until the answer is sent. */
|
|
42
|
+
unreaped: boolean
|
|
46
43
|
/** This response's nonce, minted by the `csp` rung or on the first ask. 29.3. */
|
|
47
44
|
nonce: string | undefined
|
|
48
45
|
/** This request's cookie jar, built on the first ask. 22.2. */
|
|
@@ -71,7 +68,7 @@ export type RequestScope = Scope & {
|
|
|
71
68
|
/**
|
|
72
69
|
* The request the listener was handed, before a mount or a rung made another.
|
|
73
70
|
*
|
|
74
|
-
* 16.
|
|
71
|
+
* 16.23 — bun sets the idle timeout only on the one it received itself.
|
|
75
72
|
*/
|
|
76
73
|
received: Request | undefined
|
|
77
74
|
/** What a write through `response` is collected onto, for whoever answers to apply. 22.9. */
|
|
@@ -168,7 +165,7 @@ export function scopeFor(
|
|
|
168
165
|
cookies: undefined,
|
|
169
166
|
nonce: undefined,
|
|
170
167
|
framing: undefined,
|
|
171
|
-
|
|
168
|
+
unreaped: false,
|
|
172
169
|
caller: undefined,
|
|
173
170
|
principal: undefined,
|
|
174
171
|
applied: false,
|
|
@@ -216,6 +213,8 @@ export function inReleasedScope<Result>(
|
|
|
216
213
|
received: Request | undefined = given,
|
|
217
214
|
): Promise<Result> {
|
|
218
215
|
const scope = scopeFor(given, received, app)
|
|
216
|
+
// 16.23 — a request with no body is received whole as it arrives.
|
|
217
|
+
if (received?.body === null) unreap(scope)
|
|
219
218
|
return STORAGE.run(scope, async () => {
|
|
220
219
|
try {
|
|
221
220
|
return await body()
|
|
@@ -269,6 +268,12 @@ export function heldByApp(end: () => void): () => void {
|
|
|
269
268
|
export function releaseScope(scope: RequestScope): void {
|
|
270
269
|
scope.holds -= 1
|
|
271
270
|
if (scope.holds > 0) return
|
|
271
|
+
// 16.56 — answered, the connection is reaped once idle again: set before bun writes an answer
|
|
272
|
+
// that was not streamed, and as the last chunk of one that was goes out.
|
|
273
|
+
if (scope.unreaped) {
|
|
274
|
+
scope.unreaped = false
|
|
275
|
+
LISTENING.timeout(scope.received as Request, IDLE_TIMEOUT_SECONDS)
|
|
276
|
+
}
|
|
272
277
|
const stops = scope.stops
|
|
273
278
|
for (let at = 0; at < stops.length; at += 1) (stops[at] as () => void)()
|
|
274
279
|
stops.length = 0
|
|
@@ -285,10 +290,25 @@ export function adoptRequest(given: Request): void {
|
|
|
285
290
|
}
|
|
286
291
|
|
|
287
292
|
/**
|
|
288
|
-
*
|
|
293
|
+
* Turns the received request's idle timeout off until its answer is sent (16.23), for a request
|
|
294
|
+
* received whole. Where no request was received there is no connection to keep.
|
|
295
|
+
*
|
|
296
|
+
* WHOLE, NOT ARRIVED: a body still arriving is reaped as any idle connection is (16.56), so a caller
|
|
297
|
+
* that stops sending partway is not held open for good.
|
|
298
|
+
*/
|
|
299
|
+
export function unreap(scope: RequestScope | undefined): void {
|
|
300
|
+
if (scope === undefined || scope.received === undefined || scope.unreaped)
|
|
301
|
+
return
|
|
302
|
+
scope.unreaped = true
|
|
303
|
+
// bun spells "never reaped" 0.
|
|
304
|
+
LISTENING.timeout(scope.received, 0)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* What `App.listen` bound, and how the idle timeout is set on the received request.
|
|
289
309
|
*
|
|
290
|
-
* 42.9 — the server is for `server` to hand back. 16.
|
|
291
|
-
*
|
|
310
|
+
* 42.9 — the server is for `server` to hand back. 16.23 — the timeout is set on the server bound
|
|
311
|
+
* here, or where the listener is in another process (`abide dev`), by a message to it.
|
|
292
312
|
*/
|
|
293
313
|
export const LISTENING: {
|
|
294
314
|
server: Bun.Server<unknown> | undefined
|