@abide/abide 0.50.1 → 0.52.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/AGENTS.md +486 -490
- package/CHANGELOG.md +39 -0
- package/README.md +130 -149
- package/package.json +4 -1
- package/src/abideResolverPlugin.ts +12 -0
- package/src/checkAbide.ts +60 -7
- package/src/lib/cli/completeCli.ts +38 -0
- package/src/lib/cli/printTopLevelHelp.ts +1 -0
- package/src/lib/cli/renderCliCompletions.ts +56 -0
- package/src/lib/cli/runCli.ts +27 -0
- package/src/lib/server/render.ts +70 -0
- package/src/lib/server/runtime/cacheStalenessBroadcaster.ts +33 -0
- package/src/lib/server/runtime/createServer.ts +43 -0
- package/src/lib/server/runtime/createUiPageRenderer.ts +37 -0
- package/src/lib/server/runtime/pageRenderSlot.ts +15 -0
- package/src/lib/server/runtime/runWithRequestScope.ts +1 -0
- package/src/lib/server/runtime/types/RequestStore.ts +9 -0
- package/src/lib/server/sockets/createSocketDispatcher.ts +9 -0
- package/src/lib/server/sockets/registerSocket.ts +12 -0
- package/src/lib/shared/CACHE_STALENESS_SOCKET.ts +8 -0
- package/src/lib/shared/RESERVED_SOCKET_PREFIX.ts +8 -0
- package/src/lib/shared/applyCacheStalenessLocally.ts +18 -0
- package/src/lib/shared/attachRpcSelectorMethods.ts +3 -1
- package/src/lib/shared/cache.ts +32 -4
- package/src/lib/shared/cacheStalenessSlot.ts +21 -0
- package/src/lib/shared/createRpcServerProgram.ts +187 -0
- package/src/lib/shared/docSnapshotsSlot.ts +13 -0
- package/src/lib/shared/invalidate.ts +39 -0
- package/src/lib/shared/matcherFromEnvelope.ts +31 -0
- package/src/lib/shared/prepareRpcModule.ts +22 -3
- package/src/lib/shared/refresh.ts +9 -2
- package/src/lib/shared/selectorMatcher.ts +2 -15
- package/src/lib/shared/serializeSelector.ts +69 -0
- package/src/lib/shared/setsIntersect.ts +15 -0
- package/src/lib/shared/types/CacheStalenessApply.ts +13 -0
- package/src/lib/shared/types/CacheStalenessFrame.ts +24 -0
- package/src/lib/shared/types/DocSnapshots.ts +12 -0
- package/src/lib/shared/types/RemoteFunction.ts +11 -8
- package/src/lib/shared/types/RpcBuildStamps.ts +9 -0
- package/src/lib/shared/types/SsrPayload.ts +5 -0
- package/src/lib/test/createTestApp.ts +15 -0
- package/src/lib/ui/compile/COMPOUND_ASSIGNMENT_OPERATORS.ts +19 -0
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
- package/src/lib/ui/compile/compileShadow.ts +141 -25
- package/src/lib/ui/compile/lowerDocAccess.ts +2 -14
- package/src/lib/ui/compile/lowerScript.ts +23 -0
- package/src/lib/ui/compile/renameSignalRefs.ts +65 -0
- package/src/lib/ui/compile/wrapReactionCellSources.ts +117 -0
- package/src/lib/ui/createScope.ts +42 -1
- package/src/lib/ui/dom/assertClaimedText.ts +14 -4
- package/src/lib/ui/dom/attr.ts +25 -2
- package/src/lib/ui/dom/writeCell.ts +22 -0
- package/src/lib/ui/runtime/DOC_SEED.ts +11 -0
- package/src/lib/ui/runtime/claimExpected.ts +6 -1
- package/src/lib/ui/runtime/reportHydrationDivergence.ts +30 -0
- package/src/lib/ui/startClient.ts +22 -1
- package/src/lib/ui/subscribeCacheStaleness.ts +85 -0
- package/src/serverEntry.ts +12 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
# abide
|
|
2
2
|
|
|
3
|
+
## 0.52.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 83f4e5e: warn on the inert watch(cell.member) source ([`554d3ff`](https://github.com/briancray/abide/commit/554d3ffbdef39a40ccb77c58017b759dbdcb17c2))
|
|
8
|
+
- e0c200b: `abide check` now type-checks the project's `.ts` files too, not only `.abide` templates. The `.ts` files were already loaded into the shadow program to resolve component imports, but their own diagnostics were never reported — so a mistyped `navigate('/p/[id]', {})`, `url()`, or `fn.patch()` call (or any type error) in an rpc handler, `app.ts`, or a `$shared` helper passed `abide check` silently. It now surfaces there, making `abide check` a complete type-check (templates + `.ts`) rather than templates only. Scoped to the project's own root files — a dependency or monorepo sibling resolved on demand is not reported. No new public API.
|
|
9
|
+
- 0858cd6: add `/completions <bash|zsh|fish>` to the generated CLI binary — prints a shell completion script that tab-completes command names and their `--flags` (with `--no-<flag>` for booleans). Candidates derive from the baked manifest via a live `/completions --query` callback, so completion always reflects the binary's real surface and can't drift from dispatch. No new public export.
|
|
10
|
+
- f42c33c: Add a path-addressed hydration-divergence signal on the DEBUG-gated `hydrate` channel (`DEBUG=hydrate`, or `localStorage['abide-debug'] = 'hydrate'` in the browser). With the channel off, behavior is unchanged — a claim divergence still throws the hard guard and the router recovers cold. With it on: a **text** divergence warns with the render-path of the enclosing component/branch/row and keeps hydrating, so one reload surfaces every mismatch instead of aborting at the first; a **structural** divergence names its path before throwing; and an **attribute** divergence — previously invisible, since binding always overwrote the server value — now warns too. The render-path is coarse (it locates the component, the detail names the value) and the compiled class/style/prop setters aren't covered yet. No new public API.
|
|
11
|
+
- fca2f95: add `abide/shared/invalidate` and broadcast both staleness verbs across clients (ADR-0041).
|
|
12
|
+
|
|
13
|
+
`invalidate(selector?, args?)` is reintroduced as the distinct DROP verb (next read reloads lazily; a mounted retained reader revalidates stale-in-place), paired with the existing `refresh` REFETCH verb. Instance sugar `fn.invalidate(args?)` sits beside `fn.refresh(args?)`.
|
|
14
|
+
|
|
15
|
+
Both verbs are now isomorphic: applied locally on the client, and on the SERVER they broadcast to every connected client over a reserved, server-publish-only `__abide/cache` socket (live-only, no replay — an offline client falls back to SWR staleness). **`refresh`'s server behaviour changed** from a local throwaway refetch that reached zero browsers to a cross-client broadcast — a semantic change to an existing public verb. Producer/closure and bare match-all selectors are not cross-client serializable and are rejected when broadcast from the server. The `__abide/*` socket namespace is now reserved (a user socket file may not declare it).
|
|
16
|
+
|
|
17
|
+
- 1c1d115: add `abide/server/render` — `render(path, params?, query?)` renders a page route to its HTML string in-process, through the same pipeline (app.html shell, layout chain, params, inline rpc reads) an HTTP GET of that URL runs, so a page stays directly linkable and its emailed form is one call away. Arg shape mirrors `url()`/`navigate`.
|
|
18
|
+
- ee96a2e: Plain `state(initial)` now warm-seeds from the server across hydration, so a nondeterministic initializer carries the server's value through instead of recomputing a divergent one on the client. `state(crypto.randomUUID())`, `state(Date.now())`, `state(Math.random())` — previously the client re-ran the initializer on hydrate and produced a different value than the SSR HTML (a divergence class the `hydrate` channel now reports). Each rendered scope's document snapshot is serialized into `__SSR__.docs` keyed by its render-path id (the same keying the async-cell warm-seed uses), and on hydration the first write to each slot adopts the server value (consume-once) while the throwaway fresh init is discarded. No new public API — same `state()` call, same authoring. Values that don't round-trip the wire codec (functions, class instances) or state read only client-side fall back to a cold init, unchanged.
|
|
19
|
+
|
|
20
|
+
### Patch Changes
|
|
21
|
+
|
|
22
|
+
- 83f4e5e: generate .d.ts augmentations before type-checking .ts files ([`5da85e2`](https://github.com/briancray/abide/commit/5da85e2a2f411c221bb552b60b187ebee836d3ba))
|
|
23
|
+
- 83f4e5e: forward a 0-based completion index from the zsh wrapper ([`6d14ccd`](https://github.com/briancray/abide/commit/6d14ccd3aaa8467948fe78dbc4462b40c6ea6750))
|
|
24
|
+
- 83f4e5e: dedupe mirrored maps, skip the inert reaction pass ([`b938e10`](https://github.com/briancray/abide/commit/b938e1058b10d0fa7881d9cd4e3f774bb1dc35c2))
|
|
25
|
+
- 83f4e5e: reflect .ts checking, the hydrate channel, and state seeding ([`e97f0e0`](https://github.com/briancray/abide/commit/e97f0e0c3b4c5bafde99335663cb88d476ff64cc))
|
|
26
|
+
- 83f4e5e: make the bare seed the idiomatic computed/linked form ([`f889416`](https://github.com/briancray/abide/commit/f889416eedab23346c870d491c75b7e0f361bd8e))
|
|
27
|
+
- 83f4e5e: document invalidate, server-side broadcast, and CLI completions ([`fccc4b4`](https://github.com/briancray/abide/commit/fccc4b4e833ea49871c1a955743ce807d1902a28))
|
|
28
|
+
- 090d28e: `abide check` (and the editor shadow) now type-check a `computed`/`linked` seed written with a top-level `await` — `state.computed(await load())` — as its resolved value, instead of flagging a spurious "top-level await" error and mis-typing the binding (which forced `as unknown as Awaited<…>` casts downstream). The shadow now mirrors the compiler's `wrapSeed` lowering: a non-thunk seed is normalised to a thunk (async when it carries a top-level await) and read through a helper that unwraps a promise/stream to the value a bare cell read peeks. A bare `state(await …)` — not a `computed`/`linked` seed — is still correctly flagged, since it genuinely breaks the synchronous build.
|
|
29
|
+
- 129b404: Writing to a `linked` cell from a component `<script>` now works — `let draft = state.linked(() => source); draft = draft + 1` (and `+=`, `++`, `--`, `??=`). Previously the compiler lowered every `linked` reference through `$$readCell(name)` (a call), so an assignment target became `$$readCell(draft) = …` and the build failed with "Invalid assignment target" — only `bind:value={draft}` could write a linked cell, despite `linked` being documented as writable. Assignments to a `linked` binding now lower through a new `$$writeCell` helper (`abide/ui/dom/writeCell`) that dispatches `.value =` for a sync `State` seed and `.set(...)` for an async/stream `AsyncState` seed.
|
|
30
|
+
- 9154f36: The client build of an `$rpc` module now emits a minimal module — the `remoteProxy` fetch plus only the top-level statements the endpoint `opts` transitively reaches — instead of keeping the whole handler file and trusting tree-shaking to drop the server bits. Previously, server code reachable from module-level handler support (the common `const db = getDb()` shape, where `getDb` imports `bun:sqlite`) survived handler-elision and was _loaded_ on the browser target, failing the build with a raw `Browser build cannot import Bun builtin: "bun:sqlite"` before abide's own reachability guard could give a guided message. Reachability is resolved through the warm rpc `ts.Program` (binder/checker), so an `opts` value built from a module-level const — `schemas: { input: inputSchema }` where `const inputSchema = z.object(...)` — keeps that const and its imports, while anything only the handler touches is never emitted. No public API change; the reachability guard for every other client-reaches-server edge is unchanged, and builds with no warm program fall back to the previous keep-the-file transform.
|
|
31
|
+
|
|
32
|
+
## 0.51.0
|
|
33
|
+
|
|
34
|
+
### Minor Changes
|
|
35
|
+
|
|
36
|
+
- aec503f: make watch(cell, handler) reactive ([`4db4e3d`](https://github.com/briancray/abide/commit/4db4e3d21cb04dcad5463831621140ec00ccbbe2))
|
|
37
|
+
|
|
38
|
+
### Patch Changes
|
|
39
|
+
|
|
40
|
+
- aec503f: correct README/AGENTS and lead the async story with the bare call ([`c96e936`](https://github.com/briancray/abide/commit/c96e93682cc8e05762bcde7bb808850dfdb40b6f))
|
|
41
|
+
|
|
3
42
|
## 0.50.1
|
|
4
43
|
|
|
5
44
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -1,22 +1,19 @@
|
|
|
1
1
|
# abide
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**A type-safe isomorphic framework where one typed declaration is an HTTP endpoint, a CLI subcommand, an MCP tool, and an OpenAPI operation at once.**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
You write a handler once; abide fans it out across every surface, and the bundler swaps the runtime per side — the same call reads in-process during SSR and becomes a typed `fetch` in the browser. Built for humans _and_ machines, on a single runtime.
|
|
6
6
|
|
|
7
7
|
- One direct dependency (TypeScript), one runtime (Bun ≥ 1.3.0).
|
|
8
|
-
- No barrels — every public name is its own module path (`@abide/abide/server/GET`, `@abide/abide/ui/state`, …). The namespace marks the side: `server/*` runs server-side, `ui/*` client-side, `shared/*` isomorphic.
|
|
9
8
|
|
|
10
9
|
## Quick start
|
|
11
10
|
|
|
12
11
|
```sh
|
|
13
|
-
|
|
14
|
-
bunx abide scaffold my-app
|
|
12
|
+
bunx abide scaffold my-app # scaffold, install, and start the dev server
|
|
15
13
|
```
|
|
16
14
|
|
|
17
|
-
Or clone the kitchen-sink example, which exercises the whole surface:
|
|
18
|
-
|
|
19
15
|
```sh
|
|
16
|
+
# or explore the full feature tour
|
|
20
17
|
git clone https://github.com/briancray/abide
|
|
21
18
|
cd abide/examples/kitchen-sink
|
|
22
19
|
bun install
|
|
@@ -25,65 +22,44 @@ bun run dev
|
|
|
25
22
|
|
|
26
23
|
## RPCs
|
|
27
24
|
|
|
28
|
-
An RPC is one export per file under `src/server/rpc/`. The file path is the URL; the
|
|
25
|
+
An RPC is one export per file under `src/server/rpc/`. The file path is the URL; the handler's typed input drives everything downstream. Any [Standard Schema](https://standardschema.dev) (zod, valibot, arktype — unadapted) validates the arguments and projects the MCP tool, CLI flags, and OpenAPI operation.
|
|
29
26
|
|
|
30
27
|
```ts
|
|
31
28
|
// src/server/rpc/getMessages.ts
|
|
32
29
|
import { GET } from '@abide/abide/server/GET'
|
|
33
30
|
import { json } from '@abide/abide/server/json'
|
|
34
31
|
import { z } from 'zod'
|
|
35
|
-
import {
|
|
32
|
+
import { recentMessages } from '../db.ts'
|
|
36
33
|
|
|
34
|
+
// The success body's type flows back to every caller; `cache` retains the read.
|
|
37
35
|
export const getMessages = GET(
|
|
38
|
-
(
|
|
39
|
-
{
|
|
40
|
-
schemas: {
|
|
41
|
-
input: z.object({
|
|
42
|
-
channel: z.string().default('general'),
|
|
43
|
-
limit: z.number().max(100).default(20),
|
|
44
|
-
}),
|
|
45
|
-
},
|
|
46
|
-
},
|
|
36
|
+
async ({ limit }: { limit: number }) => json(await recentMessages(limit)),
|
|
37
|
+
{ schemas: { input: z.object({ limit: z.number().int().max(100) }) }, cache: { ttl: 5_000 } },
|
|
47
38
|
)
|
|
48
39
|
```
|
|
49
40
|
|
|
50
|
-
One declaration
|
|
41
|
+
One declaration branches to every surface:
|
|
51
42
|
|
|
52
43
|
```text
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
SSR call browser
|
|
58
|
-
|
|
59
|
-
|
|
44
|
+
export const getMessages = GET(handler, { schemas })
|
|
45
|
+
│
|
|
46
|
+
┌──────────┬──────────────┼──────────────┬──────────────┐
|
|
47
|
+
▼ ▼ ▼ ▼ ▼
|
|
48
|
+
SSR call browser MCP tool CLI OpenAPI op
|
|
49
|
+
the bare fetch (read-only, subcommand in
|
|
50
|
+
call, typed proxy auto-exposed) abide cli /openapi.json
|
|
51
|
+
in-process fn(args) getMessages
|
|
60
52
|
```
|
|
61
53
|
|
|
62
|
-
A
|
|
63
|
-
|
|
64
|
-
```ts
|
|
65
|
-
// src/server/rpc/sendMessage.ts
|
|
66
|
-
import { POST } from '@abide/abide/server/POST'
|
|
67
|
-
import { json } from '@abide/abide/server/json'
|
|
68
|
-
import { z } from 'zod'
|
|
69
|
-
import { append } from '$server/db.ts'
|
|
70
|
-
|
|
71
|
-
export const sendMessage = POST(
|
|
72
|
-
(args) => json(append(args.channel, args.body)),
|
|
73
|
-
{
|
|
74
|
-
schemas: { input: z.object({ channel: z.string(), body: z.string() }) },
|
|
75
|
-
clients: { mcp: true }, // a mutating RPC must opt into MCP by hand
|
|
76
|
-
},
|
|
77
|
-
)
|
|
78
|
-
```
|
|
54
|
+
A **typed handler input** is what flips those surfaces on: the input type is projected to JSON Schema at build, so a plainly-typed handler auto-exposes to the CLI and — for read-only methods (`GET`/`HEAD`) — MCP with no hand-written `schemas.input` (a declared `schemas.input` adds runtime validation on top). A mutating method (`POST`/`PUT`/`PATCH`/`DELETE`) never auto-exposes to MCP; opt it in with `clients: { mcp: true }`.
|
|
79
55
|
|
|
80
|
-
Consume
|
|
56
|
+
Consume it by importing the export. The **bare call `getMessages(args)` is the smart read** — cached, coalesced, reactive, isomorphic (in-process on the server, `fetch` in the browser). Around it sit `getMessages.raw(args, opts?)` for the raw `Response`, the mutators/probes `.refresh()` / `.invalidate()` / `.patch(...)` / `.peek()` / `.pending()` / `.refreshing()` / `.error()`, and — when the handler streams (`jsonl`/`sse`) — a bare call that returns an `AsyncIterable`.
|
|
81
57
|
|
|
82
|
-
> Query and
|
|
58
|
+
> Query, path, and form args auto-coerce from the endpoint's typed shape at build, so a numeric/boolean/date field arrives already typed — no `z.coerce` (a value that won't parse stays a string, so the schema raises an honest 422). The per-RPC `timeout` (504 on every surface) is distinct from the client transport ceiling `ABIDE_CLIENT_TIMEOUT`.
|
|
83
59
|
|
|
84
60
|
## Sockets
|
|
85
61
|
|
|
86
|
-
A socket is one broadcast topic per file under `src/server/sockets/`. A `Socket<T>` is an isomorphic `AsyncIterable<T>` —
|
|
62
|
+
A socket is one broadcast topic per file under `src/server/sockets/`. A `Socket<T>` is an isomorphic `AsyncIterable<T>` — iterate it for the live stream — and every socket multiplexes onto one WebSocket at `/__abide/sockets`.
|
|
87
63
|
|
|
88
64
|
```ts
|
|
89
65
|
// src/server/sockets/chat.ts
|
|
@@ -91,155 +67,160 @@ import { socket } from '@abide/abide/server/socket'
|
|
|
91
67
|
import { z } from 'zod'
|
|
92
68
|
|
|
93
69
|
export const chat = socket({
|
|
94
|
-
schema: z.object({
|
|
95
|
-
tail:
|
|
96
|
-
ttl: 60_000, // evict retained frames older than 60s
|
|
70
|
+
schema: z.object({ user: z.string(), text: z.string() }),
|
|
71
|
+
tail: 50, // retain the last 50 frames so late joiners / reconnects seed from `.tail()`
|
|
72
|
+
ttl: 60_000, // evict retained frames older than 60s before replay
|
|
97
73
|
})
|
|
98
74
|
```
|
|
99
75
|
|
|
100
|
-
|
|
76
|
+
`chat.publish(frame)` is isomorphic — server code fans out in-process and to remote subscribers; client code sends a `pub` frame (gated by `clientPublish`, off by default). The HTTP face is `/__abide/sockets/<name>`: a `GET` returns the retained tail, a `POST` publishes.
|
|
101
77
|
|
|
102
78
|
## Components
|
|
103
79
|
|
|
104
|
-
A `.abide` component is HTML with a
|
|
80
|
+
A `.abide` component is valid HTML with a `<script>`, reactive primitives imported by their own module paths, and mustache control-flow blocks. The page below imports the RPC and socket above and exercises the whole grammar:
|
|
105
81
|
|
|
106
82
|
```html
|
|
107
83
|
<script>
|
|
108
|
-
import { getMessages } from '$server/rpc/getMessages
|
|
109
|
-
import { sendMessage } from '$server/rpc/sendMessage
|
|
110
|
-
import {
|
|
111
|
-
import
|
|
84
|
+
import { getMessages } from '$server/rpc/getMessages'
|
|
85
|
+
import { sendMessage } from '$server/rpc/sendMessage'
|
|
86
|
+
import { countToday } from '$server/rpc/countToday'
|
|
87
|
+
import { chat } from '$server/sockets/chat'
|
|
88
|
+
import Card from '$ui/Card.abide'
|
|
89
|
+
import Avatar from '$ui/Avatar.abide'
|
|
90
|
+
import Message from '$ui/Message.abide'
|
|
112
91
|
import { state } from '@abide/abide/ui/state'
|
|
113
92
|
import { watch } from '@abide/abide/ui/watch'
|
|
114
93
|
import { html } from '@abide/abide/ui/html'
|
|
94
|
+
import { props } from '@abide/abide/ui/props'
|
|
115
95
|
|
|
116
|
-
const { title = 'Chat'
|
|
96
|
+
const { title = 'Chat' } = props()
|
|
117
97
|
|
|
118
|
-
let
|
|
98
|
+
let limit = state(20) // writable cell, read/reassigned as a plain variable
|
|
119
99
|
let draft = state('')
|
|
120
|
-
let
|
|
121
|
-
let
|
|
100
|
+
let notify = state(true)
|
|
101
|
+
let channel = state('general')
|
|
102
|
+
const trimmed = state.computed(() => draft.trim()) // read-only derived
|
|
103
|
+
const shown = state.linked(() => limit) // writable, reseeded when `limit` changes
|
|
122
104
|
|
|
123
|
-
|
|
124
|
-
|
|
105
|
+
const rootAttrs = { role: 'log' }
|
|
106
|
+
const extra = { compact: true }
|
|
107
|
+
const get = () => draft.toUpperCase()
|
|
108
|
+
const set = (next: string) => { draft = next }
|
|
125
109
|
|
|
126
|
-
|
|
110
|
+
// watch — the single reaction primitive: over a cell, then over a live socket.
|
|
111
|
+
watch(limit, (n) => console.log('limit is now', n))
|
|
112
|
+
watch(chat, (frame) => console.log('new message', frame.text))
|
|
127
113
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
})
|
|
114
|
+
function focus(node: HTMLElement) { node.focus() }
|
|
115
|
+
function connection() { return chat.pending() ? 'connecting' : 'open' }
|
|
116
|
+
function risky() { return draft.at(999)!.length }
|
|
132
117
|
|
|
133
|
-
async function
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
await sendMessage({ channel: channel.value, body: trimmed.value })
|
|
137
|
-
draft.value = ''
|
|
118
|
+
async function send() {
|
|
119
|
+
await sendMessage({ text: trimmed }) // a mutating RPC from an event handler
|
|
120
|
+
draft = ''
|
|
138
121
|
}
|
|
139
|
-
|
|
140
|
-
function autofocus(node) {
|
|
141
|
-
node.focus()
|
|
142
|
-
return () => {}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
const rowProps = { class: 'flex gap-2' }
|
|
146
|
-
|
|
147
|
-
// Derived two-way binding: read a string, coerce writes back into the numeric cell.
|
|
148
|
-
const get = () => String(limit.value)
|
|
149
|
-
const set = (next) => (limit.value = Number(next))
|
|
150
122
|
</script>
|
|
151
123
|
|
|
152
|
-
<section
|
|
153
|
-
<h1>{title}
|
|
124
|
+
<section class="chat" class:empty={limit === 0} style:--rows={shown} {...rootAttrs}>
|
|
125
|
+
<h1>{title}</h1>
|
|
154
126
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
<button type="submit">Send</button>
|
|
162
|
-
</form>
|
|
127
|
+
<!-- Async reads have no ceremony: the bare call is the way. It peeks
|
|
128
|
+
`undefined` while pending, so it composes with `??` and the probes. -->
|
|
129
|
+
<p class:loading={getMessages.pending({ limit })}>
|
|
130
|
+
{getMessages({ limit })?.length ?? 0} messages
|
|
131
|
+
{#if getMessages.error({ limit })}<span>failed to load</span>{/if}
|
|
132
|
+
</p>
|
|
163
133
|
|
|
164
|
-
{
|
|
165
|
-
|
|
166
|
-
<p>{message.body}</p>
|
|
167
|
-
</MessageCard>
|
|
168
|
-
{/snippet}
|
|
134
|
+
<!-- `{await}` is an inline read: it blocks SSR until the value is in the HTML. -->
|
|
135
|
+
<small>today: {await countToday()}</small>
|
|
169
136
|
|
|
170
|
-
{#
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
<p>{live.value} updates</p>
|
|
174
|
-
{:else}
|
|
175
|
-
<script>
|
|
176
|
-
let seenAt = state(Date.now())
|
|
177
|
-
let ageLabel = state.computed(() => `waiting since ${seenAt.value}`)
|
|
178
|
-
</script>
|
|
179
|
-
<style>
|
|
180
|
-
p { color: gray; }
|
|
181
|
-
</style>
|
|
182
|
-
<p>{ageLabel.value}</p>
|
|
183
|
-
{/if}
|
|
184
|
-
|
|
185
|
-
{#await getMessages({ limit: limit.value })}
|
|
186
|
-
<p>Loading…</p>
|
|
137
|
+
<!-- `{#await}` is the explicit opt-in — a distinct pending branch and `{:then}` narrowing. -->
|
|
138
|
+
{#await getMessages({ limit })}
|
|
139
|
+
<p>loading…</p>
|
|
187
140
|
{:then messages}
|
|
188
|
-
{#
|
|
189
|
-
{
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
141
|
+
{#if messages.length}
|
|
142
|
+
{#for message, i of messages by message.id}
|
|
143
|
+
<Message message={message} ondelete={() => sendMessage({ text: '' })} {...extra}>
|
|
144
|
+
<Avatar alt={message.user} />
|
|
145
|
+
</Message>
|
|
146
|
+
{/for}
|
|
147
|
+
{:else if limit > 0}
|
|
148
|
+
<p>no messages yet</p>
|
|
149
|
+
{:else}
|
|
150
|
+
<p>raise the limit</p>
|
|
151
|
+
{/if}
|
|
152
|
+
{:catch err}
|
|
153
|
+
<p>{err.message}</p>
|
|
193
154
|
{:finally}
|
|
194
155
|
<hr />
|
|
195
156
|
{/await}
|
|
196
157
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
{:
|
|
207
|
-
|
|
158
|
+
<!-- live socket frames over an AsyncIterable -->
|
|
159
|
+
<ul>
|
|
160
|
+
{#for await frame of chat}
|
|
161
|
+
<li>{frame.user}: {frame.text}</li>
|
|
162
|
+
{/for}
|
|
163
|
+
</ul>
|
|
164
|
+
|
|
165
|
+
{#switch connection()}
|
|
166
|
+
{:case 'open'}<span>live</span>
|
|
167
|
+
{:case 'connecting'}<span>connecting…</span>
|
|
168
|
+
{:default}<span>offline</span>
|
|
208
169
|
{/switch}
|
|
209
170
|
|
|
210
171
|
{#try}
|
|
211
|
-
<
|
|
212
|
-
|
|
213
|
-
</
|
|
214
|
-
{:catch}
|
|
215
|
-
<p>Card crashed</p>
|
|
172
|
+
<p>{risky()}</p>
|
|
173
|
+
{:catch e}
|
|
174
|
+
<p>widget crashed: {e.message}</p>
|
|
216
175
|
{:finally}
|
|
217
|
-
<span
|
|
176
|
+
<span>rendered</span>
|
|
218
177
|
{/try}
|
|
178
|
+
|
|
179
|
+
<form onsubmit={send}>
|
|
180
|
+
<input name="text" bind:value={draft} />
|
|
181
|
+
<label><input type="checkbox" bind:checked={notify} /> notify</label>
|
|
182
|
+
<input type="radio" bind:group={channel} value="general" />
|
|
183
|
+
<input aria-label="shout" bind:value={{ get, set }} />
|
|
184
|
+
<button attach={focus}>send</button>
|
|
185
|
+
</form>
|
|
186
|
+
|
|
187
|
+
{#snippet stat(label: string, value: number)}
|
|
188
|
+
<dd>{label}: {value}</dd>
|
|
189
|
+
{/snippet}
|
|
190
|
+
<dl>{stat('shown', limit)}</dl>
|
|
191
|
+
|
|
192
|
+
<Card>
|
|
193
|
+
<p>{html`<em>${trimmed}</em>`}</p>
|
|
194
|
+
</Card>
|
|
195
|
+
|
|
196
|
+
{#if limit > 10}
|
|
197
|
+
<script>
|
|
198
|
+
// a nested branch script: branch-local state, re-seeded per mount, no imports
|
|
199
|
+
let expanded = state(false)
|
|
200
|
+
const label = state.computed(() => (expanded ? 'less' : 'more'))
|
|
201
|
+
</script>
|
|
202
|
+
<button onclick={() => (expanded = !expanded)}>{label}</button>
|
|
203
|
+
<style>
|
|
204
|
+
button { font-weight: 600; }
|
|
205
|
+
</style>
|
|
206
|
+
{/if}
|
|
219
207
|
</section>
|
|
220
208
|
|
|
221
209
|
<style>
|
|
222
|
-
|
|
223
|
-
display: grid;
|
|
224
|
-
gap: 0.5rem;
|
|
225
|
-
}
|
|
210
|
+
.chat { display: grid; gap: 0.5rem; }
|
|
226
211
|
</style>
|
|
227
212
|
```
|
|
228
213
|
|
|
229
|
-
The
|
|
214
|
+
The child component fills the passed content at `{children()}`; `{#if children}…{:else}…{/if}` is the fallback (there is no `<slot>` element, no named slots):
|
|
230
215
|
|
|
231
216
|
```html
|
|
232
217
|
<script>
|
|
233
|
-
|
|
218
|
+
import { props } from '@abide/abide/ui/props'
|
|
219
|
+
const { children } = props()
|
|
234
220
|
</script>
|
|
235
221
|
|
|
236
|
-
<article
|
|
237
|
-
<
|
|
238
|
-
{#if children}
|
|
239
|
-
{children()}
|
|
240
|
-
{:else}
|
|
241
|
-
<em>No content</em>
|
|
242
|
-
{/if}
|
|
222
|
+
<article class="card">
|
|
223
|
+
{#if children}{children()}{:else}<p>empty</p>{/if}
|
|
243
224
|
</article>
|
|
244
225
|
```
|
|
245
226
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abide/abide",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.52.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"description": "Isomorphic multimodal HTTP framework built for humans and machines in a single Bun runtime",
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"./server/sse": "./src/lib/server/sse.ts",
|
|
46
46
|
"./server/error": "./src/lib/server/error.ts",
|
|
47
47
|
"./server/redirect": "./src/lib/server/redirect.ts",
|
|
48
|
+
"./server/render": "./src/lib/server/render.ts",
|
|
48
49
|
"./server/request": "./src/lib/server/request.ts",
|
|
49
50
|
"./server/cookies": "./src/lib/server/cookies.ts",
|
|
50
51
|
"./server/env": "./src/lib/server/env.ts",
|
|
@@ -58,6 +59,7 @@
|
|
|
58
59
|
"./server/prompts/definePrompt": "./src/lib/server/prompts/definePrompt.ts",
|
|
59
60
|
"./ui/watch": "./src/lib/ui/watch.ts",
|
|
60
61
|
"./shared/pending": "./src/lib/shared/pending.ts",
|
|
62
|
+
"./shared/invalidate": "./src/lib/shared/invalidate.ts",
|
|
61
63
|
"./shared/patch": "./src/lib/shared/patch.ts",
|
|
62
64
|
"./shared/peek": "./src/lib/shared/peek.ts",
|
|
63
65
|
"./shared/refresh": "./src/lib/shared/refresh.ts",
|
|
@@ -93,6 +95,7 @@
|
|
|
93
95
|
"./ui/dom/spreadAttrs": "./src/lib/ui/dom/spreadAttrs.ts",
|
|
94
96
|
"./ui/dom/readCall": "./src/lib/ui/dom/readCall.ts",
|
|
95
97
|
"./ui/dom/readCell": "./src/lib/ui/dom/readCell.ts",
|
|
98
|
+
"./ui/dom/writeCell": "./src/lib/ui/dom/writeCell.ts",
|
|
96
99
|
"./ui/dom/cellPending": "./src/lib/ui/dom/cellPending.ts",
|
|
97
100
|
"./ui/runtime/withPath": "./src/lib/ui/runtime/withPath.ts",
|
|
98
101
|
"./ui/settleAsyncCells": "./src/lib/ui/settleAsyncCells.ts",
|
|
@@ -396,6 +396,17 @@ export function abideResolverPlugin({
|
|
|
396
396
|
program / unresolvable body / no structured field) bakes nothing — a response array
|
|
397
397
|
stays an array. */
|
|
398
398
|
const outputWirePlan = rpcServerProgram?.outputWirePlanForModule(args.path)
|
|
399
|
+
/* The client-only reachability plan: the top-level statements the emitted client
|
|
400
|
+
module retains, rooted at the endpoint `opts` (imports + declarations opts
|
|
401
|
+
transitively needs). Present → rewriteForClient emits a MINIMAL module so the
|
|
402
|
+
handler and its server-only imports are never emitted — nothing server-side loads
|
|
403
|
+
or trips the reachability guard (ADR-0022 addendum). Only the client target needs
|
|
404
|
+
it; the server rewrite keeps the whole handler. undefined (no warm program /
|
|
405
|
+
unresolvable call) falls open to the keep-the-file rewrite. */
|
|
406
|
+
const clientKeep =
|
|
407
|
+
target === 'client'
|
|
408
|
+
? rpcServerProgram?.clientKeepForModule(args.path)
|
|
409
|
+
: undefined
|
|
399
410
|
/* The handler's input args projected to JSON Schema, stamped as `inputJsonSchema` so the
|
|
400
411
|
runtime OpenAPI parameters/body / MCP inputSchema / inspector input surface renders the
|
|
401
412
|
real input shape (and the endpoint becomes advertisable) without an author-declared
|
|
@@ -410,6 +421,7 @@ export function abideResolverPlugin({
|
|
|
410
421
|
outputSchema: returnBodySchema,
|
|
411
422
|
errorSchemas,
|
|
412
423
|
outputWirePlan,
|
|
424
|
+
clientKeep,
|
|
413
425
|
})
|
|
414
426
|
if (!prepared) {
|
|
415
427
|
throw new Error(
|
package/src/checkAbide.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { resolve } from 'node:path'
|
|
2
2
|
import ts from 'typescript'
|
|
3
|
+
import { generateDeclarations } from './lib/shared/generateDeclarations.ts'
|
|
3
4
|
import { collectAbideDiagnostics } from './lib/ui/compile/collectAbideDiagnostics.ts'
|
|
4
5
|
import { createShadowProgram, type ShadowProgram } from './lib/ui/compile/createShadowProgram.ts'
|
|
5
6
|
import { interpolationClassifierForRoot } from './lib/ui/compile/interpolationClassifierForRoot.ts'
|
|
@@ -15,6 +16,13 @@ the same as each package checked on its own — and the same as the LSP. Returns
|
|
|
15
16
|
the error count so the CLI can set its exit code.
|
|
16
17
|
*/
|
|
17
18
|
export async function checkAbide({ cwd }: { cwd: string }): Promise<number> {
|
|
19
|
+
/* Materialize the project's generated augmentations (src/.abide/*.d.ts) first: since the
|
|
20
|
+
check pass now type-checks the project's `.ts` files too (rpc handlers, app.ts, tests),
|
|
21
|
+
those resolve `app.rpc.<name>` / typed `url('/rpc/…')` off the build-written RpcClient /
|
|
22
|
+
RpcRoutes augmentations. Without this, a cold `abide check` (CI, fresh clone — `.abide` is
|
|
23
|
+
gitignored, generated only by dev/build) reports every augmented member as missing. Fails
|
|
24
|
+
open, so a codegen hiccup degrades to the pre-`.ts`-check behaviour rather than aborting. */
|
|
25
|
+
await generateDeclarations({ cwd })
|
|
18
26
|
const { diagnostics, checked } = collectByProject(cwd)
|
|
19
27
|
const byFile = new Map<string, AbideDiagnostic[]>()
|
|
20
28
|
for (const diagnostic of diagnostics) {
|
|
@@ -64,17 +72,62 @@ function collectByProject(cwd: string): { diagnostics: AbideDiagnostic[]; checke
|
|
|
64
72
|
WHICH sub-expressions are async must read un-wrapped shadows — so it rides a separate verbatim
|
|
65
73
|
program, built (and reused) here per root. */
|
|
66
74
|
const classifierCache = new Map<string, ShadowProgram | undefined>()
|
|
67
|
-
const diagnostics = [...byProject].flatMap(([root, paths]) =>
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
75
|
+
const diagnostics = [...byProject].flatMap(([root, paths]) => {
|
|
76
|
+
const shadow = createShadowProgram(root, paths, (abidePath) =>
|
|
77
|
+
interpolationClassifierForRoot(classifierCache, root, abidePath),
|
|
78
|
+
)
|
|
79
|
+
/* The shadow program already holds the project's real `.ts` files (loaded so the
|
|
80
|
+
components' imports/types resolve), but `collectAbideDiagnostics` only reports the
|
|
81
|
+
`.abide` shadows. Report the `.ts` files too, so a mistyped `navigate`/`url`/`patch`
|
|
82
|
+
call — or any type error — in an rpc handler, `app.ts`, or a `$shared` helper fails
|
|
83
|
+
`abide check` instead of only surfacing under a separately-run `tsc`. */
|
|
84
|
+
return [...collectAbideDiagnostics(shadow), ...collectTsDiagnostics(shadow.program)]
|
|
85
|
+
})
|
|
74
86
|
const checked = [...byProject.values()].reduce((total, paths) => total + paths.length, 0)
|
|
75
87
|
return { diagnostics, checked }
|
|
76
88
|
}
|
|
77
89
|
|
|
90
|
+
/* Syntactic + semantic diagnostics for the project's own `.ts` files already in the shadow
|
|
91
|
+
program. Iterating the ROOT file names (the project's tsconfig inputs) — not every loaded
|
|
92
|
+
source — scopes this to the project itself: an on-demand-resolved import from node_modules or a
|
|
93
|
+
monorepo sibling is in the program but never a root, so its errors aren't attributed here.
|
|
94
|
+
`fileExists` drops the virtual `.abide.ts` shadows and the synthetic asset-modules file (neither
|
|
95
|
+
is on disk), `isDeclarationFile` drops the default libs and the generated `src/.abide/*.d.ts`,
|
|
96
|
+
and the `.ts`/`.tsx` guard drops raw `.abide` root entries. Real source coordinates — no remap. */
|
|
97
|
+
function collectTsDiagnostics(program: ts.Program): AbideDiagnostic[] {
|
|
98
|
+
const diagnostics: AbideDiagnostic[] = []
|
|
99
|
+
for (const rootName of program.getRootFileNames()) {
|
|
100
|
+
if (
|
|
101
|
+
(!rootName.endsWith('.ts') && !rootName.endsWith('.tsx')) ||
|
|
102
|
+
rootName.includes('/node_modules/') ||
|
|
103
|
+
!ts.sys.fileExists(rootName)
|
|
104
|
+
) {
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
const sourceFile = program.getSourceFile(rootName)
|
|
108
|
+
if (sourceFile === undefined || sourceFile.isDeclarationFile) {
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
const raw = [
|
|
112
|
+
...program.getSyntacticDiagnostics(sourceFile),
|
|
113
|
+
...program.getSemanticDiagnostics(sourceFile),
|
|
114
|
+
]
|
|
115
|
+
for (const diagnostic of raw) {
|
|
116
|
+
if (diagnostic.start === undefined) {
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
diagnostics.push({
|
|
120
|
+
file: sourceFile.fileName,
|
|
121
|
+
start: diagnostic.start,
|
|
122
|
+
length: diagnostic.length ?? 0,
|
|
123
|
+
message: ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'),
|
|
124
|
+
category: diagnostic.category,
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return diagnostics
|
|
129
|
+
}
|
|
130
|
+
|
|
78
131
|
/* Renders one diagnostic as `path:line:col severity message` plus the offending
|
|
79
132
|
line and a caret underline spanning the mapped range. */
|
|
80
133
|
function printDiagnostic(file: string, text: string, diagnostic: AbideDiagnostic): void {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { CliManifest } from './types/CliManifest.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Shell-completion candidates for a partially-typed command line, derived
|
|
5
|
+
entirely from the baked manifest so completion can't drift from dispatch.
|
|
6
|
+
`cword` is the token index being completed (the shell's COMP_CWORD /
|
|
7
|
+
CURRENT), `command` is the first positional (`words[1]`). Completing the
|
|
8
|
+
first positional lists every command plus the `/`-prefixed connection
|
|
9
|
+
verbs; past it, the chosen command's flags (mirroring parseArgvForRpc's
|
|
10
|
+
grammar — `--name`, plus `--no-name` for a boolean, and the always-present
|
|
11
|
+
`--json`). The shell filters these by the current prefix.
|
|
12
|
+
*/
|
|
13
|
+
export function completeCli(
|
|
14
|
+
manifest: CliManifest,
|
|
15
|
+
cword: number,
|
|
16
|
+
command: string | undefined,
|
|
17
|
+
): string[] {
|
|
18
|
+
// Completing the command itself: every rpc/socket command + the connection verbs.
|
|
19
|
+
if (cword <= 1) {
|
|
20
|
+
const connection = ['/connect', '/start', '/disconnect', '/help', '/completions']
|
|
21
|
+
return [...Object.keys(manifest).toSorted(), ...connection]
|
|
22
|
+
}
|
|
23
|
+
const entry = command === undefined ? undefined : manifest[command]
|
|
24
|
+
if (!entry) {
|
|
25
|
+
return []
|
|
26
|
+
}
|
|
27
|
+
const properties =
|
|
28
|
+
(entry.jsonSchema?.properties as Record<string, { type?: string }> | undefined) ?? {}
|
|
29
|
+
const flags = ['--json']
|
|
30
|
+
for (const [key, value] of Object.entries(properties)) {
|
|
31
|
+
flags.push(`--${key}`)
|
|
32
|
+
// A boolean flag also accepts its negation, matching printCommandHelp's `--no-<key>`.
|
|
33
|
+
if (value.type === 'boolean') {
|
|
34
|
+
flags.push(`--no-${key}`)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return flags
|
|
38
|
+
}
|
|
@@ -70,6 +70,7 @@ export function printTopLevelHelp(
|
|
|
70
70
|
console.log(` ${programName} /start start a local instance`)
|
|
71
71
|
console.log(` ${programName} /disconnect forget the saved connection`)
|
|
72
72
|
console.log(` ${programName} resume the saved connection (session)`)
|
|
73
|
+
console.log(` ${programName} /completions <bash|zsh|fish> print a shell completion script`)
|
|
73
74
|
console.log(`\n --help, -h show this help`)
|
|
74
75
|
console.log(` <command> --help show help for a specific command`)
|
|
75
76
|
console.log(`\nenv:`)
|