@abide/abide 0.124.1 → 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 (71) hide show
  1. package/ABIDE.md +4 -0
  2. package/CHANGELOG.md +36 -0
  3. package/bin/abide.ts +3 -2
  4. package/docs/REGISTRY.md +66 -40
  5. package/docs/RULEBOOK.md +202 -18
  6. package/docs/abide.md +391 -67
  7. package/package.json +1 -1
  8. package/src/compiler/appMap.ts +14 -1
  9. package/src/compiler/declaredExports.ts +41 -0
  10. package/src/compiler/handlersUnder.ts +1 -0
  11. package/src/compiler/hookCalls.ts +1 -0
  12. package/src/compiler/projectShadow.ts +52 -2
  13. package/src/compiler/readsIn.ts +39 -34
  14. package/src/server/PageRender.ts +7 -8
  15. package/src/server/abideModules.ts +6 -0
  16. package/src/server/answerRequest.ts +115 -52
  17. package/src/server/answerSocket.ts +6 -5
  18. package/src/server/answerWorker.ts +34 -23
  19. package/src/server/assistant.ts +141 -35
  20. package/src/server/banner.ts +9 -5
  21. package/src/server/bridgeRelay.ts +3 -3
  22. package/src/server/callerOf.ts +5 -0
  23. package/src/server/claudeCode.ts +20 -11
  24. package/src/server/commands.ts +14 -7
  25. package/src/server/createApp.ts +65 -12
  26. package/src/server/devWorker.ts +18 -16
  27. package/src/server/generatedSurfaces.ts +6 -5
  28. package/src/server/handlerFileMount.ts +2 -0
  29. package/src/server/hooks.ts +54 -8
  30. package/src/server/index.ts +18 -4
  31. package/src/server/invokeHandler.ts +1 -1
  32. package/src/server/mountHandlers.ts +6 -0
  33. package/src/server/principal.ts +3 -2
  34. package/src/server/render.ts +11 -2
  35. package/src/server/requestScope.ts +61 -1
  36. package/src/server/responseRelay.ts +9 -3
  37. package/src/server/serveDev.ts +18 -3
  38. package/src/server/sqliteStore.ts +65 -0
  39. package/src/server/testMount.ts +1 -0
  40. package/src/shared/AssistantEvent.ts +40 -0
  41. package/src/shared/BrowserHooks.ts +73 -0
  42. package/src/shared/CURRENT_ROUTER.ts +3 -1
  43. package/src/shared/Issues.ts +11 -3
  44. package/src/shared/Middleware.ts +9 -9
  45. package/src/shared/UNEXPECTED.ts +1 -1
  46. package/src/shared/answerOf.ts +9 -4
  47. package/src/shared/imagePiece.ts +85 -0
  48. package/src/shared/index.ts +17 -0
  49. package/src/shared/isServer.ts +15 -0
  50. package/src/shared/navigate.ts +10 -3
  51. package/src/shared/onError.ts +51 -0
  52. package/src/shared/onRefusal.ts +47 -0
  53. package/src/shared/online.ts +5 -1
  54. package/src/shared/reactive/Keyed.ts +12 -1
  55. package/src/shared/reactive/Reactive.ts +53 -25
  56. package/src/shared/reactive/cellShape.ts +4 -3
  57. package/src/shared/reactive/poll.ts +49 -0
  58. package/src/shared/reactive/scoped.ts +10 -1
  59. package/src/shared/reactive/scoping.ts +14 -0
  60. package/src/shared/reactive/watch.ts +19 -8
  61. package/src/shared/resultsOf.ts +21 -0
  62. package/src/shared/route.ts +18 -4
  63. package/src/shared/rpc.ts +112 -26
  64. package/src/shared/seededDocument.ts +5 -6
  65. package/src/shared/storedValue.ts +32 -0
  66. package/src/shared/webStore.ts +54 -0
  67. package/src/ui/Router.ts +42 -9
  68. package/src/ui/outboxQueue.ts +1 -1
  69. package/src/ui/wire.ts +4 -4
  70. package/src/shared/KEEP_OPENED.ts +0 -7
  71. /package/src/{server → shared}/IMAGE_TYPES.ts +0 -0
@@ -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'
@@ -0,0 +1,15 @@
1
+ // 22.11 — WHICH SIDE THE WORK RUNNING IS ON, read where it runs rather than fixed at build or load.
2
+ //
3
+ // NOT `typeof document`: a test process preloads a DOM and runs both halves of a page through one
4
+ // instance of every module — `App.fetch` and `mount`'s render are a server's, `mount`'s hydration a
5
+ // browser's — so the globals are the same on both sides and a build-time constant is one answer for
6
+ // two. What tells them apart is what the server alone enters: a request scope, or a render (the
7
+ // provider's `serverStops`). A process with no document at all is a server whatever it is doing.
8
+
9
+ import { serverStops } from './reactive/scoping.ts'
10
+
11
+ /** Whether the work running is a server's. */
12
+ export function isServer(): boolean {
13
+ if (typeof document === 'undefined') return true
14
+ return serverStops() !== undefined
15
+ }
@@ -1,7 +1,8 @@
1
1
  import { CURRENT_ROUTER, type NavigateOptions } from './CURRENT_ROUTER.ts'
2
2
 
3
3
  /**
4
- * Navigates the document's router to a URL without reloading; rejects where there is no router.
4
+ * Navigates the document's router to a URL, or steps its history by a number of entries, without
5
+ * reloading; rejects where there is no router.
5
6
  *
6
7
  * 30.7 — NAVIGATES, AND MUST NOT RELOAD THE DOCUMENT. The whole of that clause lives in the client
7
8
  * router; this is the app-facing name, and it sits on the isomorphic seam because that is the
@@ -11,8 +12,13 @@ import { CURRENT_ROUTER, type NavigateOptions } from './CURRENT_ROUTER.ts'
11
12
  * refuses rather than resolving quietly — a `navigate` that no-ops during a render is a page that
12
13
  * renders the wrong thing and says nothing.
13
14
  */
15
+ export function navigate(by: number): Promise<void>
14
16
  export function navigate(
15
- url: URL | string = '',
17
+ url?: URL | string,
18
+ options?: NavigateOptions,
19
+ ): Promise<void>
20
+ export function navigate(
21
+ to: URL | string | number = '',
16
22
  options: NavigateOptions = {},
17
23
  ): Promise<void> {
18
24
  const router = CURRENT_ROUTER.current
@@ -22,5 +28,6 @@ export function navigate(
22
28
  'navigate() was called with no router in this document. A browser gets one from the routes the build emitted; a server render has no document to navigate (RULEBOOK 30.7).',
23
29
  ),
24
30
  )
25
- return router.go(url, options)
31
+ // 30.38 — A NUMBER IS A STEP THROUGH THE HISTORY, as `history.go` takes one.
32
+ return typeof to === 'number' ? router.step(to) : router.go(to, options)
26
33
  }
@@ -0,0 +1,51 @@
1
+ // 37.16, 37.17, 37.18 — THE BROWSER'S `onError`: the name and the signature `@abide/abide/server`'s
2
+ // has, called in a `<script>` rather than a `<script server>`, and filed while the component that
3
+ // called it stands — so a layout's hears every page below it, as the server's hears every route
4
+ // below it. A server's is `#server/hooks.ts`'s, which `@abide/abide/server` exports over this one;
5
+ // this one called in a server's render files nothing.
6
+ //
7
+ // WHAT NOTHING CAUGHT IS THE BROWSER'S OWN ACCOUNT: the window's `error` event and its
8
+ // `unhandledrejection`, which a call's refusal no handler awaited or caught arrives as. 12.6's
9
+ // throws are handed through `UNEXPECTED` as they are on a server, and the one of them that then
10
+ // surfaces as uncaught (12.7) is not handed a second time.
11
+
12
+ import { BrowserHooks } from './BrowserHooks.ts'
13
+ import { UNEXPECTED } from './UNEXPECTED.ts'
14
+
15
+ const HOOKS = new BrowserHooks<[error: unknown]>(
16
+ 'onError',
17
+ "abide: onError of `@abide/abide` files only in a component's <script>; a <script server> or a server module files the server's, of `@abide/abide/server`. RULEBOOK 37.21.",
18
+ listen,
19
+ )
20
+
21
+ // What 12.6 handed and 12.7 may yet surface as uncaught, held until the task that handed it is over.
22
+ const SURFACING: unknown[] = []
23
+
24
+ /** Files `hook` for every failure nothing caught while the calling component stands; returns what unfiles it. */
25
+ export function onError(hook: (error: unknown) => unknown): () => void {
26
+ return HOOKS.file(hook)
27
+ }
28
+
29
+ function listen(): void {
30
+ // A SERVER'S SLOT IS LEFT AS IT IS: a test with a document and a server has both lanes.
31
+ UNEXPECTED.report ??= handedUnexpected
32
+ addEventListener('error', (event) => surfaced((event as ErrorEvent).error))
33
+ addEventListener('unhandledrejection', (event) =>
34
+ surfaced((event as PromiseRejectionEvent).reason),
35
+ )
36
+ }
37
+
38
+ function handedUnexpected(error: unknown): void {
39
+ if (SURFACING.length === 0) setTimeout(() => (SURFACING.length = 0))
40
+ SURFACING.push(error)
41
+ HOOKS.hand(error)
42
+ }
43
+
44
+ function surfaced(error: unknown): void {
45
+ const at = SURFACING.indexOf(error)
46
+ if (at !== -1) {
47
+ SURFACING.splice(at, 1)
48
+ return
49
+ }
50
+ HOOKS.hand(error)
51
+ }
@@ -0,0 +1,47 @@
1
+ // 37.22-37.27 — THE BROWSER'S `onRefusal`: filed as the browser's `onError` is, and handed every
2
+ // refusal a call over http is answered with in this browser — a refusal settles as the value's
3
+ // `error` rather than throwing, so `onError` never hears one a page read.
4
+ //
5
+ // HANDED WHERE AN ANSWER BECOMES A `Failed`, `answered` in `answerOf.ts`, which every browser call
6
+ // reads its answer through — a handler's `wire`, a handler abide mounts, and a call `outbox` sends —
7
+ // so no caller carries a branch of its own. An aborted call is never answered (16.49), and a call
8
+ // 16.81 settles to a stand-in is not refused.
9
+
10
+ import { BrowserHooks } from './BrowserHooks.ts'
11
+ import type { Failed } from './Failed.ts'
12
+ import { untracked } from './reactive/tracking.ts'
13
+ import type { Method } from './rpc.ts'
14
+
15
+ export type RefusedCall = { method: Method; url: string }
16
+
17
+ const HOOKS = new BrowserHooks<
18
+ [refusal: Failed<string, unknown>, call: RefusedCall]
19
+ >(
20
+ 'onRefusal',
21
+ "abide: onRefusal files only in a component's <script>. RULEBOOK 37.23.",
22
+ )
23
+
24
+ /** Files `hook` for every refusal a call is answered with while the calling component stands; returns what unfiles it. */
25
+ export function onRefusal(
26
+ hook: (refusal: Failed<string, unknown>, call: RefusedCall) => unknown,
27
+ ): () => void {
28
+ return HOOKS.file(hook)
29
+ }
30
+
31
+ /** 37.24 — hands `refusal`, the answer to `call`, to every `onRefusal` filed. */
32
+ export function refused(
33
+ refusal: Failed<string, unknown>,
34
+ call: RefusedCall,
35
+ ): void {
36
+ if (HOOKS.empty()) return
37
+ // UNTRACKED: an answer the server painted settled is read inside the value's body (41.27), and a
38
+ // hook's read there would subscribe that value to whatever the hook reads.
39
+ untracked(handRefused, refusal, { method: call.method, url: call.url })
40
+ }
41
+
42
+ function handRefused(
43
+ refusal: Failed<string, unknown>,
44
+ call: RefusedCall,
45
+ ): void {
46
+ HOOKS.hand(refusal, call)
47
+ }