@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.
- package/ABIDE.md +4 -0
- package/CHANGELOG.md +36 -0
- package/bin/abide.ts +3 -2
- package/docs/REGISTRY.md +66 -40
- package/docs/RULEBOOK.md +202 -18
- package/docs/abide.md +391 -67
- package/package.json +1 -1
- 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 +52 -2
- package/src/compiler/readsIn.ts +39 -34
- 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 +53 -25
- 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
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'
|
|
@@ -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
|
+
}
|
package/src/shared/navigate.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|