@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.
Files changed (73) hide show
  1. package/ABIDE.md +4 -0
  2. package/CHANGELOG.md +43 -0
  3. package/bin/abide.ts +3 -2
  4. package/docs/REGISTRY.md +67 -41
  5. package/docs/RULEBOOK.md +207 -20
  6. package/docs/abide.md +392 -68
  7. package/package.json +1 -1
  8. package/src/compiler/Lowering.ts +29 -4
  9. package/src/compiler/appMap.ts +14 -1
  10. package/src/compiler/declaredExports.ts +41 -0
  11. package/src/compiler/handlersUnder.ts +1 -0
  12. package/src/compiler/hookCalls.ts +1 -0
  13. package/src/compiler/projectShadow.ts +53 -2
  14. package/src/compiler/readsIn.ts +39 -34
  15. package/src/server/BRIDGE_VERSION.ts +1 -1
  16. package/src/server/PageRender.ts +7 -8
  17. package/src/server/abideModules.ts +6 -0
  18. package/src/server/answerRequest.ts +115 -52
  19. package/src/server/answerSocket.ts +6 -5
  20. package/src/server/answerWorker.ts +34 -23
  21. package/src/server/assistant.ts +141 -35
  22. package/src/server/banner.ts +9 -5
  23. package/src/server/bridgeRelay.ts +3 -3
  24. package/src/server/callerOf.ts +5 -0
  25. package/src/server/claudeCode.ts +20 -11
  26. package/src/server/commands.ts +14 -7
  27. package/src/server/createApp.ts +65 -12
  28. package/src/server/devWorker.ts +18 -16
  29. package/src/server/generatedSurfaces.ts +6 -5
  30. package/src/server/handlerFileMount.ts +2 -0
  31. package/src/server/hooks.ts +54 -8
  32. package/src/server/index.ts +18 -4
  33. package/src/server/invokeHandler.ts +1 -1
  34. package/src/server/mountHandlers.ts +6 -0
  35. package/src/server/principal.ts +3 -2
  36. package/src/server/render.ts +11 -2
  37. package/src/server/requestScope.ts +61 -1
  38. package/src/server/responseRelay.ts +9 -3
  39. package/src/server/serveDev.ts +18 -3
  40. package/src/server/sqliteStore.ts +65 -0
  41. package/src/server/testMount.ts +1 -0
  42. package/src/shared/AssistantEvent.ts +40 -0
  43. package/src/shared/BrowserHooks.ts +73 -0
  44. package/src/shared/CURRENT_ROUTER.ts +3 -1
  45. package/src/shared/Issues.ts +11 -3
  46. package/src/shared/Middleware.ts +9 -9
  47. package/src/shared/UNEXPECTED.ts +1 -1
  48. package/src/shared/answerOf.ts +9 -4
  49. package/src/shared/imagePiece.ts +85 -0
  50. package/src/shared/index.ts +17 -0
  51. package/src/shared/isServer.ts +15 -0
  52. package/src/shared/navigate.ts +10 -3
  53. package/src/shared/onError.ts +51 -0
  54. package/src/shared/onRefusal.ts +47 -0
  55. package/src/shared/online.ts +5 -1
  56. package/src/shared/reactive/Keyed.ts +12 -1
  57. package/src/shared/reactive/Reactive.ts +65 -26
  58. package/src/shared/reactive/cellShape.ts +4 -3
  59. package/src/shared/reactive/poll.ts +49 -0
  60. package/src/shared/reactive/scoped.ts +10 -1
  61. package/src/shared/reactive/scoping.ts +14 -0
  62. package/src/shared/reactive/watch.ts +19 -8
  63. package/src/shared/resultsOf.ts +21 -0
  64. package/src/shared/route.ts +18 -4
  65. package/src/shared/rpc.ts +112 -26
  66. package/src/shared/seededDocument.ts +5 -6
  67. package/src/shared/storedValue.ts +32 -0
  68. package/src/shared/webStore.ts +54 -0
  69. package/src/ui/Router.ts +42 -9
  70. package/src/ui/outboxQueue.ts +1 -1
  71. package/src/ui/wire.ts +4 -4
  72. package/src/shared/KEEP_OPENED.ts +0 -7
  73. /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
- const stops = scope.stops
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,
@@ -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
- { connection: number; url: string; headers: [string, string][] } | undefined
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
- if (!mux && asked !== DEV_ADDRESS) return relay.ask(request)
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
+ }
@@ -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 }
@@ -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> = T extends object
48
- ? Partial<Record<Paths<T> | '', string[]>>
49
- : string[]
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[]
@@ -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<Answer = never, Extra extends object = object> = <
12
- Given extends { request: Request } & Extra,
13
- Result,
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> = (
@@ -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
- * `null` in a browser, which has no `onError`.
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,
@@ -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
- if (isProblem(body)) return failedFrom(body)
204
- return refuse(answer.status, answer.statusText || undefined)
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
+ }
@@ -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'