@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
|
@@ -125,8 +125,20 @@ const STORAGE = new AsyncLocalStorage<RequestScope | undefined>()
|
|
|
125
125
|
// value is filed per request (11.32), and a server module builds values as it loads — the
|
|
126
126
|
// principal's document here, an app's handlers ahead of `bootApp` in a compiled entry. Anything
|
|
127
127
|
// that can enter a scope imports this module, so none is built before the provider is in.
|
|
128
|
+
// 22.11 — A RENDER OUTSIDE EVERY REQUEST IS STILL A SERVER'S: `render` called bare, and `mount`'s
|
|
129
|
+
// first half, which a test process runs beside a DOM and the browser half that hydrates it. Marked
|
|
130
|
+
// apart from STORAGE so the render's values stay the ones 11.32 files outside a request. What it
|
|
131
|
+
// holds is the render's stops (12.5): a request's are its scope's.
|
|
132
|
+
const RENDERING = new AsyncLocalStorage<(() => void)[]>()
|
|
133
|
+
|
|
134
|
+
/** The stops of the request or the render current, or `undefined` where neither is. */
|
|
135
|
+
function currentStops(): (() => void)[] | undefined {
|
|
136
|
+
return STORAGE.getStore()?.stops ?? RENDERING.getStore()
|
|
137
|
+
}
|
|
138
|
+
|
|
128
139
|
SCOPING.provider = {
|
|
129
140
|
current: (): Scope | undefined => STORAGE.getStore(),
|
|
141
|
+
serverStops: currentStops,
|
|
130
142
|
run: (scope, body, given) =>
|
|
131
143
|
STORAGE.run(scope as RequestScope | undefined, body, given),
|
|
132
144
|
}
|
|
@@ -179,6 +191,21 @@ export function inScope<Result>(
|
|
|
179
191
|
return STORAGE.run(scopeFor(given, given, undefined), body)
|
|
180
192
|
}
|
|
181
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Runs `body` as a server's render, stopping every watch it opened once it has settled.
|
|
196
|
+
*
|
|
197
|
+
* 12.5 — A RENDER OUTSIDE EVERY REQUEST IS TORN DOWN as a request's scope is, once its document is
|
|
198
|
+
* written. Inside a request or a render already open, the watches are that one's to stop.
|
|
199
|
+
*/
|
|
200
|
+
export function inReleasedRender<Result>(
|
|
201
|
+
body: () => Promise<Result>,
|
|
202
|
+
): Promise<Result> {
|
|
203
|
+
// A REQUEST'S PAGE PAYS NOTHING HERE — no wrapping promise, no tick — as its scope stops them.
|
|
204
|
+
if (currentStops() !== undefined) return body()
|
|
205
|
+
const stops: (() => void)[] = []
|
|
206
|
+
return RENDERING.run(stops, body).finally(() => stopAll(stops))
|
|
207
|
+
}
|
|
208
|
+
|
|
182
209
|
/** Runs `body` in `scope`, which `scopeFor` opened, and returns what it returned. */
|
|
183
210
|
export function inOpenScope<Result>(
|
|
184
211
|
scope: RequestScope,
|
|
@@ -261,7 +288,11 @@ export function releaseScope(scope: RequestScope): void {
|
|
|
261
288
|
scope.unreaped = false
|
|
262
289
|
LISTENING.timeout(scope.received as Request, IDLE_TIMEOUT_SECONDS)
|
|
263
290
|
}
|
|
264
|
-
|
|
291
|
+
stopAll(scope.stops)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Runs and drops every stop in `stops`. 12.5. */
|
|
295
|
+
function stopAll(stops: (() => void)[]): void {
|
|
265
296
|
for (let at = 0; at < stops.length; at += 1) (stops[at] as () => void)()
|
|
266
297
|
stops.length = 0
|
|
267
298
|
}
|
|
@@ -433,6 +464,35 @@ export function response(): { status: number; headers: Headers } {
|
|
|
433
464
|
return scope.response
|
|
434
465
|
}
|
|
435
466
|
|
|
467
|
+
/** Where a request came from: a peer's address, `null` where the listener names none. */
|
|
468
|
+
export type ReceivedFrom = {
|
|
469
|
+
address: string
|
|
470
|
+
family: 'IPv4' | 'IPv6'
|
|
471
|
+
port: number
|
|
472
|
+
} | null
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* The peer each request a listener received came from, keyed by the request it handed the app.
|
|
476
|
+
*
|
|
477
|
+
* 22.12 — FILED BY THE LISTENER: `App.listen` files what bun names, and `abide dev`'s worker what its
|
|
478
|
+
* listener sent beside the request (38.14). A request nobody filed was made in process.
|
|
479
|
+
*/
|
|
480
|
+
export const RECEIVED_FROM = new WeakMap<Request, ReceivedFrom>()
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* The address the request being served came from, or `'in-process'` for one no listener received.
|
|
484
|
+
*
|
|
485
|
+
* 22.12, 22.13 — the same under `abide start`, `abide dev` and `App.listen`; `null` where the
|
|
486
|
+
* listener names no peer, as a unix socket does. A socket mux frame answers its upgrade's.
|
|
487
|
+
*/
|
|
488
|
+
export function requestIP(): ReceivedFrom | 'in-process' {
|
|
489
|
+
const scope = within('requestIP')
|
|
490
|
+
const asked = (scope.received ?? scope.request) as Request
|
|
491
|
+
return RECEIVED_FROM.has(asked)
|
|
492
|
+
? (RECEIVED_FROM.get(asked) as ReceivedFrom)
|
|
493
|
+
: 'in-process'
|
|
494
|
+
}
|
|
495
|
+
|
|
436
496
|
/**
|
|
437
497
|
* The listening server.
|
|
438
498
|
*
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// What `serveDev` and `devWorker` keep is the processes and the sockets.
|
|
7
7
|
|
|
8
8
|
import { plainAnswer } from './plainAnswer.ts'
|
|
9
|
+
import type { ReceivedFrom } from './requestScope.ts'
|
|
9
10
|
|
|
10
11
|
/** One direction of the line between the halves: bun's IPC in `abide dev`, a pair of queues in a test. */
|
|
11
12
|
export type Channel<Message> = { postMessage(message: Message): void }
|
|
@@ -17,6 +18,8 @@ export type Ask = {
|
|
|
17
18
|
url: string
|
|
18
19
|
headers: [string, string][]
|
|
19
20
|
body: ArrayBuffer | null
|
|
21
|
+
/** 22.12 — the peer the listener received it from. */
|
|
22
|
+
from: ReceivedFrom
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
/** What the listener's half sends: a request, and a pull or cancel of its body. */
|
|
@@ -62,7 +65,7 @@ export function describe(thrown: unknown): string {
|
|
|
62
65
|
export function serveOver(
|
|
63
66
|
channel: Channel<FromRelay>,
|
|
64
67
|
answering: {
|
|
65
|
-
fetch(request: Request): Promise<Response>
|
|
68
|
+
fetch(request: Request, from: ReceivedFrom): Promise<Response>
|
|
66
69
|
/** 37.3 — whether the answer admits the request to the mux. */
|
|
67
70
|
admits(request: Request, response: Response): boolean
|
|
68
71
|
/** The answer to a throw out of `fetch`, which is an answer and not a dropped message. */
|
|
@@ -107,7 +110,7 @@ export function serveOver(
|
|
|
107
110
|
asks.set(request, ask.id)
|
|
108
111
|
let response: Response
|
|
109
112
|
try {
|
|
110
|
-
response = await answering.fetch(request)
|
|
113
|
+
response = await answering.fetch(request, ask.from)
|
|
111
114
|
} catch (thrown) {
|
|
112
115
|
response = answering.failed(describe(thrown))
|
|
113
116
|
}
|
|
@@ -207,6 +210,7 @@ type InFlight = {
|
|
|
207
210
|
request: Request
|
|
208
211
|
headers: [string, string][]
|
|
209
212
|
body: ArrayBuffer | null
|
|
213
|
+
from: ReceivedFrom
|
|
210
214
|
settle: (response: Response) => void
|
|
211
215
|
asked: Channel<ToRelay> | undefined
|
|
212
216
|
stream: ReadableStreamDefaultController<Uint8Array> | null
|
|
@@ -252,6 +256,7 @@ export function askOver(
|
|
|
252
256
|
url: waiting.request.url,
|
|
253
257
|
headers: waiting.headers,
|
|
254
258
|
body: waiting.body,
|
|
259
|
+
from: waiting.from,
|
|
255
260
|
})
|
|
256
261
|
}
|
|
257
262
|
|
|
@@ -310,7 +315,7 @@ export function askOver(
|
|
|
310
315
|
* what the worker rebuilds one from — and the body is collected ONCE, HERE, which is what lets
|
|
311
316
|
* it be asked again of a replacement (38.24): a body can only be read off a `Request` once.
|
|
312
317
|
*/
|
|
313
|
-
async ask(request: Request): Promise<Response> {
|
|
318
|
+
async ask(request: Request, from: ReceivedFrom): Promise<Response> {
|
|
314
319
|
const body =
|
|
315
320
|
request.body === null ? null : await request.arrayBuffer()
|
|
316
321
|
return await new Promise<Response>((settle) => {
|
|
@@ -318,6 +323,7 @@ export function askOver(
|
|
|
318
323
|
request,
|
|
319
324
|
headers: identityOnly(request.headers),
|
|
320
325
|
body,
|
|
326
|
+
from,
|
|
321
327
|
settle,
|
|
322
328
|
asked: undefined,
|
|
323
329
|
stream: null,
|
package/src/server/serveDev.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { IDLE_TIMEOUT_SECONDS } from './IDLE_TIMEOUT_SECONDS.ts'
|
|
|
32
32
|
import { muxSocketOptions, sendOrClose, upgraded } from './muxUpgrade.ts'
|
|
33
33
|
import { placeOf } from './placement.ts'
|
|
34
34
|
import { plainAnswer } from './plainAnswer.ts'
|
|
35
|
+
import type { ReceivedFrom } from './requestScope.ts'
|
|
35
36
|
import { askOver } from './responseRelay.ts'
|
|
36
37
|
import { SETTLE_MILLISECONDS } from './SETTLE_MILLISECONDS.ts'
|
|
37
38
|
|
|
@@ -43,6 +44,8 @@ export type DevOptions = {
|
|
|
43
44
|
*/
|
|
44
45
|
root: string
|
|
45
46
|
port?: number | undefined
|
|
47
|
+
/** 38.136 — the interface the listener binds, every one where `undefined`. */
|
|
48
|
+
hostname?: string | undefined
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
// The app's process, spoken to in its own protocol.
|
|
@@ -63,7 +66,13 @@ const LAST_DEV_PORT = 3099
|
|
|
63
66
|
// An app-mux socket's data: the id the worker knows its connection by, and what `Mux.connect` reads
|
|
64
67
|
// off the upgrade. `undefined` on the reload client's.
|
|
65
68
|
type Carried =
|
|
66
|
-
|
|
69
|
+
| {
|
|
70
|
+
connection: number
|
|
71
|
+
url: string
|
|
72
|
+
headers: [string, string][]
|
|
73
|
+
from: ReceivedFrom
|
|
74
|
+
}
|
|
75
|
+
| undefined
|
|
67
76
|
|
|
68
77
|
/**
|
|
69
78
|
* `abide dev`'s listener, compiling the app here and serving it from a child process.
|
|
@@ -268,6 +277,9 @@ export async function serveDev(
|
|
|
268
277
|
let muxOptions = muxSocketOptions()
|
|
269
278
|
const serving = (port: number | undefined): Bun.Serve.Options<Carried> => ({
|
|
270
279
|
...(port === undefined ? {} : { port }),
|
|
280
|
+
...(options.hostname === undefined
|
|
281
|
+
? {}
|
|
282
|
+
: { hostname: options.hostname }),
|
|
271
283
|
// 16.54, as `App.listen` lifts it: the worker behind this is where the ceiling is read.
|
|
272
284
|
maxRequestBodySize: Number.MAX_SAFE_INTEGER,
|
|
273
285
|
// 16.56 — the bound a request received whole lifts and its answer puts back (16.23).
|
|
@@ -281,10 +293,12 @@ export async function serveDev(
|
|
|
281
293
|
const mux =
|
|
282
294
|
asked !== DEV_ADDRESS &&
|
|
283
295
|
request.headers.get('upgrade') === 'websocket'
|
|
284
|
-
|
|
296
|
+
// 22.12 — the peer, which only this process can see, crosses with the request.
|
|
297
|
+
const from = listening.requestIP(request)
|
|
298
|
+
if (!mux && asked !== DEV_ADDRESS) return relay.ask(request, from)
|
|
285
299
|
// 37.3 — THE MUX'S UPGRADE IS ASKED OF THE APP FIRST, whose lane runs in the worker.
|
|
286
300
|
if (mux && ready) {
|
|
287
|
-
const answered = await relay.ask(request)
|
|
301
|
+
const answered = await relay.ask(request, from)
|
|
288
302
|
if (!relay.admitted(answered)) return answered
|
|
289
303
|
}
|
|
290
304
|
// NOT WHILE THE APP IS COMPILING. The reload client and the browser's mux both read a
|
|
@@ -298,6 +312,7 @@ export async function serveDev(
|
|
|
298
312
|
connection: nextConnection,
|
|
299
313
|
url: request.url,
|
|
300
314
|
headers: [...request.headers.entries()],
|
|
315
|
+
from,
|
|
301
316
|
}
|
|
302
317
|
}
|
|
303
318
|
return upgraded(listening, request, data)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite'
|
|
2
|
+
|
|
3
|
+
import type { Store } from '#shared'
|
|
4
|
+
import type { Schema } from '#shared/Schema.ts'
|
|
5
|
+
import { expiresAfter, storedValue } from '#shared/storedValue.ts'
|
|
6
|
+
|
|
7
|
+
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
8
|
+
|
|
9
|
+
/** One connection per path a store names, so a `Keyed` building a store per entry opens one file. */
|
|
10
|
+
const OPENED = new Map<string, Database>()
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A `Store` kept in a row of a `bun:sqlite` table, under `key`.
|
|
14
|
+
*
|
|
15
|
+
* 8.25 — the table is made where it is missing, and its shape is the store's: the key, the value as
|
|
16
|
+
* JSON, and the deadline 8.26 reads, as epoch milliseconds or `NULL` for never.
|
|
17
|
+
*/
|
|
18
|
+
export function sqliteStore<Stored>(
|
|
19
|
+
database: Database | string,
|
|
20
|
+
key: string,
|
|
21
|
+
options?: { table?: string; schema?: Schema<Stored> },
|
|
22
|
+
): Store<Stored> {
|
|
23
|
+
const table = options?.table ?? 'abide_store'
|
|
24
|
+
if (!IDENTIFIER.test(table))
|
|
25
|
+
throw new Error(
|
|
26
|
+
`sqliteStore's table ${JSON.stringify(table)} is not an SQL identifier.`,
|
|
27
|
+
)
|
|
28
|
+
const db = typeof database === 'string' ? opened(database) : database
|
|
29
|
+
db.run(
|
|
30
|
+
`CREATE TABLE IF NOT EXISTS ${table} (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires INTEGER)`,
|
|
31
|
+
)
|
|
32
|
+
const schema = options?.schema as Schema<unknown> | undefined
|
|
33
|
+
const where = `${table}[${JSON.stringify(key)}]`
|
|
34
|
+
return {
|
|
35
|
+
get: () => {
|
|
36
|
+
const row = db
|
|
37
|
+
.query(`SELECT value, expires FROM ${table} WHERE key = ?`)
|
|
38
|
+
.get(key) as { value: string; expires: number | null } | null
|
|
39
|
+
if (row === null) return undefined
|
|
40
|
+
const value = storedValue(
|
|
41
|
+
{ value: JSON.parse(row.value), expires: row.expires },
|
|
42
|
+
schema,
|
|
43
|
+
where,
|
|
44
|
+
)
|
|
45
|
+
// 8.26 — a row past its deadline is gone, not merely skipped.
|
|
46
|
+
if (value === undefined)
|
|
47
|
+
db.query(`DELETE FROM ${table} WHERE key = ?`).run(key)
|
|
48
|
+
return value as Stored | undefined
|
|
49
|
+
},
|
|
50
|
+
set: (value, retention) => {
|
|
51
|
+
db.query(
|
|
52
|
+
`INSERT INTO ${table} (key, value, expires) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires = excluded.expires`,
|
|
53
|
+
).run(key, JSON.stringify(value), expiresAfter(retention.ttl))
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function opened(path: string): Database {
|
|
59
|
+
let db = OPENED.get(path)
|
|
60
|
+
if (db === undefined) {
|
|
61
|
+
db = new Database(path, { create: true })
|
|
62
|
+
OPENED.set(path, db)
|
|
63
|
+
}
|
|
64
|
+
return db
|
|
65
|
+
}
|
package/src/server/testMount.ts
CHANGED
|
@@ -36,6 +36,7 @@ export async function mount(component: Component): Promise<Mounted> {
|
|
|
36
36
|
throw new Error(
|
|
37
37
|
'abide: mount renders into a DOM, and this process has none. Preload `@abide/harness/preload` ahead of `@abide/abide/test` in bunfig.toml. RULEBOOK 38.119.',
|
|
38
38
|
)
|
|
39
|
+
// 12.5 — the server pass's watches stop once its document is written, as any render's do.
|
|
39
40
|
const html = new TextDecoder().decode(await render(component))
|
|
40
41
|
// After the `<body …>` open tag, whatever attributes the component wrote onto it (35.15).
|
|
41
42
|
document.body.innerHTML = html.slice(
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// SECTION 44 — WHAT A CONVERSATION IS MADE OF, on both sides: the transcript a page sends and the
|
|
2
|
+
// events `assistant` yields back to it. Types alone, so the browser names what `ask()` hands it
|
|
3
|
+
// without reaching for the server's entry.
|
|
4
|
+
|
|
5
|
+
import type { Method } from './rpc.ts'
|
|
6
|
+
|
|
7
|
+
/** A piece of a turn: words, or an image as base64 with its type. */
|
|
8
|
+
export type AssistantContent =
|
|
9
|
+
| { type: 'text'; text: string }
|
|
10
|
+
| { type: 'image'; data: string; mimeType: string }
|
|
11
|
+
|
|
12
|
+
/** One turn of the transcript a caller sends: its words, or its pieces in order. */
|
|
13
|
+
export type AssistantMessage = {
|
|
14
|
+
role: 'user' | 'assistant'
|
|
15
|
+
content: string | AssistantContent[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** What a conversation yields, the same from every provider. */
|
|
19
|
+
export type AssistantEvent =
|
|
20
|
+
| { type: 'text'; text: string }
|
|
21
|
+
| {
|
|
22
|
+
type: 'tool'
|
|
23
|
+
id: string
|
|
24
|
+
name: string
|
|
25
|
+
handler: { method: Method; address: string } | undefined
|
|
26
|
+
}
|
|
27
|
+
| { type: 'input'; id: string; input: unknown }
|
|
28
|
+
| {
|
|
29
|
+
type: 'result'
|
|
30
|
+
id: string
|
|
31
|
+
text: string
|
|
32
|
+
failed: boolean
|
|
33
|
+
handler: { method: Method; address: string } | undefined
|
|
34
|
+
value: unknown
|
|
35
|
+
}
|
|
36
|
+
| {
|
|
37
|
+
type: 'done'
|
|
38
|
+
stop: string
|
|
39
|
+
usage: { input: number; output: number }
|
|
40
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// 37.16, 37.18, 37.21 — A HOOK A COMPONENT FILES IN THE BROWSER, for as long as it stands, and what
|
|
2
|
+
// it is handed: `onError`'s and `onRefusal`'s (37.22-37.25) are filed and handed by this one class,
|
|
3
|
+
// so the two cannot come apart on where they file, whom they hand first, or what a throw does.
|
|
4
|
+
|
|
5
|
+
import { namedLogger } from './log.ts'
|
|
6
|
+
import { serverStops } from './reactive/scoping.ts'
|
|
7
|
+
import { owned, settingUp } from './reactive/watch.ts'
|
|
8
|
+
|
|
9
|
+
const LIFECYCLE_LOG = namedLogger('abide:lifecycle')
|
|
10
|
+
|
|
11
|
+
/** The hooks of one name filed in this browser, and the walk that hands them what they hear. */
|
|
12
|
+
export class BrowserHooks<Handed extends unknown[]> {
|
|
13
|
+
// IN THE ORDER FILED, which is outermost first: a layout's setup runs before the page inside it.
|
|
14
|
+
private readonly filed: { hook: (...handed: Handed) => unknown }[] = []
|
|
15
|
+
private readonly name: string
|
|
16
|
+
/** 37.21 — what a call that could never file throws. */
|
|
17
|
+
private readonly misplaced: string
|
|
18
|
+
/** Run before the first hook is filed — `onError`'s listeners. */
|
|
19
|
+
private first: (() => void) | undefined
|
|
20
|
+
|
|
21
|
+
constructor(name: string, misplaced: string, first?: () => void) {
|
|
22
|
+
this.name = name
|
|
23
|
+
this.misplaced = misplaced
|
|
24
|
+
this.first = first
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Whether no hook is filed. */
|
|
28
|
+
empty(): boolean {
|
|
29
|
+
return this.filed.length === 0
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Files `hook` while the calling component stands; returns what unfiles it. */
|
|
33
|
+
file(hook: (...handed: Handed) => unknown): () => void {
|
|
34
|
+
if (!settingUp()) {
|
|
35
|
+
// ONLY A BROWSER COLLECTS A SETUP, so a server's render — in a process with a document
|
|
36
|
+
// or without one — files nothing.
|
|
37
|
+
if (serverStops() !== undefined) return () => {}
|
|
38
|
+
// 37.21 — ANYWHERE ELSE IT COULD NEVER FILE: a call outside a component's setup has
|
|
39
|
+
// nothing to stand for.
|
|
40
|
+
throw new Error(this.misplaced)
|
|
41
|
+
}
|
|
42
|
+
if (this.first !== undefined) {
|
|
43
|
+
const first = this.first
|
|
44
|
+
this.first = undefined
|
|
45
|
+
first()
|
|
46
|
+
}
|
|
47
|
+
const entry = { hook }
|
|
48
|
+
const filed = this.filed
|
|
49
|
+
filed.push(entry)
|
|
50
|
+
return owned(() => {
|
|
51
|
+
const at = filed.indexOf(entry)
|
|
52
|
+
if (at !== -1) filed.splice(at, 1)
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 37.17, 37.18 — hands every hook filed what it hears, NEAREST FIRST, which is the last filed. A
|
|
58
|
+
* hook that throws is written rather than handed on, where it would surface as uncaught.
|
|
59
|
+
*/
|
|
60
|
+
hand(...handed: Handed): void {
|
|
61
|
+
const filed = this.filed
|
|
62
|
+
for (let at = filed.length - 1; at >= 0; at -= 1) {
|
|
63
|
+
// A hook that unfiled one on its way leaves the walk past the end.
|
|
64
|
+
const entry = filed[at]
|
|
65
|
+
if (entry === undefined) continue
|
|
66
|
+
try {
|
|
67
|
+
entry.hook(...handed)
|
|
68
|
+
} catch (thrown) {
|
|
69
|
+
LIFECYCLE_LOG.error(`an ${this.name} hook threw`, thrown)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* What a router offers `navigate`: a way to go to a URL.
|
|
2
|
+
* What a router offers `navigate`: a way to go to a URL, and a way to step the history.
|
|
3
3
|
*
|
|
4
4
|
* Named for what it DOES rather than what it is: `Navigator` is a DOM global, and a routing file
|
|
5
5
|
* is the one place a reader would take the two for each other.
|
|
6
6
|
*/
|
|
7
7
|
export type Navigable = {
|
|
8
8
|
go(url: URL | string, options: NavigateOptions): Promise<void>
|
|
9
|
+
/** 30.38 — steps the history by `by` entries, settling once the step has arrived. */
|
|
10
|
+
step(by: number): Promise<void>
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
export type NavigateOptions = { replace?: boolean; keepScroll?: boolean }
|
package/src/shared/Issues.ts
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
// nowhere to key by, and `{ '': string[] }` for that case is a wrapper every reader would
|
|
12
12
|
// have to unwrap.
|
|
13
13
|
|
|
14
|
+
import type { Rpc } from './rpc.ts'
|
|
15
|
+
|
|
14
16
|
type Down = [0, 0, 1, 2, 3, 4]
|
|
15
17
|
|
|
16
18
|
/**
|
|
@@ -43,7 +45,13 @@ export type Paths<T, Limit extends number = 5> = Limit extends 0
|
|
|
43
45
|
|
|
44
46
|
/**
|
|
45
47
|
* A refusal's messages keyed by path, `''` for the whole value, or a bare list over a scalar.
|
|
48
|
+
*
|
|
49
|
+
* 19.26 — over an `Rpc`, the `Issues` of what it is called with: a form holding a handler's
|
|
50
|
+
* refusal names the handler rather than spelling its `Args`.
|
|
46
51
|
*/
|
|
47
|
-
export type Issues<T> =
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
export type Issues<T> =
|
|
53
|
+
T extends Rpc<infer _Value, infer Args, infer _Failures, infer _Produced>
|
|
54
|
+
? Issues<Args>
|
|
55
|
+
: T extends object
|
|
56
|
+
? Partial<Record<Paths<T> | '', string[]>>
|
|
57
|
+
: string[]
|
package/src/shared/Middleware.ts
CHANGED
|
@@ -7,18 +7,18 @@ import type { Failed } from './Failed.ts'
|
|
|
7
7
|
* A rung written once for every lane whose context carries `Extra`: generic over the context it is
|
|
8
8
|
* handed and over what `next` answers, so it passes `next()`'s answer on, refuses in its place, or
|
|
9
9
|
* answers with an `Answer` of its own.
|
|
10
|
+
*
|
|
11
|
+
* `Refused` is what it may refuse with; a rung naming its refusals carries them onto the `Rpc` its
|
|
12
|
+
* handler is declared with (16.86), where one left at `Failed<string, unknown>` carries none.
|
|
10
13
|
*/
|
|
11
|
-
export type Middleware<
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
>
|
|
14
|
+
export type Middleware<
|
|
15
|
+
Answer = never,
|
|
16
|
+
Extra extends object = object,
|
|
17
|
+
Refused extends Failed<string, unknown> = Failed<string, unknown>,
|
|
18
|
+
> = <Given extends { request: Request } & Extra, Result>(
|
|
15
19
|
next: (ctx?: Given) => Promise<Result>,
|
|
16
20
|
ctx: Given,
|
|
17
|
-
) =>
|
|
18
|
-
| Result
|
|
19
|
-
| Answer
|
|
20
|
-
| Failed<string, unknown>
|
|
21
|
-
| Promise<Result | Answer | Failed<string, unknown>>
|
|
21
|
+
) => Result | Answer | Refused | Promise<Result | Answer | Refused>
|
|
22
22
|
|
|
23
23
|
/** A rung as one lane composes it, over that lane's context and what its center answers. */
|
|
24
24
|
export type Rung<Ctx, Result> = (
|
package/src/shared/UNEXPECTED.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 12.6 — filled by `#server/installServer.ts` with the `onError` hooks and nothing else: the core warns on
|
|
5
5
|
* its own namespace, so the lifecycle log is not written a second time. A leaf, for `RPC_SERVER`'s
|
|
6
6
|
* reason: the reactive core reports through it and a browser bundle must not reach what fills it.
|
|
7
|
-
*
|
|
7
|
+
* In a browser, filled by the first `onError` filed there (37.16), and `null` until one is.
|
|
8
8
|
*/
|
|
9
9
|
export const UNEXPECTED: { report: ((error: unknown) => void) | null } = {
|
|
10
10
|
report: null,
|
package/src/shared/answerOf.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
framingOf,
|
|
13
13
|
} from './framing.ts'
|
|
14
14
|
import { KEPT_HEADER } from './KEPT_HEADER.ts'
|
|
15
|
+
import { refused, type RefusedCall } from './onRefusal.ts'
|
|
15
16
|
import { principalRotated } from './principalRotated.ts'
|
|
16
17
|
import { refuse } from './refuse.ts'
|
|
17
18
|
import type { RpcRequest } from './rpcRequest.ts'
|
|
@@ -179,13 +180,14 @@ function answerFrom(
|
|
|
179
180
|
* What a call's body hands back: the value, nothing (16.31), or the refusal it was answered with.
|
|
180
181
|
*
|
|
181
182
|
* The refusal (15.1) is rebuilt as the `Failed` the server returned. RETURNED, so the value refuses
|
|
182
|
-
* (15.5) rather than failing the way a network error does.
|
|
183
|
+
* (15.5) rather than failing the way a network error does — and handed to `onRefusal` (37.24) on
|
|
184
|
+
* its way, `call` being the request it answers.
|
|
183
185
|
*
|
|
184
186
|
* 16.32 — A FRAMED STREAM IS COLLECTED INTO THE CHUNKS IT CARRIED, which is the value a streaming
|
|
185
187
|
* handler holds once its run has closed — for a copy the build did not mark as streaming, whose
|
|
186
188
|
* value is one production. A marked one reads its body as it lands (`framedStream`).
|
|
187
189
|
*/
|
|
188
|
-
export function answered(answer: Answer): unknown {
|
|
190
|
+
export function answered(answer: Answer, call: RefusedCall): unknown {
|
|
189
191
|
if (answer.status === 204) return undefined
|
|
190
192
|
if (answer.ok) {
|
|
191
193
|
// 16.34 — a binary decodes back to the bytes it was.
|
|
@@ -200,6 +202,9 @@ export function answered(answer: Answer): unknown {
|
|
|
200
202
|
body = undefined
|
|
201
203
|
}
|
|
202
204
|
if (answer.ok) return body
|
|
203
|
-
|
|
204
|
-
|
|
205
|
+
const refusal = isProblem(body)
|
|
206
|
+
? failedFrom(body)
|
|
207
|
+
: refuse(answer.status, answer.statusText || undefined)
|
|
208
|
+
refused(refusal, call)
|
|
209
|
+
return refusal
|
|
205
210
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// 44.52-44.55 — AN IMAGE A VISITOR PASTES, DROPS OR PICKS, as the piece a turn carries it in. In
|
|
2
|
+
// the browser it is drawn down to what a model looks at, so a full-size screenshot is not sent whole
|
|
3
|
+
// with every turn of the transcript; where nothing can draw (a server) it goes as it is.
|
|
4
|
+
|
|
5
|
+
import type { AssistantContent } from './AssistantEvent.ts'
|
|
6
|
+
import { IMAGE_TYPES } from './IMAGE_TYPES.ts'
|
|
7
|
+
|
|
8
|
+
// The longest side, in pixels, a model looks at before it scales an image down itself.
|
|
9
|
+
const LONG_EDGE = 1568
|
|
10
|
+
const JPEG_QUALITY = 0.9
|
|
11
|
+
|
|
12
|
+
/** The image files a paste or a drop carries of a type a model is shown, in order. 44.55. */
|
|
13
|
+
export function imageFiles(data: DataTransfer | null): File[] {
|
|
14
|
+
const files: File[] = []
|
|
15
|
+
if (data === null) return files
|
|
16
|
+
for (const file of data.files)
|
|
17
|
+
if (IMAGE_TYPES.has(file.type)) files.push(file)
|
|
18
|
+
if (files.length > 0) return files
|
|
19
|
+
// A pasted image some browsers carry in `items` alone.
|
|
20
|
+
for (const item of data.items) {
|
|
21
|
+
if (item.kind !== 'file' || !IMAGE_TYPES.has(item.type)) continue
|
|
22
|
+
const file = item.getAsFile()
|
|
23
|
+
if (file !== null) files.push(file)
|
|
24
|
+
}
|
|
25
|
+
return files
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** `image` as a turn's image piece, drawn down to `LONG_EDGE` on its long side. 44.52, 44.53, 44.54. */
|
|
29
|
+
export async function imagePiece(
|
|
30
|
+
image: Blob,
|
|
31
|
+
): Promise<Extract<AssistantContent, { type: 'image' }>> {
|
|
32
|
+
const shown = IMAGE_TYPES.has(image.type)
|
|
33
|
+
// A GIF may move, and drawing it keeps one frame.
|
|
34
|
+
if (image.type !== 'image/gif' && typeof createImageBitmap === 'function') {
|
|
35
|
+
const drawn = await drawnDown(image, shown).catch(() => undefined)
|
|
36
|
+
if (drawn !== undefined)
|
|
37
|
+
return {
|
|
38
|
+
type: 'image',
|
|
39
|
+
data: await base64Of(drawn),
|
|
40
|
+
mimeType: drawn.type,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (!shown)
|
|
44
|
+
throw new Error(
|
|
45
|
+
`abide: an image of ${image.type || 'no type'} could not be drawn as one a model is shown, ${[...IMAGE_TYPES].join(', ')}. RULEBOOK 44.54.`,
|
|
46
|
+
)
|
|
47
|
+
return { type: 'image', data: await base64Of(image), mimeType: image.type }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `image` drawn at most `LONG_EDGE` on its long side, or `undefined` where it is already that and of
|
|
52
|
+
* a type a model is shown. A photo stays a JPEG, and anything else becomes a PNG, sharp text and all —
|
|
53
|
+
* but a type a model is not shown (a HEIC) is a photo.
|
|
54
|
+
*/
|
|
55
|
+
async function drawnDown(
|
|
56
|
+
image: Blob,
|
|
57
|
+
shown: boolean,
|
|
58
|
+
): Promise<Blob | undefined> {
|
|
59
|
+
const bitmap = await createImageBitmap(image)
|
|
60
|
+
try {
|
|
61
|
+
const scale = Math.min(
|
|
62
|
+
1,
|
|
63
|
+
LONG_EDGE / Math.max(bitmap.width, bitmap.height),
|
|
64
|
+
)
|
|
65
|
+
if (scale === 1 && shown) return undefined
|
|
66
|
+
const canvas = new OffscreenCanvas(
|
|
67
|
+
Math.round(bitmap.width * scale),
|
|
68
|
+
Math.round(bitmap.height * scale),
|
|
69
|
+
)
|
|
70
|
+
canvas
|
|
71
|
+
.getContext('2d')
|
|
72
|
+
?.drawImage(bitmap, 0, 0, canvas.width, canvas.height)
|
|
73
|
+
const type =
|
|
74
|
+
image.type === 'image/png' || image.type === 'image/webp'
|
|
75
|
+
? 'image/png'
|
|
76
|
+
: 'image/jpeg'
|
|
77
|
+
return await canvas.convertToBlob({ type, quality: JPEG_QUALITY })
|
|
78
|
+
} finally {
|
|
79
|
+
bitmap.close()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function base64Of(blob: Blob): Promise<string> {
|
|
84
|
+
return new Uint8Array(await blob.arrayBuffer()).toBase64()
|
|
85
|
+
}
|
package/src/shared/index.ts
CHANGED
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
// value (15.1), so it is in this seam rather than the server's: the same object crosses a
|
|
11
11
|
// wire, fills `s.error` and narrows through `s.isError`.
|
|
12
12
|
|
|
13
|
+
// 44 — a conversation as a page sends it and is handed it back: the server's `assistant` yields it.
|
|
14
|
+
export type {
|
|
15
|
+
AssistantContent,
|
|
16
|
+
AssistantEvent,
|
|
17
|
+
AssistantMessage,
|
|
18
|
+
} from './AssistantEvent.ts'
|
|
13
19
|
// 33.23 — an element's attributes, typed as a component's props.
|
|
14
20
|
export type { Attributes } from './Attributes.ts'
|
|
15
21
|
// 32.37 — what a value writes into an attribute, on both sides.
|
|
@@ -27,8 +33,11 @@ export { escapeAttribute, escapeStyleValue, escapeText } from './escapeText.ts'
|
|
|
27
33
|
export { type Failed, isFailed } from './Failed.ts'
|
|
28
34
|
// 25 — the account of the app.
|
|
29
35
|
export { type Health, health } from './health.ts'
|
|
36
|
+
// 44.52-44.55 — a pasted, dropped or picked image as a turn's piece.
|
|
37
|
+
export { imageFiles, imagePiece } from './imagePiece.ts'
|
|
30
38
|
export type { Issues, Paths } from './Issues.ts'
|
|
31
39
|
export type { JsonValue } from './JsonValue.ts'
|
|
40
|
+
export { isServer } from './isServer.ts'
|
|
32
41
|
export { jsonSchema } from './jsonSchema.ts'
|
|
33
42
|
// 32.15's run-time half. In this seam because BOTH sides ask it what a `{#for}` subject iterates
|
|
34
43
|
// as — the server's emitted block, the client's `updateList`, and the markup a client builds for a
|
|
@@ -52,7 +61,11 @@ export {
|
|
|
52
61
|
export { navigate } from './navigate.ts'
|
|
53
62
|
export { online } from './online.ts'
|
|
54
63
|
export { outbox, type QueuedCall } from './outbox.ts'
|
|
64
|
+
// 37.16 — the browser's `onError`. `@abide/abide/server` exports the server's over it.
|
|
65
|
+
export { onError } from './onError.ts'
|
|
55
66
|
export { onMenu } from './onMenu.ts'
|
|
67
|
+
// 37.22 — the browser's `onRefusal`, which has no server's.
|
|
68
|
+
export { onRefusal } from './onRefusal.ts'
|
|
56
69
|
export { type Principal, principal } from './principal.ts'
|
|
57
70
|
// EMITTED CODE'S: a prop the child writes (33.6, 33.7), and one it hands where a `Reactive` is due (33.27).
|
|
58
71
|
export {
|
|
@@ -96,6 +109,8 @@ export {
|
|
|
96
109
|
watch,
|
|
97
110
|
} from './reactive/index.ts'
|
|
98
111
|
export { aborted, notFound, refuse, validationError } from './refuse.ts'
|
|
112
|
+
// 44.51 — what a conversation's calls to one handler answered.
|
|
113
|
+
export { resultsOf } from './resultsOf.ts'
|
|
99
114
|
export { route } from './route.ts'
|
|
100
115
|
export type { Schema, StandardSchemaV1 } from './Schema.ts'
|
|
101
116
|
export { safeUrl } from './safeUrl.ts'
|
|
@@ -119,4 +134,6 @@ export {
|
|
|
119
134
|
url,
|
|
120
135
|
} from './url.ts'
|
|
121
136
|
export { validateJson } from './validateJson.ts'
|
|
137
|
+
// 8.22 — a `Store` in the browser's Web Storage, inert where there is none (8.23).
|
|
138
|
+
export { webStore } from './webStore.ts'
|
|
122
139
|
export { mutatePath, writePath } from './writePath.ts'
|