@abide/abide 0.105.0 → 0.106.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/CHANGELOG.md +24 -0
- package/bin/abide.ts +4 -1
- package/docs/REGISTRY.md +156 -44
- package/docs/RULEBOOK.md +375 -87
- package/docs/abide.md +7243 -10256
- package/package.json +4 -1
- package/scaffold/AGENTS.md +2 -0
- package/scaffold/src/getGreeting.ts +6 -0
- package/scaffold/src/index.abide +3 -1
- package/scaffold/src/tests/getGreeting.test.ts +6 -0
- package/scaffold/src/tests/index.test.ts +18 -0
- package/src/compiler/DOCS_SITE.ts +2 -0
- package/src/compiler/Lowering.ts +234 -14
- package/src/compiler/PositionTable.ts +14 -7
- package/src/compiler/PositionedRefusal.ts +13 -0
- package/src/compiler/Refusals.ts +18 -11
- package/src/compiler/SHARED_NAMES.ts +2 -0
- package/src/compiler/ShadowMap.ts +37 -0
- package/src/compiler/TEMPLATE_BLOCKS.ts +12 -0
- package/src/compiler/TypeSession.ts +410 -31
- package/src/compiler/appLayout.ts +18 -2
- package/src/compiler/argsSchemaOf.ts +55 -9
- package/src/compiler/checkApp.ts +33 -0
- package/src/compiler/checkedShadow.ts +36 -22
- package/src/compiler/citedClauses.ts +14 -0
- package/src/compiler/classifyRead.ts +6 -0
- package/src/compiler/classifyTemplate.ts +2 -1
- package/src/compiler/compileApp.ts +2 -2
- package/src/compiler/declaredExports.ts +27 -6
- package/src/compiler/emitClient.ts +304 -103
- package/src/compiler/handlersUnder.ts +4 -0
- package/src/compiler/invocationProps.ts +1 -1
- package/src/compiler/isIgnored.ts +27 -0
- package/src/compiler/parseAbide.ts +176 -76
- package/src/compiler/projectShadow.ts +30 -6
- package/src/compiler/readSources.ts +1 -1
- package/src/compiler/readsIn.ts +100 -1
- package/src/compiler/renderMarkup.ts +36 -5
- package/src/compiler/scaffold.ts +19 -6
- package/src/compiler/serveLanguage.ts +156 -21
- package/src/compiler/templateContext.ts +150 -0
- package/src/compiler/tokenize.ts +6 -0
- package/src/server/BRIDGE_VERSION.ts +1 -1
- package/src/server/Inbox.ts +8 -0
- package/src/server/MCP_PROTOCOL_VERSION.ts +1 -1
- package/src/server/MOUNTABLE.ts +6 -0
- package/src/server/abideModules.ts +8 -1
- package/src/server/answerRequest.ts +3 -1
- package/src/server/answerRpc.ts +26 -12
- package/src/server/answerSocket.ts +26 -1
- package/src/server/argsFor.ts +4 -3
- package/src/server/assistant.ts +19 -13
- package/src/server/bridgeRelay.ts +108 -20
- package/src/server/claudeApi.ts +7 -7
- package/src/server/claudeCode.ts +11 -11
- package/src/server/commands.ts +73 -9
- package/src/server/createApp.ts +41 -7
- package/src/server/devWorker.ts +3 -3
- package/src/server/generatedSurfaces.ts +37 -4
- package/src/server/muxUpgrade.ts +47 -0
- package/src/server/preload.ts +13 -2
- package/src/server/render.ts +4 -1
- package/src/server/runCompiled.ts +1 -0
- package/src/server/seedBuffer.ts +31 -1
- package/src/server/serveDev.ts +83 -72
- package/src/server/surfaceOf.ts +3 -0
- package/src/server/testMount.ts +53 -0
- package/src/shared/ADDRESSES.ts +1 -1
- package/src/shared/ERROR_META.ts +3 -0
- package/src/shared/Schema.ts +1 -1
- package/src/shared/WORK.ts +14 -12
- package/src/shared/applyPatch.ts +1 -1
- package/src/shared/boundValue.ts +81 -0
- package/src/shared/index.ts +7 -2
- package/src/shared/jsonSchema.ts +25 -2
- package/src/shared/log.ts +11 -1
- package/src/shared/reactive/Keyed.ts +30 -6
- package/src/shared/reactive/PATCHES.ts +1 -1
- package/src/shared/reactive/Reactive.ts +118 -60
- package/src/shared/reactive/canonicalKey.ts +1 -1
- package/src/shared/reactive/index.ts +13 -5
- package/src/shared/reactive/recordWrites.ts +1 -1
- package/src/shared/reactive/watch.ts +26 -4
- package/src/shared/rpc.ts +6 -1
- package/src/shared/url.ts +7 -3
- package/src/shared/validateJson.ts +1 -1
- package/src/ui/ArmRegion.ts +2 -2
- package/src/ui/List.ts +9 -9
- package/src/ui/RawRun.ts +1 -1
- package/src/ui/Router.ts +194 -36
- package/src/ui/Scope.ts +29 -0
- package/src/ui/adoptWalk.ts +3 -3
- package/src/ui/applyHead.ts +25 -3
- package/src/ui/bindProperty.ts +70 -22
- package/src/ui/index.ts +1 -1
- package/src/ui/leave.ts +5 -0
- package/src/ui/listenForRoutes.ts +14 -3
- package/src/ui/reconcile.ts +48 -28
- package/src/ui/regions.ts +36 -4
- package/src/ui/rows.ts +108 -28
package/src/server/serveDev.ts
CHANGED
|
@@ -31,7 +31,7 @@ import { DEV_ADDRESS } from '#shared/ADDRESSES.ts'
|
|
|
31
31
|
import type { Compilation } from './abideModules.ts'
|
|
32
32
|
import { DEFAULT_PORT } from './DEFAULT_PORT.ts'
|
|
33
33
|
import type { FromWorker, ToWorker } from './devWorker.ts'
|
|
34
|
-
import { upgraded } from './muxUpgrade.ts'
|
|
34
|
+
import { muxSocketOptions, sendOrClose, upgraded } from './muxUpgrade.ts'
|
|
35
35
|
import { placeOf } from './placement.ts'
|
|
36
36
|
import { plainAnswer } from './plainAnswer.ts'
|
|
37
37
|
import { askOver } from './responseRelay.ts'
|
|
@@ -79,7 +79,7 @@ export async function serveDev(
|
|
|
79
79
|
// 38.24, 38.35 — every request, asked of the worker there is and streamed back a chunk per read.
|
|
80
80
|
// 16.22 — a call's idle timeout raised on the request bun handed this listener.
|
|
81
81
|
const relay = askOver((request, seconds) =>
|
|
82
|
-
|
|
82
|
+
served?.timeout(request, seconds),
|
|
83
83
|
)
|
|
84
84
|
// Every socket this listener holds: the reload client's, with no data, and the app mux's, carrying
|
|
85
85
|
// the connection id the worker knows it by.
|
|
@@ -88,6 +88,7 @@ export async function serveDev(
|
|
|
88
88
|
let nextConnection = 0
|
|
89
89
|
let worker: DevWorker | undefined
|
|
90
90
|
let ready = false
|
|
91
|
+
let served: Bun.Server<Carried> | undefined
|
|
91
92
|
|
|
92
93
|
// ONE COMPILE AT A TIME, and the worker is started from the LAST one. A save landing while a
|
|
93
94
|
// compile runs marks it stale rather than starting a second: two in flight each started a
|
|
@@ -201,9 +202,12 @@ export async function serveDev(
|
|
|
201
202
|
stdio: ['inherit', 'inherit', 'inherit'],
|
|
202
203
|
serialization: 'advanced',
|
|
203
204
|
ipc(message: FromWorker) {
|
|
204
|
-
if (message.kind === 'ready')
|
|
205
|
-
|
|
206
|
-
|
|
205
|
+
if (message.kind === 'ready') {
|
|
206
|
+
ready = true
|
|
207
|
+
muxOptions = message.muxOptions
|
|
208
|
+
served?.reload(serving(undefined))
|
|
209
|
+
} else if (message.kind === 'socket-send')
|
|
210
|
+
sendOrClose(muxed.get(message.connection), message.text)
|
|
207
211
|
else relay.take(message)
|
|
208
212
|
},
|
|
209
213
|
// 38.41 — AN APP THAT EXITS ON ITS OWN is replaced, and says so: a listener still
|
|
@@ -290,86 +294,93 @@ export async function serveDev(
|
|
|
290
294
|
void closeChecker().finally(() => process.kill(process.pid, signal))
|
|
291
295
|
})
|
|
292
296
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
297
|
+
// 18.28 — THE MUX'S LIMITS ARE THE APP'S CONFIGURATION, and so the worker's to resolve: each
|
|
298
|
+
// worker reports them on `ready` and the listener is reloaded with them, or `abide dev` would
|
|
299
|
+
// hold a socket to a limit `onConfig` moved in production and not here. Bun applies a reload's
|
|
300
|
+
// `maxPayloadLength` to every connection opened after it.
|
|
301
|
+
let muxOptions = muxSocketOptions()
|
|
302
|
+
const serving = (port: number | undefined): Bun.Serve.Options<Carried> => ({
|
|
303
|
+
...(port === undefined ? {} : { port }),
|
|
304
|
+
// 16.54, as `App.listen` lifts it: the worker behind this is where the ceiling is read.
|
|
305
|
+
maxRequestBodySize: Number.MAX_SAFE_INTEGER,
|
|
306
|
+
async fetch(request, listening) {
|
|
307
|
+
const asked = new URL(request.url).pathname
|
|
308
|
+
// THE MUX STANDS UNDER THE MOUNT, which is the app's configuration and so the worker's to
|
|
309
|
+
// read: every upgrade but the reload client's is asked of it, and the worker says
|
|
310
|
+
// whether it is the mux's (`muxUpgrade`) — a status alone is not, since any rung at any
|
|
311
|
+
// address can answer one.
|
|
312
|
+
const mux =
|
|
313
|
+
asked !== DEV_ADDRESS &&
|
|
314
|
+
request.headers.get('upgrade') === 'websocket'
|
|
315
|
+
if (!mux && asked !== DEV_ADDRESS) return relay.ask(request)
|
|
316
|
+
// 37.3 — THE MUX'S UPGRADE IS ASKED OF THE APP FIRST, whose lane runs in the worker.
|
|
317
|
+
if (mux && ready) {
|
|
318
|
+
const answered = await relay.ask(request)
|
|
319
|
+
if (!relay.admitted(answered)) return answered
|
|
320
|
+
}
|
|
321
|
+
// NOT WHILE THE APP IS COMPILING. The reload client and the browser's mux both read a
|
|
322
|
+
// refusal as "still rebuilding" and retry, which is what keeps a reload from racing
|
|
323
|
+
// the worker it is meant to show.
|
|
324
|
+
if (!ready) return plainAnswer('rebuilding', 503)
|
|
325
|
+
let data: Carried
|
|
326
|
+
if (mux) {
|
|
327
|
+
nextConnection += 1
|
|
328
|
+
data = {
|
|
329
|
+
connection: nextConnection,
|
|
330
|
+
url: request.url,
|
|
331
|
+
headers: [...request.headers.entries()],
|
|
312
332
|
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
}
|
|
333
|
+
}
|
|
334
|
+
return upgraded(listening, request, data)
|
|
335
|
+
},
|
|
336
|
+
websocket: {
|
|
337
|
+
...muxOptions,
|
|
338
|
+
open(socket) {
|
|
339
|
+
// A RELOAD LANDED between the upgrade and this open: the worker it was meant for is
|
|
340
|
+
// gone and the next one would never hear of it, so it is closed and retried instead.
|
|
341
|
+
if (!ready) {
|
|
342
|
+
socket.terminate()
|
|
343
|
+
return
|
|
325
344
|
}
|
|
326
|
-
|
|
345
|
+
sockets.add(socket)
|
|
346
|
+
const carried = socket.data
|
|
347
|
+
if (carried === undefined) return
|
|
348
|
+
muxed.set(carried.connection, socket)
|
|
349
|
+
worker?.postMessage({ kind: 'socket-open', ...carried })
|
|
327
350
|
},
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
return
|
|
335
|
-
}
|
|
336
|
-
sockets.add(socket)
|
|
337
|
-
const carried = socket.data
|
|
338
|
-
if (carried === undefined) return
|
|
339
|
-
muxed.set(carried.connection, socket)
|
|
340
|
-
worker?.postMessage({ kind: 'socket-open', ...carried })
|
|
341
|
-
},
|
|
342
|
-
close(socket) {
|
|
343
|
-
sockets.delete(socket)
|
|
344
|
-
const carried = socket.data
|
|
345
|
-
if (carried === undefined) return
|
|
346
|
-
// A socket a reload terminated is the old worker's, and the new one never knew it.
|
|
347
|
-
if (muxed.delete(carried.connection))
|
|
348
|
-
worker?.postMessage({
|
|
349
|
-
kind: 'socket-close',
|
|
350
|
-
connection: carried.connection,
|
|
351
|
-
})
|
|
352
|
-
},
|
|
353
|
-
// The reload client says nothing; an app socket's frames go to the mux in the worker.
|
|
354
|
-
message(socket, text) {
|
|
355
|
-
const carried = socket.data
|
|
356
|
-
if (carried === undefined) return
|
|
351
|
+
close(socket) {
|
|
352
|
+
sockets.delete(socket)
|
|
353
|
+
const carried = socket.data
|
|
354
|
+
if (carried === undefined) return
|
|
355
|
+
// A socket a reload terminated is the old worker's, and the new one never knew it.
|
|
356
|
+
if (muxed.delete(carried.connection))
|
|
357
357
|
worker?.postMessage({
|
|
358
|
-
kind: 'socket-
|
|
358
|
+
kind: 'socket-close',
|
|
359
359
|
connection: carried.connection,
|
|
360
|
-
text: String(text),
|
|
361
360
|
})
|
|
362
|
-
},
|
|
363
361
|
},
|
|
364
|
-
|
|
362
|
+
// The reload client says nothing; an app socket's frames go to the mux in the worker.
|
|
363
|
+
message(socket, text) {
|
|
364
|
+
const carried = socket.data
|
|
365
|
+
if (carried === undefined) return
|
|
366
|
+
worker?.postMessage({
|
|
367
|
+
kind: 'socket-message',
|
|
368
|
+
connection: carried.connection,
|
|
369
|
+
text: String(text),
|
|
370
|
+
})
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
})
|
|
374
|
+
const listen = (port: number | undefined) =>
|
|
375
|
+
Bun.serve<Carried>(serving(port))
|
|
365
376
|
|
|
366
377
|
// 38.64 — A PORT SOMEONE NAMED IS THE PORT, and one taken is the failure. `Bun.serve` given
|
|
367
378
|
// none reads `PORT` itself.
|
|
368
|
-
|
|
379
|
+
served =
|
|
369
380
|
options.port !== undefined || process.env.PORT
|
|
370
381
|
? listen(options.port)
|
|
371
382
|
: listenOnFirstFree(listen)
|
|
372
|
-
return
|
|
383
|
+
return served as unknown as Bun.Server<undefined>
|
|
373
384
|
}
|
|
374
385
|
|
|
375
386
|
// 38.63 — NAMED BY NOBODY, the first free port from 3000. A port taken is the next one tried, and
|
package/src/server/surfaceOf.ts
CHANGED
|
@@ -15,6 +15,8 @@ export type Listed = {
|
|
|
15
15
|
description: string | undefined
|
|
16
16
|
/** Its args' JSON Schema, where it has one. 19.9, 20.11. */
|
|
17
17
|
schema: unknown
|
|
18
|
+
/** What it answers, as JSON Schema, where its type says. 19.21. */
|
|
19
|
+
result: unknown
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
/**
|
|
@@ -47,6 +49,7 @@ export function surfaceOf(
|
|
|
47
49
|
description:
|
|
48
50
|
live === undefined ? mount.description : live.handler.description,
|
|
49
51
|
schema: live === undefined ? mount.args : live.argsDocument,
|
|
52
|
+
result: mount.result,
|
|
50
53
|
}
|
|
51
54
|
}
|
|
52
55
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// 38.119 — `mount` from `@abide/abide/test`: a component an app's test imported and bound, rendered
|
|
2
|
+
// into the document's body as the server writes it and hydrated there by its browser module — the
|
|
3
|
+
// same two halves a page load runs, in one process, so a test can click it and read the work.
|
|
4
|
+
|
|
5
|
+
import { flushSync } from '#shared/reactive/Reactive.ts'
|
|
6
|
+
import { MOUNTABLE } from './MOUNTABLE.ts'
|
|
7
|
+
import { type Component, render } from './render.ts'
|
|
8
|
+
|
|
9
|
+
type Mountable = {
|
|
10
|
+
accessors: Record<string, () => unknown>
|
|
11
|
+
client: () => Promise<{
|
|
12
|
+
default: (
|
|
13
|
+
region: unknown,
|
|
14
|
+
props: Record<string, () => unknown>,
|
|
15
|
+
) => { stop(): void } | undefined
|
|
16
|
+
}>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A mounted component: `stop` ends every watch its hydration opened. */
|
|
20
|
+
export type Mounted = { stop(): void; [Symbol.dispose](): void }
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Renders `component` into `document.body` and hydrates it there, with every effect its mount woke
|
|
24
|
+
* already run.
|
|
25
|
+
*/
|
|
26
|
+
export async function mount(component: Component): Promise<Mounted> {
|
|
27
|
+
const mountable = (component as Record<symbol, Mountable | undefined>)[
|
|
28
|
+
MOUNTABLE
|
|
29
|
+
]
|
|
30
|
+
if (mountable === undefined)
|
|
31
|
+
throw new Error(
|
|
32
|
+
'abide: mount takes a component a test imported from a `.abide` and bound — `mount(Counter({ start: 2 }))`. RULEBOOK 38.119.',
|
|
33
|
+
)
|
|
34
|
+
if (typeof document === 'undefined')
|
|
35
|
+
throw new Error(
|
|
36
|
+
'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.',
|
|
37
|
+
)
|
|
38
|
+
const html = new TextDecoder().decode(await render(component))
|
|
39
|
+
// After the `<body …>` open tag, whatever attributes the component wrote onto it (35.15).
|
|
40
|
+
document.body.innerHTML = html.slice(
|
|
41
|
+
html.indexOf('>', html.indexOf('<body')) + 1,
|
|
42
|
+
html.indexOf('</body>'),
|
|
43
|
+
)
|
|
44
|
+
// LOADED HERE, not at the top: the preload this module is part of runs in processes with no DOM.
|
|
45
|
+
const [{ default: hydrate }, { pageRegion }] = await Promise.all([
|
|
46
|
+
mountable.client(),
|
|
47
|
+
import('#ui/regions.ts'),
|
|
48
|
+
])
|
|
49
|
+
const scope = hydrate(pageRegion(document.body), mountable.accessors)
|
|
50
|
+
flushSync()
|
|
51
|
+
const stop = (): void => scope?.stop()
|
|
52
|
+
return { stop, [Symbol.dispose]: stop }
|
|
53
|
+
}
|
package/src/shared/ADDRESSES.ts
CHANGED
|
@@ -35,7 +35,7 @@ export const OPENAPI_ADDRESS = `${ABIDE_ADDRESS}/openapi.json`
|
|
|
35
35
|
export const MCP_ADDRESS = `${ABIDE_ADDRESS}/mcp`
|
|
36
36
|
/** The health check. */
|
|
37
37
|
export const HEALTH_ADDRESS = `${ABIDE_ADDRESS}/health`
|
|
38
|
-
/** Where a visitor's Claude Code bridge pairs, and is relayed turns (
|
|
38
|
+
/** Where a visitor's Claude Code bridge pairs, and is relayed turns (44.13). */
|
|
39
39
|
export const BRIDGE_ADDRESS = `${ABIDE_ADDRESS}/bridge`
|
|
40
40
|
|
|
41
41
|
/**
|
package/src/shared/Schema.ts
CHANGED
|
@@ -161,7 +161,7 @@ function runSchemaTracked(schema: unknown, value: unknown): Checked {
|
|
|
161
161
|
// rather than returning it. A real `Schema<boolean>` only ever sees booleans.
|
|
162
162
|
if (typeof returned === 'boolean' && typeof value !== 'boolean')
|
|
163
163
|
throw new Error(
|
|
164
|
-
'A predicate is not a Schema: it answered true/false where the value was expected. A Schema returns what it accepts (RULEBOOK 19.2) and throws what it refuses (19.4).',
|
|
164
|
+
'A predicate is not a Schema: it answered true/false where the value was expected. A Schema returns what it accepts (RULEBOOK 19.2) and throws what it refuses (RULEBOOK 19.4).',
|
|
165
165
|
)
|
|
166
166
|
return accepted(returned)
|
|
167
167
|
}
|
package/src/shared/WORK.ts
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The three work counters the measurement lane reads, and the record they are published on.
|
|
3
3
|
*
|
|
4
|
-
* A leaf — no imports of its own — because both seams increment it and
|
|
5
|
-
* off `globalThis` rather than importing it: that lane may have
|
|
6
|
-
* (
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* never increments is created at 0 and read back at 0 — and every gate asserting it passes
|
|
10
|
-
* against a counter that was never wired (44.21). Publishing two of three is worse than
|
|
11
|
-
* publishing none, because the third looks measured.
|
|
4
|
+
* A leaf — no imports of its own — because both seams increment it and `@abide/harness/measure`
|
|
5
|
+
* reads it off `globalThis` rather than importing it: that lane may have nothing under test in its
|
|
6
|
+
* graph at all (43.1), so the two halves cannot share a declaration and the key is spelled on both
|
|
7
|
+
* sides. `@abide/harness/abide`'s `measure` reads it back by these field names, and throws naming
|
|
8
|
+
* one the record lacks (43.20).
|
|
12
9
|
*
|
|
13
10
|
* WHAT IT COSTS is one integer increment on the propagation walk and one per effect run, and it
|
|
14
11
|
* is UNDER THE NOISE FLOOR rather than priced: two runs of `bun run bench:reactive` put the
|
|
@@ -31,7 +28,12 @@ export const WORK = {
|
|
|
31
28
|
descents: 0,
|
|
32
29
|
}
|
|
33
30
|
|
|
34
|
-
// The
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
31
|
+
// The key is spelled here and again in the harness's own leaf. Two copies on purpose: the lane
|
|
32
|
+
// checks the SHAPE at the read rather than sharing a type, so a rename breaks a test rather than
|
|
33
|
+
// silently publishing to a key nothing looks at.
|
|
34
|
+
type HarnessGlobal = {
|
|
35
|
+
__HARNESS_WORK__?: Record<string, Record<string, number>>
|
|
36
|
+
}
|
|
37
|
+
const published = (globalThis as HarnessGlobal).__HARNESS_WORK__ ?? {}
|
|
38
|
+
published.abide = WORK
|
|
39
|
+
;(globalThis as HarnessGlobal).__HARNESS_WORK__ = published
|
package/src/shared/applyPatch.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// 18.11 — WHAT A PATCH WROTE, WRITTEN AGAIN HERE: a JSON Patch (RFC 6902) applied in order, each op's
|
|
2
2
|
// `path` a JSON Pointer (RFC 6901). Called inside the value's own `s.patch`, so the rows it reaches are
|
|
3
|
-
// the rows 32.33
|
|
3
|
+
// the rows 32.33 updates. A browser replays the server's writes.
|
|
4
4
|
//
|
|
5
5
|
// A pointer walks own properties alone, and an array step is an index or, for `add`, `-`. Every op a
|
|
6
6
|
// patch records passes, so the check costs a browser replaying its server nothing, and an op naming
|
package/src/shared/boundValue.ts
CHANGED
|
@@ -56,3 +56,84 @@ export function boundProp(hold: () => unknown): (held?: true) => unknown {
|
|
|
56
56
|
export function groupHolds(group: unknown, value: unknown): boolean {
|
|
57
57
|
return Array.isArray(group) ? group.includes(value) : group === value
|
|
58
58
|
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether a `<select>`'s bound value selects an option: it is the option's value, or a list
|
|
62
|
+
* holding it.
|
|
63
|
+
*
|
|
64
|
+
* 32.61, 32.62 — compared as TEXT, unlike `groupHolds`: a select's value is written to the
|
|
65
|
+
* property as its text, so a number selects the option whose value spells it.
|
|
66
|
+
*/
|
|
67
|
+
export function selectHolds(value: unknown, option: string): boolean {
|
|
68
|
+
if (!Array.isArray(value))
|
|
69
|
+
return value !== undefined && value !== null && String(value) === option
|
|
70
|
+
for (const member of value) if (String(member) === option) return true
|
|
71
|
+
return false
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// An `<option` open tag: the name, then attributes up to the `>` that ends it, a quoted value
|
|
75
|
+
// carrying any `>` it holds.
|
|
76
|
+
const OPTION_TAG = /<option(?=[\s>])((?:[^>"']|"[^"]*"|'[^']*')*)>/gi
|
|
77
|
+
const VALUE_ATTRIBUTE = /\svalue\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/i
|
|
78
|
+
// What ends an option's text where it has no `value`: the next tag that is not a comment.
|
|
79
|
+
const OPTION_TEXT_END = /<(?!!--)/
|
|
80
|
+
const COMMENT = /<!--[\s\S]*?-->/g
|
|
81
|
+
const REFERENCE = /&(?:#(\d+)|#x([\da-f]+)|(amp|lt|gt|quot|apos));/gi
|
|
82
|
+
const NAMED: Record<string, string> = {
|
|
83
|
+
amp: '&',
|
|
84
|
+
lt: '<',
|
|
85
|
+
gt: '>',
|
|
86
|
+
quot: '"',
|
|
87
|
+
apos: "'",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A `<select>`'s rendered options with `selected` written on each its bound value selects.
|
|
92
|
+
*
|
|
93
|
+
* 32.61 — THE SERVER'S HALF OF THE PROPERTY: a select's value is no attribute of its own, so the
|
|
94
|
+
* painted page carries it on the options. Read off the markup rather than the template, because an
|
|
95
|
+
* option may come from a `{#for}`, an arm or a component, and every one of them has rendered by
|
|
96
|
+
* now. A single select marks the first match, which is the one assigning `value` selects.
|
|
97
|
+
*/
|
|
98
|
+
export function selectedMarkup(markup: string, value: unknown): string {
|
|
99
|
+
if (value === undefined || value === null) return markup
|
|
100
|
+
const multiple = Array.isArray(value)
|
|
101
|
+
let out = ''
|
|
102
|
+
let cut = 0
|
|
103
|
+
let marked = false
|
|
104
|
+
for (const match of markup.matchAll(OPTION_TAG)) {
|
|
105
|
+
if (marked && !multiple) break
|
|
106
|
+
const end = (match.index as number) + match[0].length
|
|
107
|
+
const named = VALUE_ATTRIBUTE.exec(match[1] as string)
|
|
108
|
+
let text: string
|
|
109
|
+
if (named !== null)
|
|
110
|
+
text = decoded(named[1] ?? named[2] ?? named[3] ?? '')
|
|
111
|
+
else {
|
|
112
|
+
// The option's text, as the browser reads `value` from it: comments out,
|
|
113
|
+
// whitespace collapsed.
|
|
114
|
+
const rest = markup.slice(end)
|
|
115
|
+
const stop = rest.search(OPTION_TEXT_END)
|
|
116
|
+
text = decoded(
|
|
117
|
+
(stop === -1 ? rest : rest.slice(0, stop)).replace(COMMENT, ''),
|
|
118
|
+
)
|
|
119
|
+
.replace(/\s+/g, ' ')
|
|
120
|
+
.trim()
|
|
121
|
+
}
|
|
122
|
+
if (!selectHolds(value, text)) continue
|
|
123
|
+
out += `${markup.slice(cut, end - 1)} selected>`
|
|
124
|
+
cut = end
|
|
125
|
+
marked = true
|
|
126
|
+
}
|
|
127
|
+
return cut === 0 ? markup : out + markup.slice(cut)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function decoded(text: string): string {
|
|
131
|
+
if (!text.includes('&')) return text
|
|
132
|
+
return text.replace(REFERENCE, (whole, decimal, hex, name) =>
|
|
133
|
+
decimal !== undefined
|
|
134
|
+
? String.fromCodePoint(Number(decimal))
|
|
135
|
+
: hex !== undefined
|
|
136
|
+
? String.fromCodePoint(Number.parseInt(hex, 16))
|
|
137
|
+
: (NAMED[(name as string).toLowerCase()] ?? whole),
|
|
138
|
+
)
|
|
139
|
+
}
|
package/src/shared/index.ts
CHANGED
|
@@ -15,7 +15,12 @@ export type { Attributes } from './Attributes.ts'
|
|
|
15
15
|
// 32.37 — what a value writes into an attribute, on both sides.
|
|
16
16
|
export { attributeMarkup, attributeText } from './attributeText.ts'
|
|
17
17
|
// EMITTED CODE'S: the value a `bind:` operand holds, as the server writes it into the tag (32.5).
|
|
18
|
-
export {
|
|
18
|
+
export {
|
|
19
|
+
boundProp,
|
|
20
|
+
boundValue,
|
|
21
|
+
groupHolds,
|
|
22
|
+
selectedMarkup,
|
|
23
|
+
} from './boundValue.ts'
|
|
19
24
|
export { type Cookies, cookies } from './cookies.ts'
|
|
20
25
|
export { csp } from './csp.ts'
|
|
21
26
|
export {
|
|
@@ -43,7 +48,6 @@ export {
|
|
|
43
48
|
type MenuRole,
|
|
44
49
|
menuItem,
|
|
45
50
|
} from './menuItem.ts'
|
|
46
|
-
export { onMenu } from './onMenu.ts'
|
|
47
51
|
// `navigate` ALONE, of everything routing needs. This barrel is what an app resolves through the
|
|
48
52
|
// bare `@abide/abide` specifier, and the matcher, the pattern grammar and `routeOf` are reached by path
|
|
49
53
|
// from the two places that have them — the build and the client router. Putting them here would
|
|
@@ -51,6 +55,7 @@ export { onMenu } from './onMenu.ts'
|
|
|
51
55
|
// modules into the static closure of the entry every page imports.
|
|
52
56
|
export { navigate } from './navigate.ts'
|
|
53
57
|
export { online } from './online.ts'
|
|
58
|
+
export { onMenu } from './onMenu.ts'
|
|
54
59
|
export { type Principal, principal } from './principal.ts'
|
|
55
60
|
// EMITTED CODE'S: a prop the child writes (33.6, 33.7).
|
|
56
61
|
export {
|
package/src/shared/jsonSchema.ts
CHANGED
|
@@ -14,8 +14,17 @@ import { validateJson } from './validateJson.ts'
|
|
|
14
14
|
* `validateJson`'s own, split at the dots `runSchema` joins it back at. And the document is
|
|
15
15
|
* exposed where a Standard Schema exposes its JSON Schema, so 20.11's one probe publishes it with
|
|
16
16
|
* no case of its own.
|
|
17
|
+
*
|
|
18
|
+
* 19.23 — WITH NO TYPE ARGUMENT IT IS `Untyped`, whose `validate` answers `any`: assignable to every
|
|
19
|
+
* slot, and a source the checker draws NO candidate from. A document says nothing the checker can
|
|
20
|
+
* read, so the value's type has to come from what the schema gates. Typed `StandardSchemaV1<never>`
|
|
21
|
+
* instead, `never` was a candidate nested inside the schema, and a candidate that is not top level
|
|
22
|
+
* stops a literal widening: `reactive(0, { schema })` was `Reactive<0>`. Typed
|
|
23
|
+
* `StandardSchemaV1<any>`, `any` was a candidate and every gated value became `any`.
|
|
17
24
|
*/
|
|
18
|
-
export function jsonSchema<T
|
|
25
|
+
export function jsonSchema<T = Untyped>(
|
|
26
|
+
document: JsonValue,
|
|
27
|
+
): [T] extends [Untyped] ? UntypedSchema : StandardSchemaV1<T> {
|
|
19
28
|
return {
|
|
20
29
|
'~standard': {
|
|
21
30
|
version: 1,
|
|
@@ -46,5 +55,19 @@ export function jsonSchema<T>(document: JsonValue): StandardSchemaV1<T> {
|
|
|
46
55
|
output: () => document,
|
|
47
56
|
},
|
|
48
57
|
},
|
|
49
|
-
} as StandardSchemaV1<T>
|
|
58
|
+
} as [T] extends [Untyped] ? UntypedSchema : StandardSchemaV1<T>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
declare const UNTYPED: unique symbol
|
|
62
|
+
|
|
63
|
+
/** The type argument a bare `jsonSchema` takes: no output was named. */
|
|
64
|
+
type Untyped = typeof UNTYPED
|
|
65
|
+
|
|
66
|
+
/** A Standard Schema whose output is whatever the slot it gates says (19.23). */
|
|
67
|
+
type UntypedSchema = {
|
|
68
|
+
readonly '~standard': {
|
|
69
|
+
readonly version: 1
|
|
70
|
+
readonly vendor: string
|
|
71
|
+
readonly validate: (value: unknown) => any
|
|
72
|
+
}
|
|
50
73
|
}
|
package/src/shared/log.ts
CHANGED
|
@@ -144,6 +144,14 @@ function currentGate(): Gate {
|
|
|
144
144
|
return gate
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Whether `DEBUG` could enable any namespace at all: with no inclusion nothing passes, so a gated
|
|
149
|
+
* line returns before its namespace is built (28.12 still reads it on every line that can write).
|
|
150
|
+
*/
|
|
151
|
+
function anyEnabled(): boolean {
|
|
152
|
+
return currentGate().include.length !== 0
|
|
153
|
+
}
|
|
154
|
+
|
|
147
155
|
/** Whether `DEBUG` enables a namespace's gated lines (28.6). */
|
|
148
156
|
function isEnabled(namespace: string): boolean {
|
|
149
157
|
const { include, exclude } = currentGate()
|
|
@@ -247,10 +255,12 @@ function loggerOver(namespaceOf: () => string): Logger {
|
|
|
247
255
|
write(namespaceOf(), 'info', args)
|
|
248
256
|
}) as Logger
|
|
249
257
|
logger.info = (...args) => {
|
|
258
|
+
if (!anyEnabled()) return
|
|
250
259
|
const namespace = namespaceOf()
|
|
251
260
|
if (isEnabled(namespace)) write(namespace, 'info', args)
|
|
252
261
|
}
|
|
253
262
|
logger.debug = (...args) => {
|
|
263
|
+
if (!anyEnabled()) return
|
|
254
264
|
const namespace = namespaceOf()
|
|
255
265
|
if (isEnabled(namespace)) write(namespace, 'debug', args)
|
|
256
266
|
}
|
|
@@ -259,7 +269,7 @@ function loggerOver(namespaceOf: () => string): Logger {
|
|
|
259
269
|
// 28.4 — another segment, below this one. 28.12 — asked per line too, so a logger named at module
|
|
260
270
|
// load, before the app's name resolved, writes under the name once it has.
|
|
261
271
|
logger.named = (name) => loggerOver(() => `${namespaceOf()}:${name}`)
|
|
262
|
-
logger.enabled = () => isEnabled(namespaceOf())
|
|
272
|
+
logger.enabled = () => anyEnabled() && isEnabled(namespaceOf())
|
|
263
273
|
Object.defineProperty(logger, 'records', { get: recordsValue })
|
|
264
274
|
return logger
|
|
265
275
|
}
|
|
@@ -107,7 +107,7 @@ type Follower = {
|
|
|
107
107
|
* RECENCY IS THE ORDER OF A MAP. A production moves its key to the end by deleting and re-setting
|
|
108
108
|
* it, so the oldest entry is always the first one and 11.48's "armed at the oldest" is one
|
|
109
109
|
* iterator step rather than a scan — and the sweep stops at the first entry still inside its
|
|
110
|
-
* life
|
|
110
|
+
* life.
|
|
111
111
|
*
|
|
112
112
|
* THE PRODUCTION IS HEARD BY A MIRROR ON EACH ENTRY, which is what the refusal that stood here
|
|
113
113
|
* said could not be had without a hook on every cell in the design. A mirror is a reader, so only
|
|
@@ -117,6 +117,13 @@ type Follower = {
|
|
|
117
117
|
* those apart: every one of them leaves nothing to serve, where a production always leaves
|
|
118
118
|
* something.
|
|
119
119
|
*/
|
|
120
|
+
function end(follower: Follower): void {
|
|
121
|
+
follower.reader.alive = false
|
|
122
|
+
dropReader(follower.cell, follower.reader)
|
|
123
|
+
detachProbes(follower.reader)
|
|
124
|
+
follower.evict()
|
|
125
|
+
}
|
|
126
|
+
|
|
120
127
|
class Lifetime {
|
|
121
128
|
ms: number
|
|
122
129
|
followed: Map<string, Follower>
|
|
@@ -178,13 +185,30 @@ class Lifetime {
|
|
|
178
185
|
// 11.69 — PARKED, NOT EVICTED: a call reaching it joins the load, and the load's end
|
|
179
186
|
// touches it back into the order.
|
|
180
187
|
if (follower.cell.loading) continue
|
|
181
|
-
follower
|
|
182
|
-
dropReader(follower.cell, follower.reader)
|
|
183
|
-
detachProbes(follower.reader)
|
|
184
|
-
follower.evict()
|
|
188
|
+
end(follower)
|
|
185
189
|
}
|
|
186
190
|
this.arm()
|
|
187
191
|
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Evicts `key`'s entry where its life has run out and no load holds it, and says whether it did.
|
|
195
|
+
*
|
|
196
|
+
* THE REACH'S HALF of the sweep, as 6.5 is the read's half of 6.3: a timer that has not fired
|
|
197
|
+
* yet — a `ttl` of 0 is past as the entry produces — leaves an entry a reach would otherwise
|
|
198
|
+
* join. 16.7's mutation called twice in one turn is the case: the second call is a second write.
|
|
199
|
+
*/
|
|
200
|
+
outlived(key: string): boolean {
|
|
201
|
+
const follower = this.followed.get(key)
|
|
202
|
+
if (
|
|
203
|
+
follower === undefined ||
|
|
204
|
+
follower.cell.loading ||
|
|
205
|
+
Date.now() - follower.at < this.ms
|
|
206
|
+
)
|
|
207
|
+
return false
|
|
208
|
+
this.followed.delete(key)
|
|
209
|
+
end(follower)
|
|
210
|
+
return true
|
|
211
|
+
}
|
|
188
212
|
}
|
|
189
213
|
|
|
190
214
|
/**
|
|
@@ -354,7 +378,7 @@ export function createKeyed<
|
|
|
354
378
|
function reach(given: Args): Reactive<Stored, Accepted, Failures> {
|
|
355
379
|
const args = normalized(self, given)
|
|
356
380
|
const key = canonicalKey(args)
|
|
357
|
-
const standing = entries.get(key)
|
|
381
|
+
const standing = lifetime?.outlived(key) ? undefined : entries.get(key)
|
|
358
382
|
// 1.8: reaching an instance that exists joins the standing work — 15.19, unless that work
|
|
359
383
|
// failed transiently, which this reach runs again.
|
|
360
384
|
if (standing !== undefined) {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Which objects an `s.patch` wrote to, and when.
|
|
3
3
|
*
|
|
4
4
|
* 32.33. `epoch` moves once per patch and `written` holds the epoch an object was last written at,
|
|
5
|
-
* so a row
|
|
5
|
+
* so a row that took its item at epoch `e` is stale exactly where its item's entry is past `e`. A leaf, because
|
|
6
6
|
* `reconcile` reads it from the `@abide/abide/runtime` barrel.
|
|
7
7
|
*/
|
|
8
8
|
export const PATCHES: { epoch: number; written: WeakMap<object, number> } = {
|