@abide/abide 0.124.0 → 0.125.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/ABIDE.md +4 -0
- package/CHANGELOG.md +43 -0
- package/bin/abide.ts +3 -2
- package/docs/REGISTRY.md +67 -41
- package/docs/RULEBOOK.md +207 -20
- package/docs/abide.md +392 -68
- package/package.json +1 -1
- package/src/compiler/Lowering.ts +29 -4
- package/src/compiler/appMap.ts +14 -1
- package/src/compiler/declaredExports.ts +41 -0
- package/src/compiler/handlersUnder.ts +1 -0
- package/src/compiler/hookCalls.ts +1 -0
- package/src/compiler/projectShadow.ts +53 -2
- package/src/compiler/readsIn.ts +39 -34
- package/src/server/BRIDGE_VERSION.ts +1 -1
- package/src/server/PageRender.ts +7 -8
- package/src/server/abideModules.ts +6 -0
- package/src/server/answerRequest.ts +115 -52
- package/src/server/answerSocket.ts +6 -5
- package/src/server/answerWorker.ts +34 -23
- package/src/server/assistant.ts +141 -35
- package/src/server/banner.ts +9 -5
- package/src/server/bridgeRelay.ts +3 -3
- package/src/server/callerOf.ts +5 -0
- package/src/server/claudeCode.ts +20 -11
- package/src/server/commands.ts +14 -7
- package/src/server/createApp.ts +65 -12
- package/src/server/devWorker.ts +18 -16
- package/src/server/generatedSurfaces.ts +6 -5
- package/src/server/handlerFileMount.ts +2 -0
- package/src/server/hooks.ts +54 -8
- package/src/server/index.ts +18 -4
- package/src/server/invokeHandler.ts +1 -1
- package/src/server/mountHandlers.ts +6 -0
- package/src/server/principal.ts +3 -2
- package/src/server/render.ts +11 -2
- package/src/server/requestScope.ts +61 -1
- package/src/server/responseRelay.ts +9 -3
- package/src/server/serveDev.ts +18 -3
- package/src/server/sqliteStore.ts +65 -0
- package/src/server/testMount.ts +1 -0
- package/src/shared/AssistantEvent.ts +40 -0
- package/src/shared/BrowserHooks.ts +73 -0
- package/src/shared/CURRENT_ROUTER.ts +3 -1
- package/src/shared/Issues.ts +11 -3
- package/src/shared/Middleware.ts +9 -9
- package/src/shared/UNEXPECTED.ts +1 -1
- package/src/shared/answerOf.ts +9 -4
- package/src/shared/imagePiece.ts +85 -0
- package/src/shared/index.ts +17 -0
- package/src/shared/isServer.ts +15 -0
- package/src/shared/navigate.ts +10 -3
- package/src/shared/onError.ts +51 -0
- package/src/shared/onRefusal.ts +47 -0
- package/src/shared/online.ts +5 -1
- package/src/shared/reactive/Keyed.ts +12 -1
- package/src/shared/reactive/Reactive.ts +65 -26
- package/src/shared/reactive/cellShape.ts +4 -3
- package/src/shared/reactive/poll.ts +49 -0
- package/src/shared/reactive/scoped.ts +10 -1
- package/src/shared/reactive/scoping.ts +14 -0
- package/src/shared/reactive/watch.ts +19 -8
- package/src/shared/resultsOf.ts +21 -0
- package/src/shared/route.ts +18 -4
- package/src/shared/rpc.ts +112 -26
- package/src/shared/seededDocument.ts +5 -6
- package/src/shared/storedValue.ts +32 -0
- package/src/shared/webStore.ts +54 -0
- package/src/ui/Router.ts +42 -9
- package/src/ui/outboxQueue.ts +1 -1
- package/src/ui/wire.ts +4 -4
- package/src/shared/KEEP_OPENED.ts +0 -7
- /package/src/{server → shared}/IMAGE_TYPES.ts +0 -0
|
@@ -33,7 +33,12 @@ import { answerBridge } from './bridgeRelay.ts'
|
|
|
33
33
|
import { compressAnswer } from './compressAnswer.ts'
|
|
34
34
|
import { DOCUMENT_HEADERS } from './DOCUMENT_HEADERS.ts'
|
|
35
35
|
import { answerMcp, openApi } from './generatedSurfaces.ts'
|
|
36
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
type AppContext,
|
|
38
|
+
failureOf,
|
|
39
|
+
type Lane,
|
|
40
|
+
type LaneRung,
|
|
41
|
+
} from './hooks.ts'
|
|
37
42
|
import { type Mounted, mountedOwn } from './mountHandlers.ts'
|
|
38
43
|
import { muxAdmission } from './muxUpgrade.ts'
|
|
39
44
|
import { OFFLINE_STALE } from './OFFLINE_STALE.ts'
|
|
@@ -95,10 +100,7 @@ export type RouteRow = {
|
|
|
95
100
|
* 16.14 — composed once with the table. Every server block is imported before it is built
|
|
96
101
|
* (37.1), so these are the rungs the boot found, as the root's are.
|
|
97
102
|
*/
|
|
98
|
-
answers: Map<
|
|
99
|
-
Ranked | Mounted,
|
|
100
|
-
(ctx: { request: Request }) => Promise<Response>
|
|
101
|
-
>
|
|
103
|
+
answers: Map<Ranked | Mounted, (ctx: AppContext) => Promise<Response>>
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
/** A public file, read once at boot: where the bytes are, the etag off them, and its type. */
|
|
@@ -171,7 +173,7 @@ export async function answerRequest(
|
|
|
171
173
|
// 20.5, 20.9, 36.7 — a surface of abide's own, or a 404 where it answers nothing.
|
|
172
174
|
const surface = SURFACES.get(asked)
|
|
173
175
|
if (surface !== undefined) {
|
|
174
|
-
const answer = await surface(app, request)
|
|
176
|
+
const answer = await surface.answer(app, request)
|
|
175
177
|
if (answer === undefined) return notFound()
|
|
176
178
|
// 21.4 — an answer about the app's surfaces is never cached.
|
|
177
179
|
return answerValue(
|
|
@@ -187,11 +189,7 @@ export async function answerRequest(
|
|
|
187
189
|
// 37.3 — THE MUX'S UPGRADE PASSES THE APP LANE as every request does (`muxUpgrade`).
|
|
188
190
|
const admitted = muxAdmission(asked, request, app)
|
|
189
191
|
if (admitted !== undefined) return admitted
|
|
190
|
-
|
|
191
|
-
if (
|
|
192
|
-
asked.startsWith(`${ABIDE_ADDRESS}/`) &&
|
|
193
|
-
!asked.startsWith(`${PRIVATE_HANDLERS_ADDRESS}/`)
|
|
194
|
-
) {
|
|
192
|
+
if (isFrameworkAddress(asked)) {
|
|
195
193
|
const answer = app.framework(asked, request)
|
|
196
194
|
if (answer === undefined) return notFound()
|
|
197
195
|
return readsOnly(request) ? answer : methodRefused(app, asked, READS)
|
|
@@ -217,38 +215,7 @@ export async function answerRequest(
|
|
|
217
215
|
refuse(400, 'The address is not a valid path.'),
|
|
218
216
|
)
|
|
219
217
|
|
|
220
|
-
|
|
221
|
-
// its preflight names (16.24). A route that matches and answers some other method is held, so
|
|
222
|
-
// an address nothing answers the method at is a 405 naming what is answered there (15.17).
|
|
223
|
-
const preflight = request.method === 'OPTIONS'
|
|
224
|
-
// `null` for an `OPTIONS` that names no method, which is no preflight (Fetch's CORS-preflight
|
|
225
|
-
// request carries one) and so no handler's: the route answers 15.17's 405 with what it allows.
|
|
226
|
-
const asking = preflight
|
|
227
|
-
? request.headers.get('access-control-request-method')
|
|
228
|
-
: method
|
|
229
|
-
let allowed: Set<string> | undefined
|
|
230
|
-
let found:
|
|
231
|
-
| { route: RouteRow; params: Params; handler: Mounted | undefined }
|
|
232
|
-
| undefined
|
|
233
|
-
for (const route of app.routes) {
|
|
234
|
-
const params = matchOf(route.segments, parts)
|
|
235
|
-
if (params === undefined) continue
|
|
236
|
-
const handler =
|
|
237
|
-
asking === null ? undefined : route.handlers.get(asking as Method)
|
|
238
|
-
if (
|
|
239
|
-
handler !== undefined ||
|
|
240
|
-
(!preflight && asking === 'GET' && route.page !== undefined)
|
|
241
|
-
) {
|
|
242
|
-
found = { route, params, handler }
|
|
243
|
-
break
|
|
244
|
-
}
|
|
245
|
-
allowed ??= new Set()
|
|
246
|
-
if (route.page !== undefined) allowed.add('GET').add('HEAD')
|
|
247
|
-
for (const method of route.handlers.keys()) {
|
|
248
|
-
allowed.add(method)
|
|
249
|
-
if (method === 'GET') allowed.add('HEAD')
|
|
250
|
-
}
|
|
251
|
-
}
|
|
218
|
+
const { found, allowed } = routed(app, parts, request)
|
|
252
219
|
if (found === undefined) {
|
|
253
220
|
if (allowed !== undefined)
|
|
254
221
|
return methodRefused(app, asked, [...allowed], request)
|
|
@@ -270,8 +237,90 @@ export async function answerRequest(
|
|
|
270
237
|
scope.params = found.params
|
|
271
238
|
const answer = found.route.answers.get(
|
|
272
239
|
found.handler ?? (found.route.page as Ranked),
|
|
273
|
-
) as (ctx:
|
|
274
|
-
return answer({
|
|
240
|
+
) as (ctx: AppContext) => Promise<Response>
|
|
241
|
+
return answer({
|
|
242
|
+
request,
|
|
243
|
+
lane: found.handler === undefined ? 'page' : 'handler',
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* What `answerRequest` would answer `request` with, by the arm of its dispatch it takes. 37.19.
|
|
249
|
+
*
|
|
250
|
+
* ASKED IN THE DISPATCH'S OWN ORDER, over the same tables, so a gate reading it before routing
|
|
251
|
+
* (37.3) is told what routing will choose.
|
|
252
|
+
*/
|
|
253
|
+
export function laneOf(app: AppSources, request: Request): Lane {
|
|
254
|
+
const asked = new URL(request.url).pathname
|
|
255
|
+
if (OWN_HANDLERS.has(asked)) return 'handler'
|
|
256
|
+
const surface = SURFACES.get(asked)
|
|
257
|
+
if (surface !== undefined) return surface.lane
|
|
258
|
+
if (muxAdmission(asked, request, app) !== undefined) return 'socket'
|
|
259
|
+
if (isFrameworkAddress(asked)) return 'abide'
|
|
260
|
+
if (app.published.has(decodedOrKept(asked))) return 'file'
|
|
261
|
+
const parts = partsOf(asked)
|
|
262
|
+
if (parts === undefined) return 'page'
|
|
263
|
+
return routed(app, parts, request).found?.handler === undefined
|
|
264
|
+
? 'page'
|
|
265
|
+
: 'handler'
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Whether `asked` is an address abide answers out of the command's own files.
|
|
270
|
+
*
|
|
271
|
+
* 16.70 — a private handler is answered by the route table, below abide's own namespace.
|
|
272
|
+
*/
|
|
273
|
+
function isFrameworkAddress(asked: string): boolean {
|
|
274
|
+
return (
|
|
275
|
+
asked.startsWith(`${ABIDE_ADDRESS}/`) &&
|
|
276
|
+
!asked.startsWith(`${PRIVATE_HANDLERS_ADDRESS}/`)
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The route answering `request` at `parts`, or the methods answered there where none answers its own.
|
|
282
|
+
*
|
|
283
|
+
* 19.20 — THE MOST SPECIFIC ROUTE ANSWERING THE METHOD, an `OPTIONS` asking about the method
|
|
284
|
+
* its preflight names (16.24). A route that matches and answers some other method is held, so
|
|
285
|
+
* an address nothing answers the method at is a 405 naming what is answered there (15.17).
|
|
286
|
+
*/
|
|
287
|
+
function routed(
|
|
288
|
+
app: AppSources,
|
|
289
|
+
parts: string[],
|
|
290
|
+
request: Request,
|
|
291
|
+
): {
|
|
292
|
+
found:
|
|
293
|
+
| { route: RouteRow; params: Params; handler: Mounted | undefined }
|
|
294
|
+
| undefined
|
|
295
|
+
allowed: Set<string> | undefined
|
|
296
|
+
} {
|
|
297
|
+
const preflight = request.method === 'OPTIONS'
|
|
298
|
+
// `null` for an `OPTIONS` that names no method, which is no preflight (Fetch's CORS-preflight
|
|
299
|
+
// request carries one) and so no handler's: the route answers 15.17's 405 with what it allows.
|
|
300
|
+
const asking = preflight
|
|
301
|
+
? request.headers.get('access-control-request-method')
|
|
302
|
+
: request.method === 'HEAD'
|
|
303
|
+
? 'GET'
|
|
304
|
+
: request.method
|
|
305
|
+
let allowed: Set<string> | undefined
|
|
306
|
+
for (const route of app.routes) {
|
|
307
|
+
const params = matchOf(route.segments, parts)
|
|
308
|
+
if (params === undefined) continue
|
|
309
|
+
const handler =
|
|
310
|
+
asking === null ? undefined : route.handlers.get(asking as Method)
|
|
311
|
+
if (
|
|
312
|
+
handler !== undefined ||
|
|
313
|
+
(!preflight && asking === 'GET' && route.page !== undefined)
|
|
314
|
+
)
|
|
315
|
+
return { found: { route, params, handler }, allowed: undefined }
|
|
316
|
+
allowed ??= new Set()
|
|
317
|
+
if (route.page !== undefined) allowed.add('GET').add('HEAD')
|
|
318
|
+
for (const method of route.handlers.keys()) {
|
|
319
|
+
allowed.add(method)
|
|
320
|
+
if (method === 'GET') allowed.add('HEAD')
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return { found: undefined, allowed }
|
|
275
324
|
}
|
|
276
325
|
|
|
277
326
|
/**
|
|
@@ -332,7 +381,8 @@ async function answerMatched(
|
|
|
332
381
|
// would otherwise render one address and hydrate against another.
|
|
333
382
|
const at = new URL(request.url)
|
|
334
383
|
at.pathname = mounted(at.pathname)
|
|
335
|
-
|
|
384
|
+
// 23.29 — a request is a document load, which arrives at an entry of its own.
|
|
385
|
+
arrive(at.href, page.pattern, params, 0)
|
|
336
386
|
// 11.32 — the page renders in a scope of its own, so what its values hold is this request's —
|
|
337
387
|
// and 41.13, every call it makes is recorded into its render.
|
|
338
388
|
beginPageRender(request)
|
|
@@ -369,18 +419,31 @@ const OWN_HANDLERS = new Map([
|
|
|
369
419
|
|
|
370
420
|
// THE ADDRESSES ABIDE ANSWERS ITSELF, looked up once rather than asked in turn: each to its value,
|
|
371
421
|
// a `Response` of its own, or `undefined` for a 404.
|
|
422
|
+
// 37.19 — each with the lane a gate is told it is.
|
|
372
423
|
const SURFACES = new Map<
|
|
373
424
|
string,
|
|
374
|
-
|
|
425
|
+
{
|
|
426
|
+
lane: Lane
|
|
427
|
+
answer: (app: AppSources, request: Request) => unknown
|
|
428
|
+
}
|
|
375
429
|
>([
|
|
376
|
-
[
|
|
430
|
+
[
|
|
431
|
+
OPENAPI_ADDRESS,
|
|
432
|
+
{ lane: 'openapi', answer: (app) => openApi(app.handlers) },
|
|
433
|
+
],
|
|
377
434
|
// Filled by the app answering this request, so never `undefined` here.
|
|
378
435
|
[
|
|
379
436
|
MCP_ADDRESS,
|
|
380
|
-
|
|
381
|
-
|
|
437
|
+
{
|
|
438
|
+
lane: 'mcp',
|
|
439
|
+
answer: (_app, request) =>
|
|
440
|
+
answerMcp((within('mcp').app as AnsweringApp).mcp, request),
|
|
441
|
+
},
|
|
442
|
+
],
|
|
443
|
+
[
|
|
444
|
+
BRIDGE_ADDRESS,
|
|
445
|
+
{ lane: 'bridge', answer: (_app, request) => answerBridge(request) },
|
|
382
446
|
],
|
|
383
|
-
[BRIDGE_ADDRESS, (_app, request) => answerBridge(request)],
|
|
384
447
|
])
|
|
385
448
|
|
|
386
449
|
/**
|
|
@@ -395,7 +458,7 @@ export function answerInside(
|
|
|
395
458
|
app: AppSources,
|
|
396
459
|
found: Ranked | Mounted,
|
|
397
460
|
rungs: LaneRung[],
|
|
398
|
-
): (ctx:
|
|
461
|
+
): (ctx: AppContext) => Promise<Response> {
|
|
399
462
|
return compose(rungs, ({ request }) => answerMatched(app, request, found))
|
|
400
463
|
}
|
|
401
464
|
|
|
@@ -26,6 +26,7 @@ import type { FollowedHandler } from './mountHandlers.ts'
|
|
|
26
26
|
import { sendOrClose } from './muxUpgrade.ts'
|
|
27
27
|
import {
|
|
28
28
|
inOpenScope,
|
|
29
|
+
RECEIVED_FROM,
|
|
29
30
|
releaseScope,
|
|
30
31
|
type RequestScope,
|
|
31
32
|
scopeFor,
|
|
@@ -139,11 +140,11 @@ export class MuxConnection {
|
|
|
139
140
|
upgrade.url,
|
|
140
141
|
)
|
|
141
142
|
// NO `received`: an upgraded connection has no http idle timeout for 16.23 to set.
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
)
|
|
143
|
+
const request = new Request(upgrade, { signal: this.hangUp.signal })
|
|
144
|
+
// 22.12 — every frame came from where the upgrade did.
|
|
145
|
+
if (RECEIVED_FROM.has(upgrade))
|
|
146
|
+
RECEIVED_FROM.set(request, RECEIVED_FROM.get(upgrade) ?? null)
|
|
147
|
+
this.scope = scopeFor(request, undefined, mux.app)
|
|
147
148
|
// 22.10 — the headers went out with the upgrade, so nothing a frame writes is carried.
|
|
148
149
|
this.scope.applied = true
|
|
149
150
|
}
|
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
|
|
8
8
|
import { CLIENT_ADDRESS } from '#shared/ADDRESSES.ts'
|
|
9
9
|
import { KEEP_HEADER } from '#shared/KEEP_HEADER.ts'
|
|
10
|
-
import { KEEP_OPENED } from '#shared/KEEP_OPENED.ts'
|
|
11
10
|
import { KEPT_HEADER } from '#shared/KEPT_HEADER.ts'
|
|
12
11
|
import { MOUNT, mounted } from '#shared/mount.ts'
|
|
13
12
|
import { NAVIGATION_HEADER } from '#shared/NAVIGATION_HEADER.ts'
|
|
@@ -42,7 +41,7 @@ async function dropOurs(keeping) {
|
|
|
42
41
|
}
|
|
43
42
|
`
|
|
44
43
|
|
|
45
|
-
// 45.3 through 45.8, 45.13, 45.17 through 45.
|
|
44
|
+
// 45.3 through 45.8, 45.13, and 45.17 through 45.26.
|
|
46
45
|
const SAVING = `
|
|
47
46
|
self.addEventListener('install', (event) => {
|
|
48
47
|
event.waitUntil(
|
|
@@ -149,31 +148,33 @@ function marked(saved) {
|
|
|
149
148
|
})
|
|
150
149
|
}
|
|
151
150
|
|
|
152
|
-
// A page as the worker asks for it
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
function pageRequest(url, headers, keep) {
|
|
151
|
+
// A page no navigation is asking for, as the worker asks for it: past the browser's cache, as a page
|
|
152
|
+
// kept, and with its redirects left unfollowed — one followed would be saved under the address it was
|
|
153
|
+
// asked at, holding where it led (RULEBOOK 45.4, 45.21, 45.23).
|
|
154
|
+
function pageRequest(url, headers) {
|
|
157
155
|
const asking = new Headers(headers)
|
|
158
|
-
asking.set(KEEP,
|
|
156
|
+
asking.set(KEEP, '1')
|
|
159
157
|
return new Request(url, { headers: asking, redirect: 'manual', cache: 'no-cache' })
|
|
160
158
|
}
|
|
161
159
|
|
|
162
|
-
// 45.
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
160
|
+
// 45.25 — THE NAVIGATION ASKED AS ITSELF: any init rebuilds a navigation's request as a script's
|
|
161
|
+
// fetch, and a proxy in front of the app tells the two apart — one sending a page load to sign-in
|
|
162
|
+
// answers a fetch 401. As itself its mode is navigate, the page that hydrates (RULEBOOK 41.29), its redirects
|
|
163
|
+
// left to the browser. Anything else is asked past the browser's own cache (RULEBOOK 45.21), which
|
|
164
|
+
// holds the same answers under the same headers and would hand a revalidation the copy it is replacing
|
|
165
|
+
// — and a page as one kept, which a script's fetch is not otherwise told from (RULEBOOK 45.23, 41.30).
|
|
167
166
|
async function fromNetwork(event, page, opened) {
|
|
168
167
|
const request = event.request
|
|
169
|
-
const asked =
|
|
170
|
-
?
|
|
171
|
-
:
|
|
168
|
+
const asked = opened
|
|
169
|
+
? request
|
|
170
|
+
: page
|
|
171
|
+
? pageRequest(request.url, request.headers)
|
|
172
|
+
: new Request(request, { cache: 'no-cache' })
|
|
172
173
|
const since = drops
|
|
173
174
|
const response = await fetch(asked)
|
|
174
175
|
// A copy nothing reads is a tee holding every chunk, so one is cloned only to be saved.
|
|
175
176
|
const copy = kept(response) ? response.clone() : undefined
|
|
176
|
-
event.waitUntil(keep(request, copy, since
|
|
177
|
+
event.waitUntil(keep(request, response, copy, since))
|
|
177
178
|
return response
|
|
178
179
|
}
|
|
179
180
|
|
|
@@ -181,11 +182,11 @@ async function fromNetwork(event, page, opened) {
|
|
|
181
182
|
// window's as the worker activates, and an address the router reached.
|
|
182
183
|
function keepPage(url) {
|
|
183
184
|
const since = drops
|
|
184
|
-
return fetch(pageRequest(url
|
|
185
|
+
return fetch(pageRequest(url)).then((response) => {
|
|
185
186
|
// Nothing reads this answer, so one 45.3 or 45.4 turns away is let go here.
|
|
186
187
|
const copy = kept(response) ? response : undefined
|
|
187
188
|
if (copy === undefined) response.body?.cancel()
|
|
188
|
-
return keep(new Request(url), copy, since
|
|
189
|
+
return keep(new Request(url), response, copy, since)
|
|
189
190
|
}, () => {})
|
|
190
191
|
}
|
|
191
192
|
|
|
@@ -273,12 +274,23 @@ function dropSaved() {
|
|
|
273
274
|
// Saves a copy 45.3 and 45.4 let through where no drop has come since its request was asked — after
|
|
274
275
|
// the drop its own answer makes where that answer rotated the principal — and cancels one where one has.
|
|
275
276
|
// One any caller may be answered is saved whenever it was asked, since no caller's cookies made it.
|
|
276
|
-
async function keep(request, copy, since
|
|
277
|
-
if (
|
|
277
|
+
async function keep(request, response, copy, since) {
|
|
278
|
+
if (response.headers.has(PRINCIPAL)) {
|
|
278
279
|
await dropSaved()
|
|
279
280
|
since = drops
|
|
280
281
|
}
|
|
281
|
-
|
|
282
|
+
// 45.26 — AN ANSWER NOT SAVED STILL REPLACES THE COPY: a redirect or a 401 is what the address
|
|
283
|
+
// answers now, and a copy left standing would answer every load in its stale-while-revalidate
|
|
284
|
+
// window in its place — a session lapsed behind a sign-in proxy never reaching the sign-in. A
|
|
285
|
+
// server error is no answer to replace a copy with.
|
|
286
|
+
if (copy === undefined) {
|
|
287
|
+
if (response.status >= 500) return
|
|
288
|
+
await Promise.all([
|
|
289
|
+
caches.open(SAVED).then((cache) => cache.delete(request)),
|
|
290
|
+
caches.open(SHARED).then((cache) => cache.delete(request)),
|
|
291
|
+
])
|
|
292
|
+
return
|
|
293
|
+
}
|
|
282
294
|
const shared = directivesOf(copy).has('public')
|
|
283
295
|
if (!shared && since !== drops) return copy.body?.cancel()
|
|
284
296
|
// An address whose answer went public or private since leaves no copy behind for savedCopy to
|
|
@@ -338,7 +350,6 @@ export function answerWorker(manifest: Manifest | undefined): Response {
|
|
|
338
350
|
script = `${CACHES}const PRINCIPAL = ${JSON.stringify(PRINCIPAL_HEADER)}
|
|
339
351
|
const KEPT = ${JSON.stringify(KEPT_HEADER)}
|
|
340
352
|
const KEEP = ${JSON.stringify(KEEP_HEADER)}
|
|
341
|
-
const OPENED = ${JSON.stringify(KEEP_OPENED)}
|
|
342
353
|
const NAVIGATION = ${JSON.stringify(NAVIGATION_HEADER)}
|
|
343
354
|
const REACHED = ${JSON.stringify(REACHED_MESSAGE)}
|
|
344
355
|
// 45.4 — the types a stream 16.32 frames, which has no end to save.
|
package/src/server/assistant.ts
CHANGED
|
@@ -10,35 +10,28 @@
|
|
|
10
10
|
// another request's scope — `claudeCode` answers one from the bridge's — and the call is still this
|
|
11
11
|
// asker's.
|
|
12
12
|
|
|
13
|
+
import type {
|
|
14
|
+
AssistantContent,
|
|
15
|
+
AssistantEvent,
|
|
16
|
+
AssistantMessage,
|
|
17
|
+
} from '#shared/AssistantEvent.ts'
|
|
18
|
+
import { IMAGE_TYPES } from '#shared/IMAGE_TYPES.ts'
|
|
13
19
|
import { namedLogger } from '#shared/log.ts'
|
|
14
|
-
import {
|
|
20
|
+
import { mounted } from '#shared/mount.ts'
|
|
21
|
+
import { aborted, refuse } from '#shared/refuse.ts'
|
|
15
22
|
|
|
16
23
|
import { type McpReply, McpSurface } from './generatedSurfaces.ts'
|
|
17
|
-
import { IMAGE_TYPES } from './IMAGE_TYPES.ts'
|
|
18
24
|
import { request, within } from './requestScope.ts'
|
|
25
|
+
import type { Listed } from './surfaceOf.ts'
|
|
19
26
|
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
export type AssistantMessage = {
|
|
27
|
-
role: 'user' | 'assistant'
|
|
28
|
-
content: string | AssistantContent[]
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** What a conversation yields, the same from every provider. */
|
|
32
|
-
export type AssistantEvent =
|
|
33
|
-
| { type: 'text'; text: string }
|
|
27
|
+
/**
|
|
28
|
+
* An `AssistantEvent` as a `Provider` yields it: a tool call named, and its result in words, with what
|
|
29
|
+
* `assistant` reads off the app's own surface for both — the handler, and the value — still to come.
|
|
30
|
+
*/
|
|
31
|
+
export type ProviderEvent =
|
|
32
|
+
| Exclude<AssistantEvent, { type: 'tool' | 'result' }>
|
|
34
33
|
| { type: 'tool'; id: string; name: string }
|
|
35
|
-
| { type: 'input'; id: string; input: unknown }
|
|
36
34
|
| { type: 'result'; id: string; text: string; failed: boolean }
|
|
37
|
-
| {
|
|
38
|
-
type: 'done'
|
|
39
|
-
stop: string
|
|
40
|
-
usage: { input: number; output: number }
|
|
41
|
-
}
|
|
42
35
|
|
|
43
36
|
/** A tool as the app's MCP surface lists it. */
|
|
44
37
|
export type ToolDefinition = {
|
|
@@ -68,11 +61,15 @@ export type Provider = {
|
|
|
68
61
|
messages: (AssistantMessage & { content: AssistantContent[] })[],
|
|
69
62
|
tools: Toolbox,
|
|
70
63
|
limits: { maxTurns: number; signal: AbortSignal },
|
|
71
|
-
): AsyncGenerator<
|
|
64
|
+
): AsyncGenerator<ProviderEvent>
|
|
72
65
|
}
|
|
73
66
|
|
|
74
67
|
/** What `assistant` takes beside a provider, the same for every one. */
|
|
75
|
-
export type AssistantOptions = {
|
|
68
|
+
export type AssistantOptions = {
|
|
69
|
+
mcp?: boolean
|
|
70
|
+
maxTurns?: number
|
|
71
|
+
signal?: AbortSignal
|
|
72
|
+
}
|
|
76
73
|
|
|
77
74
|
const MAX_TURNS = 10
|
|
78
75
|
const ASSISTANT_LOG = namedLogger('abide:assistant')
|
|
@@ -118,19 +115,128 @@ export async function* assistant(
|
|
|
118
115
|
)
|
|
119
116
|
}
|
|
120
117
|
}
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
118
|
+
const surface = options.mcp === true ? appSurface() : NO_SURFACE
|
|
119
|
+
const tools = toolboxOver(surface, askerOf(request()))
|
|
120
|
+
// 44.57 — the caller's signal and the request's are one: either ends the conversation.
|
|
121
|
+
const signal =
|
|
122
|
+
options.signal === undefined
|
|
123
|
+
? request().signal
|
|
124
|
+
: AbortSignal.any([request().signal, options.signal])
|
|
125
|
+
const events = endedBy(
|
|
126
|
+
provider.run(turns, tools, {
|
|
127
|
+
maxTurns: options.maxTurns ?? MAX_TURNS,
|
|
128
|
+
signal,
|
|
129
|
+
}),
|
|
128
130
|
signal,
|
|
129
|
-
|
|
130
|
-
// 28.7 — a closed namespace
|
|
131
|
+
)
|
|
132
|
+
// 28.7 — a closed namespace pays for no line.
|
|
131
133
|
yield* ASSISTANT_LOG.enabled()
|
|
132
|
-
? traced(events, messages.length, signal)
|
|
133
|
-
: events
|
|
134
|
+
? traced(read(events, surface), messages.length, signal)
|
|
135
|
+
: read(events, surface)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* `events` until `signal` aborts, then `aborted`. 44.57.
|
|
140
|
+
*
|
|
141
|
+
* THE ABORT DOES NOT WAIT FOR THE PROVIDER: one stuck in an await it was handed no signal for — a
|
|
142
|
+
* bridge that never answers — is asked to return and left to, and the conversation ends now. An
|
|
143
|
+
* abort read ahead of a throw, as `traced` reads it: a provider that throws as it unwinds was ended
|
|
144
|
+
* by the abort.
|
|
145
|
+
*/
|
|
146
|
+
async function* endedBy(
|
|
147
|
+
events: AsyncGenerator<ProviderEvent>,
|
|
148
|
+
signal: AbortSignal,
|
|
149
|
+
): AsyncGenerator<ProviderEvent> {
|
|
150
|
+
// A STEP RACES ITS OWN ABORT: one promise pending for the whole conversation would keep a
|
|
151
|
+
// reaction per event, none freed until it ends.
|
|
152
|
+
let rejectStep: (reason: unknown) => void = () => {}
|
|
153
|
+
const stop = (): void => rejectStep(aborted())
|
|
154
|
+
signal.addEventListener('abort', stop, { once: true })
|
|
155
|
+
try {
|
|
156
|
+
for (;;) {
|
|
157
|
+
if (signal.aborted) throw aborted()
|
|
158
|
+
const step = await Promise.race([
|
|
159
|
+
events.next(),
|
|
160
|
+
new Promise<never>((_, reject) => {
|
|
161
|
+
rejectStep = reject
|
|
162
|
+
}),
|
|
163
|
+
])
|
|
164
|
+
if (step.done === true) return
|
|
165
|
+
yield step.value
|
|
166
|
+
}
|
|
167
|
+
} catch (thrown) {
|
|
168
|
+
throw signal.aborted ? aborted() : thrown
|
|
169
|
+
} finally {
|
|
170
|
+
signal.removeEventListener('abort', stop)
|
|
171
|
+
// A provider still awaiting returns once that await settles, and nobody waits for it.
|
|
172
|
+
events.return(undefined).catch(() => {})
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** A called tool's handler, and whether its answer is a value read as JSON (19.21). */
|
|
177
|
+
type Called = {
|
|
178
|
+
handler: { method: Listed['method']; address: string } | undefined
|
|
179
|
+
valued: boolean
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const NOT_THE_APPS: Called = { handler: undefined, valued: false }
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* `events` with each call's handler, and each answer's value, read off `surface`. 44.48, 44.49.
|
|
186
|
+
*
|
|
187
|
+
* READ HERE RATHER THAN BY EACH PROVIDER, so a provider the app wrote yields the same events abide's
|
|
188
|
+
* do (44.2): a provider names the tool as the `Toolbox` lists it (44.50), and the rest is the surface's.
|
|
189
|
+
*/
|
|
190
|
+
async function* read(
|
|
191
|
+
events: AsyncGenerator<ProviderEvent>,
|
|
192
|
+
surface: McpSurface,
|
|
193
|
+
): AsyncGenerator<AssistantEvent> {
|
|
194
|
+
const calls = new Map<string, Called>()
|
|
195
|
+
for await (const event of events) {
|
|
196
|
+
if (event.type === 'tool') {
|
|
197
|
+
const tool = surface.tool(event.name)
|
|
198
|
+
const called: Called =
|
|
199
|
+
tool === undefined
|
|
200
|
+
? NOT_THE_APPS
|
|
201
|
+
: {
|
|
202
|
+
// 16.10 — the address an `Rpc` carries, under the mount.
|
|
203
|
+
handler: {
|
|
204
|
+
method: tool.method,
|
|
205
|
+
address: mounted(tool.address),
|
|
206
|
+
},
|
|
207
|
+
valued: tool.result !== undefined,
|
|
208
|
+
}
|
|
209
|
+
calls.set(event.id, called)
|
|
210
|
+
yield {
|
|
211
|
+
type: 'tool',
|
|
212
|
+
id: event.id,
|
|
213
|
+
name: event.name,
|
|
214
|
+
handler: called.handler,
|
|
215
|
+
}
|
|
216
|
+
} else if (event.type === 'result') {
|
|
217
|
+
const called = calls.get(event.id) ?? NOT_THE_APPS
|
|
218
|
+
yield {
|
|
219
|
+
type: 'result',
|
|
220
|
+
id: event.id,
|
|
221
|
+
text: event.text,
|
|
222
|
+
failed: event.failed,
|
|
223
|
+
handler: called.handler,
|
|
224
|
+
value:
|
|
225
|
+
called.valued && !event.failed
|
|
226
|
+
? valueOf(event.text)
|
|
227
|
+
: undefined,
|
|
228
|
+
}
|
|
229
|
+
} else yield event
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** An answer's words as the JSON value they spell, or `undefined` where they spell none. */
|
|
234
|
+
function valueOf(text: string): unknown {
|
|
235
|
+
try {
|
|
236
|
+
return JSON.parse(text)
|
|
237
|
+
} catch {
|
|
238
|
+
return undefined
|
|
239
|
+
}
|
|
134
240
|
}
|
|
135
241
|
|
|
136
242
|
/**
|
package/src/server/banner.ts
CHANGED
|
@@ -9,15 +9,16 @@ import type { AppPackage } from './config.ts'
|
|
|
9
9
|
* What `start` and `dev` print once the app is listening: the app, and every address it answers at.
|
|
10
10
|
*
|
|
11
11
|
* THE LOCAL ADDRESS IS THE FIRST URL PRINTED, because a harness reading the output takes the first
|
|
12
|
-
* `http://` it finds (`addressOf` in the tests).
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* `http://` it finds (`addressOf` in the tests). 38.58, 38.136 — the network rows follow it where the
|
|
13
|
+
* listener binds every interface, which `Bun.serve` given no `hostname` does whatever `url` says, and
|
|
14
|
+
* are left out where `hostname` names one interface.
|
|
15
15
|
*/
|
|
16
16
|
export function banner(
|
|
17
17
|
host: string,
|
|
18
18
|
command: string,
|
|
19
19
|
identity: AppPackage,
|
|
20
20
|
server: Bun.Server<unknown>,
|
|
21
|
+
hostname: string | undefined,
|
|
21
22
|
): string {
|
|
22
23
|
const color = useColor()
|
|
23
24
|
const paint = (code: string, text: string): string =>
|
|
@@ -29,8 +30,11 @@ export function banner(
|
|
|
29
30
|
? ''
|
|
30
31
|
: ` ${paint('2', `${identity.name}${identity.version === undefined ? '' : ` ${identity.version}`}`)}`
|
|
31
32
|
let text = `\n ${title}${named}\n\n ${paint('32', '➜')} ${paint('1', 'Local:')} ${paint('36', local)}\n`
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
const everyInterface =
|
|
34
|
+
hostname === undefined || hostname === '0.0.0.0' || hostname === '::'
|
|
35
|
+
if (everyInterface)
|
|
36
|
+
for (const address of networkAddresses())
|
|
37
|
+
text += ` ${paint('32', '➜')} ${paint('1', 'Network:')} ${paint('36', `${server.url.protocol}//${address}:${server.port}/`)}\n`
|
|
34
38
|
return text
|
|
35
39
|
}
|
|
36
40
|
|
|
@@ -22,7 +22,7 @@ import { refuse } from '#shared/refuse.ts'
|
|
|
22
22
|
import { answered } from './answerFailure.ts'
|
|
23
23
|
import { bodyWithin } from './answerRpc.ts'
|
|
24
24
|
import { framed } from './answerValue.ts'
|
|
25
|
-
import type {
|
|
25
|
+
import type { ProviderEvent } from './assistant.ts'
|
|
26
26
|
import { Inbox } from './Inbox.ts'
|
|
27
27
|
import { randomToken } from './randomToken.ts'
|
|
28
28
|
import { RESUME_MILLISECONDS } from './RESUME_MILLISECONDS.ts'
|
|
@@ -126,7 +126,7 @@ export async function* runTurn({
|
|
|
126
126
|
/** Never rejects: its failure is a reply too. */
|
|
127
127
|
answer: (method: string, params: unknown) => Promise<unknown>
|
|
128
128
|
signal: AbortSignal
|
|
129
|
-
}): AsyncGenerator<
|
|
129
|
+
}): AsyncGenerator<ProviderEvent> {
|
|
130
130
|
const bridge = BY_KEY.get(pairingKey(caller, signedIn))
|
|
131
131
|
// 44.13, 44.33 — nothing paired by this sign-in is the page's cue to show the command.
|
|
132
132
|
if (bridge === undefined)
|
|
@@ -146,7 +146,7 @@ export async function* runTurn({
|
|
|
146
146
|
signal.addEventListener('abort', cancel)
|
|
147
147
|
try {
|
|
148
148
|
for await (const item of inbox) {
|
|
149
|
-
if (item.kind === 'event') yield item.event as
|
|
149
|
+
if (item.kind === 'event') yield item.event as ProviderEvent
|
|
150
150
|
else if (item.kind === 'mcp')
|
|
151
151
|
void answer(item.method, item.params).then((reply) => {
|
|
152
152
|
bridge.frames.push({
|
package/src/server/callerOf.ts
CHANGED
|
@@ -46,6 +46,11 @@ export function callerOrMint(): string {
|
|
|
46
46
|
return callerOf() ?? mintCaller(scope)
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/** This request's caller, given rather than read off its cookie, with no cookie written. 42.21. */
|
|
50
|
+
export function presumeCaller(caller: string): void {
|
|
51
|
+
within('App caller').caller = caller
|
|
52
|
+
}
|
|
53
|
+
|
|
49
54
|
/**
|
|
50
55
|
* Mints a fresh caller for this request, sets its cookie, and returns it.
|
|
51
56
|
*
|