@astrale-os/adapter-cloudflare 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/package.json +53 -0
  2. package/src/client.ts +54 -0
  3. package/src/cloudflare.ts +228 -0
  4. package/src/codegen/identity.ts +40 -0
  5. package/src/codegen/merge.ts +92 -0
  6. package/src/codegen/worker.ts +105 -0
  7. package/src/codegen/wrangler.ts +95 -0
  8. package/src/index.ts +17 -0
  9. package/src/params.ts +49 -0
  10. package/src/parse-output.ts +54 -0
  11. package/src/wrangler-cli.ts +240 -0
  12. package/template/.env.example +6 -0
  13. package/template/README.md +77 -0
  14. package/template/astrale.config.ts +35 -0
  15. package/template/client/README.md +85 -0
  16. package/template/client/__tests__/app.test.tsx +191 -0
  17. package/template/client/__tests__/harness.ts +221 -0
  18. package/template/client/__tests__/kernel.test.ts +68 -0
  19. package/template/client/index.html +12 -0
  20. package/template/client/package.json +26 -0
  21. package/template/client/src/app.tsx +94 -0
  22. package/template/client/src/lib/kernel.ts +135 -0
  23. package/template/client/src/lib/shell.ts +197 -0
  24. package/template/client/src/lib/use-node.ts +66 -0
  25. package/template/client/src/lib/use-shell.ts +85 -0
  26. package/template/client/src/main.tsx +9 -0
  27. package/template/client/src/styles.css +107 -0
  28. package/template/client/tsconfig.json +25 -0
  29. package/template/client/vite.config.ts +40 -0
  30. package/template/client/vitest.config.ts +18 -0
  31. package/template/env.ts +18 -0
  32. package/template/functions/index.ts +9 -0
  33. package/template/methods/index.ts +66 -0
  34. package/template/methods/note.ts +131 -0
  35. package/template/package.json +30 -0
  36. package/template/pnpm-workspace.yaml +17 -0
  37. package/template/schema/compiled.ts +14 -0
  38. package/template/schema/index.ts +21 -0
  39. package/template/schema/note.ts +64 -0
  40. package/template/tsconfig.json +17 -0
  41. package/template/views/index.ts +10 -0
  42. package/template/views/note.ts +21 -0
  43. package/template/views/welcome.ts +35 -0
@@ -0,0 +1,191 @@
1
+ import { act, cleanup, render, screen } from '@testing-library/react'
2
+ import { afterEach, describe, expect, it } from 'vitest'
3
+
4
+ import { App } from '../src/app'
5
+ import { type CapturedRequest, installFakeKernel, installFakeParent, noteNode, ok } from './harness'
6
+
7
+ const KERNEL_URL = 'https://k.example.test/api'
8
+ const KERNEL_TYPE = 'application/vnd.astrale.kernel+json'
9
+
10
+ afterEach(() => {
11
+ cleanup()
12
+ })
13
+
14
+ /**
15
+ * Let the handshake + any kernel fetch settle, flushing the resulting React
16
+ * state updates inside `act`. The handshake completes over a real MessagePort
17
+ * (async hops), so a microtask isn't enough — a short timer pump is. Wrapping
18
+ * it in `act` keeps the updates warning-free and committed before we assert.
19
+ */
20
+ async function flush(ms = 20) {
21
+ await act(async () => {
22
+ await new Promise((r) => setTimeout(r, ms))
23
+ })
24
+ }
25
+
26
+ /** Common assertions on a captured kernel request for `@<id>::get`. */
27
+ function expectGetCall(req: CapturedRequest, nodeId: string, token: string) {
28
+ // POST to the kernel URL with a trailing slash added.
29
+ expect(req.method).toBe('POST')
30
+ expect(req.url).toBe(`${KERNEL_URL}/`)
31
+ // Bare delegation token (no "Bearer ").
32
+ expect(req.headers['authorization']).toBe(token)
33
+ // Kernel JSON envelope content type on both content-type and accept.
34
+ expect(req.headers['content-type']).toBe(KERNEL_TYPE)
35
+ expect(req.headers['accept']).toBe(KERNEL_TYPE)
36
+ // Envelope body shape.
37
+ expect(req.body.method).toBe(`@${nodeId}::get`)
38
+ expect(req.body.params).toEqual({})
39
+ expect(req.body.id).toBeTruthy()
40
+ }
41
+
42
+ describe('ui-note view — handshake + real node render', () => {
43
+ it('completes the handshake, calls @<id>::get, and renders the Note', async () => {
44
+ const parent = installFakeParent({
45
+ windowId: 'win-1',
46
+ kernelUrl: KERNEL_URL,
47
+ functionId: 'ui-note',
48
+ delegationToken: 'tok-A',
49
+ tokenExpiresAt: Date.now() + 3_600_000,
50
+ targetNodeId: 'note-1',
51
+ })
52
+ const kernel = installFakeKernel(() =>
53
+ ok(noteNode({ id: 'note-1', name: 'My first note', body: 'Hello from the kernel.' })),
54
+ )
55
+
56
+ try {
57
+ render(<App />)
58
+ await flush()
59
+
60
+ // Parent observed the child's handshakeAck.
61
+ await expect(parent.ack).resolves.toBeUndefined()
62
+
63
+ // The Note renders its title (Named.name) and body.
64
+ expect(screen.getByText('My first note')).toBeTruthy()
65
+ expect(screen.getByText('Hello from the kernel.')).toBeTruthy()
66
+
67
+ // Exactly one kernel call, with the verified wire shape.
68
+ expect(kernel.calls).toHaveLength(1)
69
+ expectGetCall(kernel.calls[0]!, 'note-1', 'tok-A')
70
+ } finally {
71
+ kernel.restore()
72
+ parent.restore()
73
+ }
74
+ })
75
+
76
+ it('renders the kernel error envelope on the error path', async () => {
77
+ const parent = installFakeParent({
78
+ windowId: 'win-2',
79
+ kernelUrl: KERNEL_URL,
80
+ functionId: 'ui-note',
81
+ delegationToken: 'tok-A',
82
+ tokenExpiresAt: Date.now() + 3_600_000,
83
+ targetNodeId: 'missing-1',
84
+ })
85
+ const kernel = installFakeKernel(() => ({
86
+ error: { code: 3002, message: 'Path not found' },
87
+ }))
88
+
89
+ try {
90
+ render(<App />)
91
+ await flush()
92
+
93
+ expect(screen.getByText(/Failed to load the Note/)).toBeTruthy()
94
+ // The "<code>: <message>" surfaces from kernelCall.
95
+ expect(screen.getByText(/3002: Path not found/)).toBeTruthy()
96
+ expect(kernel.calls).toHaveLength(1)
97
+ } finally {
98
+ kernel.restore()
99
+ parent.restore()
100
+ }
101
+ })
102
+
103
+ it('re-fetches on a setTarget hot-swap', async () => {
104
+ const parent = installFakeParent({
105
+ windowId: 'win-3',
106
+ kernelUrl: KERNEL_URL,
107
+ functionId: 'ui-note',
108
+ delegationToken: 'tok-A',
109
+ tokenExpiresAt: Date.now() + 3_600_000,
110
+ targetNodeId: 'note-1',
111
+ })
112
+ const kernel = installFakeKernel((body) =>
113
+ // Respond per-target so the swap is observable.
114
+ body.method === '@note-2::get'
115
+ ? ok(noteNode({ id: 'note-2', name: 'Second note', body: 'Swapped in.' }))
116
+ : ok(noteNode({ id: 'note-1', name: 'First note', body: 'Original.' })),
117
+ )
118
+
119
+ try {
120
+ render(<App />)
121
+ await flush()
122
+ expect(screen.getByText('First note')).toBeTruthy()
123
+ expect(kernel.calls).toHaveLength(1)
124
+
125
+ // Parent pushes a new target node.
126
+ parent.setTarget('note-2')
127
+ await flush()
128
+
129
+ expect(screen.getByText('Second note')).toBeTruthy()
130
+ expect(screen.getByText('Swapped in.')).toBeTruthy()
131
+
132
+ // A second kernel call for the new node.
133
+ expect(kernel.calls).toHaveLength(2)
134
+ expectGetCall(kernel.calls[1]!, 'note-2', 'tok-A')
135
+ } finally {
136
+ kernel.restore()
137
+ parent.restore()
138
+ }
139
+ })
140
+
141
+ it('uses the refreshed token for calls after ctrl:tokenRefresh', async () => {
142
+ const parent = installFakeParent({
143
+ windowId: 'win-4',
144
+ kernelUrl: KERNEL_URL,
145
+ functionId: 'ui-note',
146
+ delegationToken: 'tok-A',
147
+ tokenExpiresAt: Date.now() + 1_000,
148
+ targetNodeId: 'note-1',
149
+ })
150
+ const kernel = installFakeKernel((body) =>
151
+ ok(noteNode({ id: body.method.slice(1, -5), name: 'Note', body: 'b' })),
152
+ )
153
+
154
+ try {
155
+ render(<App />)
156
+ await flush()
157
+ expect(kernel.calls).toHaveLength(1)
158
+ // First call used the handshake token.
159
+ expect(kernel.calls[0]!.headers['authorization']).toBe('tok-A')
160
+
161
+ // Parent pushes a fresh token, then triggers a new fetch via setTarget.
162
+ parent.tokenRefresh('tok-B', Date.now() + 3_600_000)
163
+ parent.setTarget('note-9')
164
+ await flush()
165
+
166
+ // The post-refresh call carries the NEW token.
167
+ expect(kernel.calls).toHaveLength(2)
168
+ expect(kernel.calls[1]!.body.method).toBe('@note-9::get')
169
+ expect(kernel.calls[1]!.headers['authorization']).toBe('tok-B')
170
+ } finally {
171
+ kernel.restore()
172
+ parent.restore()
173
+ }
174
+ })
175
+
176
+ it('falls back to a standalone preview with no parent', async () => {
177
+ // No fake parent installed → connectShell rejects fast because
178
+ // window.parent === window in this realm.
179
+ const kernel = installFakeKernel(() => ok(null))
180
+ try {
181
+ render(<App />)
182
+ await flush()
183
+
184
+ expect(screen.getByText(/No parent shell/)).toBeTruthy()
185
+ // Never hit the kernel in standalone mode.
186
+ expect(kernel.calls).toHaveLength(0)
187
+ } finally {
188
+ kernel.restore()
189
+ }
190
+ })
191
+ })
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Test harness — a FAKE shell parent + a FAKE kernel, so the client's inline
3
+ * handshake (`src/lib/shell.ts`) and JSON kernel client (`src/lib/kernel.ts`)
4
+ * can be exercised with no real GUI/kernel.
5
+ *
6
+ * The fake parent mirrors `runParentHandshake`
7
+ * (`shell/packages/shell/src/application/windowing/handshake.ts`):
8
+ * - replies to `astrale-shell/init-request` with `init-response` + a
9
+ * transferred `MessagePort` (`MessageChannel.port2`),
10
+ * - posts `ctrl:handshake` over the port with the session data,
11
+ * - resolves once it receives the child's `ctrl:handshakeAck`,
12
+ * - then lets a test push `setTarget` intents and `ctrl:tokenRefresh`.
13
+ *
14
+ * The happy-dom env exposes Node's REAL `MessageChannel` (its own `MessagePort`
15
+ * is a no-op stub), so port delivery actually works. `MessageEvent` carries
16
+ * `source`/`ports` via its constructor, and `window.parent` is overridden so
17
+ * the child believes it is framed.
18
+ */
19
+
20
+ const INIT_REQUEST_TYPE = 'astrale-shell/init-request'
21
+ const INIT_RESPONSE_TYPE = 'astrale-shell/init-response'
22
+
23
+ export type HandshakePayload = {
24
+ windowId: string
25
+ kernelUrl: string
26
+ functionId: string
27
+ delegationToken: string
28
+ tokenExpiresAt: number
29
+ targetNodeId?: string
30
+ mintIdentity?: string
31
+ }
32
+
33
+ export type FakeParent = {
34
+ /** Resolves once the child acks the handshake (mirrors runParentHandshake). */
35
+ readonly ack: Promise<void>
36
+ /** Hot-swap the target node via a `setTarget` intent. */
37
+ setTarget(nodeId: string): void
38
+ /** Push a fresh delegation token via `ctrl:tokenRefresh`. */
39
+ tokenRefresh(delegationToken: string, tokenExpiresAt: number): void
40
+ /** Restore `window.parent` and detach listeners. */
41
+ restore(): void
42
+ }
43
+
44
+ /**
45
+ * Install a fake shell parent. MUST be called BEFORE the child mounts (the
46
+ * child posts `init-request` to `window.parent` in a mount effect).
47
+ */
48
+ export function installFakeParent(payload: HandshakePayload): FakeParent {
49
+ const originalParentDesc = Object.getOwnPropertyDescriptor(window, 'parent')
50
+
51
+ let port: MessagePort | null = null
52
+ let ackResolve!: () => void
53
+ const ack = new Promise<void>((r) => {
54
+ ackResolve = r
55
+ })
56
+
57
+ // The fake parent object — its `postMessage` receives the child's init-request.
58
+ const fakeParent = {
59
+ postMessage(message: unknown, _targetOrigin?: string) {
60
+ if (
61
+ message !== null &&
62
+ typeof message === 'object' &&
63
+ (message as { type?: unknown }).type === INIT_REQUEST_TYPE
64
+ ) {
65
+ onInitRequest()
66
+ }
67
+ },
68
+ }
69
+
70
+ function onInitRequest() {
71
+ const channel = new MessageChannel()
72
+ port = channel.port1
73
+
74
+ port.onmessage = (ev: MessageEvent) => {
75
+ const msg = ev.data as { type?: string; action?: string } | null
76
+ if (msg && msg.type === 'ctrl' && msg.action === 'handshakeAck') {
77
+ ackResolve()
78
+ }
79
+ }
80
+ port.start()
81
+
82
+ // Transfer port2 to the child via an init-response on `window`.
83
+ window.dispatchEvent(
84
+ new MessageEvent('message', {
85
+ data: { type: INIT_RESPONSE_TYPE, version: 1, windowId: payload.windowId },
86
+ source: fakeParent as unknown as Window,
87
+ ports: [channel.port2],
88
+ }),
89
+ )
90
+
91
+ // Then the handshake control message carrying the session data.
92
+ port.postMessage({
93
+ type: 'ctrl',
94
+ version: 1,
95
+ action: 'handshake',
96
+ data: {
97
+ delegationToken: payload.delegationToken,
98
+ tokenExpiresAt: payload.tokenExpiresAt,
99
+ windowId: payload.windowId,
100
+ kernelUrl: payload.kernelUrl,
101
+ functionId: payload.functionId,
102
+ targetNodeId: payload.targetNodeId,
103
+ mintIdentity: payload.mintIdentity,
104
+ },
105
+ })
106
+ }
107
+
108
+ Object.defineProperty(window, 'parent', { value: fakeParent, configurable: true })
109
+
110
+ return {
111
+ ack,
112
+ setTarget(nodeId: string) {
113
+ port?.postMessage({
114
+ type: 'intent',
115
+ version: 1,
116
+ envelope: { name: 'setTarget', payload: { nodeId }, sender: { windowId: '<parent>' } },
117
+ })
118
+ },
119
+ tokenRefresh(delegationToken: string, tokenExpiresAt: number) {
120
+ port?.postMessage({
121
+ type: 'ctrl',
122
+ version: 1,
123
+ action: 'tokenRefresh',
124
+ data: { delegationToken, tokenExpiresAt },
125
+ })
126
+ },
127
+ restore() {
128
+ if (port) {
129
+ port.onmessage = null
130
+ port.close()
131
+ }
132
+ if (originalParentDesc) Object.defineProperty(window, 'parent', originalParentDesc)
133
+ },
134
+ }
135
+ }
136
+
137
+ // ─── Fake kernel (fetch stub) ────────────────────────────────────────────────
138
+
139
+ export type CapturedRequest = {
140
+ url: string
141
+ method: string
142
+ headers: Record<string, string>
143
+ body: { method: string; params: unknown; id: unknown }
144
+ }
145
+
146
+ export type FakeKernel = {
147
+ /** Every request the client made, in order. */
148
+ readonly calls: CapturedRequest[]
149
+ /** Restore the global `fetch`. */
150
+ restore(): void
151
+ }
152
+
153
+ /**
154
+ * Replace global `fetch` with a stub that records each kernel request and
155
+ * returns whatever `respond` yields for it (a kernel envelope value, JSON-
156
+ * encoded). `respond` sees the parsed envelope body and the call index.
157
+ */
158
+ export function installFakeKernel(
159
+ respond: (body: CapturedRequest['body'], index: number) => unknown,
160
+ ): FakeKernel {
161
+ const calls: CapturedRequest[] = []
162
+ const original = globalThis.fetch
163
+
164
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
165
+ const url = typeof input === 'string' ? input : input.toString()
166
+ const headers: Record<string, string> = {}
167
+ const h = init?.headers
168
+ if (h && typeof h === 'object' && !Array.isArray(h)) {
169
+ for (const [k, v] of Object.entries(h)) headers[k.toLowerCase()] = String(v)
170
+ }
171
+ const body = JSON.parse(String(init?.body ?? '{}')) as CapturedRequest['body']
172
+ const captured: CapturedRequest = {
173
+ url,
174
+ method: String(init?.method ?? 'GET'),
175
+ headers,
176
+ body,
177
+ }
178
+ calls.push(captured)
179
+ const envelope = respond(body, calls.length - 1)
180
+ return new Response(JSON.stringify(envelope), {
181
+ status: 200,
182
+ headers: { 'content-type': 'application/vnd.astrale.kernel+json' },
183
+ })
184
+ }) as typeof fetch
185
+
186
+ return {
187
+ calls,
188
+ restore() {
189
+ globalThis.fetch = original
190
+ },
191
+ }
192
+ }
193
+
194
+ /** Wrap a value as a success envelope `{ result }` (what the kernel returns). */
195
+ export function ok(result: unknown): { result: unknown } {
196
+ return { result }
197
+ }
198
+
199
+ /** Build a minimal kernel Note node whose props carry title/body. */
200
+ export function noteNode(opts: {
201
+ id: string
202
+ path?: string
203
+ name?: string
204
+ title?: string
205
+ body?: string
206
+ domain?: string
207
+ }): { id: string; path: string; class: { raw: string }; props: Record<string, unknown> } {
208
+ const domain = opts.domain ?? 'notes.astrale.ai'
209
+ const props: Record<string, unknown> = {}
210
+ if (opts.name !== undefined) {
211
+ props['kernel.astrale.ai:interface.Named.property.name'] = opts.name
212
+ }
213
+ if (opts.title !== undefined) props[`${domain}:class.Note.property.title`] = opts.title
214
+ if (opts.body !== undefined) props[`${domain}:class.Note.property.body`] = opts.body
215
+ return {
216
+ id: opts.id,
217
+ path: opts.path ?? `/notes/${opts.id}`,
218
+ class: { raw: `/:${domain}:class.Note` },
219
+ props,
220
+ }
221
+ }
@@ -0,0 +1,68 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+
3
+ import { kernelCall, readProp, readPropBySuffix } from '../src/lib/kernel'
4
+ import { installFakeKernel } from './harness'
5
+
6
+ const TYPE = 'application/vnd.astrale.kernel+json'
7
+
8
+ describe('kernelCall — inline JSON envelope client', () => {
9
+ let restore: (() => void) | undefined
10
+ afterEach(() => {
11
+ restore?.()
12
+ restore = undefined
13
+ })
14
+
15
+ it('POSTs the envelope with a trailing slash, bare token, and kernel content type', async () => {
16
+ const k = installFakeKernel(() => ({ result: { ok: true } }))
17
+ restore = k.restore
18
+ const result = await kernelCall('https://k.test/api', 'tok-X', '@n1::get', {})
19
+ expect(result).toEqual({ ok: true })
20
+
21
+ const req = k.calls[0]!
22
+ expect(req.method).toBe('POST')
23
+ expect(req.url).toBe('https://k.test/api/') // slash added
24
+ expect(req.headers['authorization']).toBe('tok-X')
25
+ expect(req.headers['content-type']).toBe(TYPE)
26
+ expect(req.headers['accept']).toBe(TYPE)
27
+ expect(req.body).toMatchObject({ method: '@n1::get', params: {} })
28
+ expect(req.body.id).toBeTruthy()
29
+ })
30
+
31
+ it('does not double the trailing slash when the URL already ends in one', async () => {
32
+ const k = installFakeKernel(() => ({ result: 1 }))
33
+ restore = k.restore
34
+ await kernelCall('https://k.test/api/', 'tok', '@n::get')
35
+ expect(k.calls[0]!.url).toBe('https://k.test/api/')
36
+ })
37
+
38
+ it('throws "<code>: <message>" on a kernel error envelope', async () => {
39
+ const k = installFakeKernel(() => ({ error: { code: 2004, message: 'Permission denied' } }))
40
+ restore = k.restore
41
+ await expect(kernelCall('https://k.test/api', 't', '@n::get')).rejects.toThrow(
42
+ '2004: Permission denied',
43
+ )
44
+ })
45
+
46
+ it('throws on an unexpected redirect envelope', async () => {
47
+ const k = installFakeKernel(() => ({ redirect: { url: 'https://other.test/api' } }))
48
+ restore = k.restore
49
+ await expect(kernelCall('https://k.test/api', 't', '@n::get')).rejects.toThrow(/redirect/)
50
+ })
51
+ })
52
+
53
+ describe('prop readers', () => {
54
+ it('readProp returns the string value at the exact key', () => {
55
+ expect(readProp({ a: 'x', b: 1 }, 'a')).toBe('x')
56
+ expect(readProp({ a: 'x' }, 'missing')).toBeUndefined()
57
+ expect(readProp({ a: 1 }, 'a')).toBeUndefined() // non-string
58
+ })
59
+
60
+ it('readPropBySuffix matches the first key ending with the suffix', () => {
61
+ const props = {
62
+ 'notes.astrale.ai:class.Note.property.body': 'the body',
63
+ 'kernel.astrale.ai:interface.Named.property.name': 'n',
64
+ }
65
+ expect(readPropBySuffix(props, '.property.body')).toBe('the body')
66
+ expect(readPropBySuffix(props, '.property.title')).toBeUndefined()
67
+ })
68
+ })
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>astrale-domain · ui-note</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "astrale-domain-client",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "build": "vite build",
8
+ "dev": "vite build --watch",
9
+ "dev:hmr": "vite",
10
+ "typecheck": "tsgo --noEmit",
11
+ "test": "vitest run"
12
+ },
13
+ "dependencies": {
14
+ "react": "^19.2.0",
15
+ "react-dom": "^19.2.0"
16
+ },
17
+ "devDependencies": {
18
+ "@testing-library/react": "^16.3.0",
19
+ "@types/react": "^19.2.0",
20
+ "@types/react-dom": "^19.2.0",
21
+ "@vitejs/plugin-react": "^5.0.4",
22
+ "happy-dom": "^20.0.0",
23
+ "vite": "^7.1.7",
24
+ "vitest": "^3.2.4"
25
+ }
26
+ }
@@ -0,0 +1,94 @@
1
+ import type { NodeSession } from './lib/use-node'
2
+
3
+ import { classShortName, type KernelNode, PROP, readProp, readPropBySuffix } from './lib/kernel'
4
+ import { useNode } from './lib/use-node'
5
+ import { useShell } from './lib/use-shell'
6
+
7
+ /**
8
+ * The `ui-note` view. Mounted by the Astrale shell as a sandboxed iframe; the
9
+ * parent completes the handshake (see `lib/shell.ts`) and pushes the target
10
+ * node id this view renders. The view then loads that Note from the kernel via
11
+ * a minimal inline JSON client (`lib/kernel.ts`, `@<id>::get`) and renders its
12
+ * title/body — no `@astrale-os/*` deps.
13
+ */
14
+ export function App() {
15
+ const { status, session, nodeId, reason } = useShell()
16
+
17
+ return (
18
+ <div className="wrap">
19
+ <div className="card">
20
+ {status === 'loading' && (
21
+ <>
22
+ <h1 className="title">Note</h1>
23
+ <p className="subline">ui-note · Astrale view SPA</p>
24
+ <p className="muted">Waiting for the shell handshake…</p>
25
+ </>
26
+ )}
27
+
28
+ {status === 'standalone' && (
29
+ <>
30
+ <h1 className="title">Note</h1>
31
+ <p className="subline">ui-note · Astrale view SPA</p>
32
+ <div className="banner">
33
+ No parent shell ({reason}). This view is meant to be mounted by the Astrale GUI, which
34
+ hands it a target node and a kernel token. Showing a standalone preview.
35
+ </div>
36
+ <p className="body muted">
37
+ When mounted by the shell, this card renders the Note you opened.
38
+ </p>
39
+ </>
40
+ )}
41
+
42
+ {status === 'ready' && session && <NoteCard session={session} nodeId={nodeId} />}
43
+ </div>
44
+ </div>
45
+ )
46
+ }
47
+
48
+ /**
49
+ * Renders one Note, loaded from the kernel via the live session. Split out so
50
+ * `useNode` is called unconditionally (the parent only mounts this once the
51
+ * handshake is `ready`, so `session` is non-null here).
52
+ */
53
+ function NoteCard({ session, nodeId }: { session: NodeSession; nodeId: string | undefined }) {
54
+ const state = useNode(session, nodeId)
55
+
56
+ const title =
57
+ state.status === 'ok' ? (readProp(state.node.props, PROP.named.name) ?? 'Note') : 'Note'
58
+
59
+ return (
60
+ <>
61
+ <h1 className="title">{title}</h1>
62
+ <p className="subline">
63
+ {state.status === 'ok'
64
+ ? `${classShortName(state.node)} · ${state.node.path}`
65
+ : 'ui-note · Astrale view SPA'}
66
+ </p>
67
+
68
+ {state.status === 'idle' && (
69
+ <p className="body muted">Handshake complete, but no target node / token yet.</p>
70
+ )}
71
+
72
+ {state.status === 'loading' && <p className="muted">Loading the Note…</p>}
73
+
74
+ {state.status === 'error' && (
75
+ <div className="banner">Failed to load the Note: {state.message}</div>
76
+ )}
77
+
78
+ {state.status === 'ok' && <NoteBody node={state.node} />}
79
+ </>
80
+ )
81
+ }
82
+
83
+ /**
84
+ * The Note's `body` prop. Its key is domain-qualified
85
+ * (`<domain>:class.Note.property.body`), so read it by suffix rather than a
86
+ * build-time-unknown full key.
87
+ */
88
+ function NoteBody({ node }: { node: KernelNode }) {
89
+ const body = readPropBySuffix(node.props, '.property.body')
90
+ if (!body) {
91
+ return <p className="body muted">This Note has no body.</p>
92
+ }
93
+ return <p className="body">{body}</p>
94
+ }