@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.
- package/package.json +53 -0
- package/src/client.ts +54 -0
- package/src/cloudflare.ts +228 -0
- package/src/codegen/identity.ts +40 -0
- package/src/codegen/merge.ts +92 -0
- package/src/codegen/worker.ts +105 -0
- package/src/codegen/wrangler.ts +95 -0
- package/src/index.ts +17 -0
- package/src/params.ts +49 -0
- package/src/parse-output.ts +54 -0
- package/src/wrangler-cli.ts +240 -0
- package/template/.env.example +6 -0
- package/template/README.md +77 -0
- package/template/astrale.config.ts +35 -0
- package/template/client/README.md +85 -0
- package/template/client/__tests__/app.test.tsx +191 -0
- package/template/client/__tests__/harness.ts +221 -0
- package/template/client/__tests__/kernel.test.ts +68 -0
- package/template/client/index.html +12 -0
- package/template/client/package.json +26 -0
- package/template/client/src/app.tsx +94 -0
- package/template/client/src/lib/kernel.ts +135 -0
- package/template/client/src/lib/shell.ts +197 -0
- package/template/client/src/lib/use-node.ts +66 -0
- package/template/client/src/lib/use-shell.ts +85 -0
- package/template/client/src/main.tsx +9 -0
- package/template/client/src/styles.css +107 -0
- package/template/client/tsconfig.json +25 -0
- package/template/client/vite.config.ts +40 -0
- package/template/client/vitest.config.ts +18 -0
- package/template/env.ts +18 -0
- package/template/functions/index.ts +9 -0
- package/template/methods/index.ts +66 -0
- package/template/methods/note.ts +131 -0
- package/template/package.json +30 -0
- package/template/pnpm-workspace.yaml +17 -0
- package/template/schema/compiled.ts +14 -0
- package/template/schema/index.ts +21 -0
- package/template/schema/note.ts +64 -0
- package/template/tsconfig.json +17 -0
- package/template/views/index.ts +10 -0
- package/template/views/note.ts +21 -0
- 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
|
+
}
|