@astrale-os/adapter-cloudflare 0.4.4 → 0.4.6
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/dist/codegen/wrangler.js +1 -1
- package/package.json +4 -3
- package/src/codegen/wrangler.ts +1 -1
- package/template/CLAUDE.md +8 -21
- package/template/README.md +30 -107
- package/template/client/__tests__/app.test.tsx +6 -57
- package/template/client/components.json +25 -0
- package/template/client/index.html +1 -1
- package/template/client/package.json +10 -2
- package/template/client/src/app.tsx +42 -33
- package/template/client/src/components/ui/cn.ts +6 -0
- package/template/client/src/styles.css +71 -502
- package/template/client/vite.config.ts +2 -18
- package/template/client/vitest.config.ts +0 -6
- package/template/core/README.md +5 -21
- package/template/deps.ts +2 -20
- package/template/domain.ts +0 -26
- package/template/env.ts +1 -17
- package/template/icons.ts +4 -25
- package/template/integrations/README.md +5 -20
- package/template/package.json +7 -5
- package/template/pnpm-workspace.yaml +2 -0
- package/template/runtime/index.ts +0 -27
- package/template/schema/index.ts +0 -20
- package/template/simulation/README.md +12 -0
- package/template/tsconfig.json +2 -3
- package/template/vitest.config.ts +7 -0
- package/template/.domain-studio/comments.json +0 -4
- package/template/client/README.md +0 -104
- package/template/client/__tests__/harness.ts +0 -230
- package/template/client/__tests__/shell.test.ts +0 -46
- package/template/client/src/shell/client.ts +0 -67
- package/template/client/src/shell/index.ts +0 -21
- package/template/client/src/shell/invoke.ts +0 -35
- package/template/client/src/shell/transformers.ts +0 -75
- package/template/client/src/shell/use-async.ts +0 -59
- package/template/client/src/shell/use-capability.ts +0 -59
- package/template/client/src/shell/use-node.ts +0 -81
- package/template/client/src/shell/use-shell.ts +0 -91
- package/template/client/src/shell/view-router.tsx +0 -97
- package/template/client/src/ui/format.ts +0 -24
- package/template/client/src/ui/index.ts +0 -10
- package/template/client/src/ui/surface.tsx +0 -56
- package/template/client/src/ui/value.tsx +0 -32
- package/template/functions/index.ts +0 -22
- package/template/views/index.ts +0 -19
package/template/domain.ts
CHANGED
|
@@ -1,37 +1,11 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* domain.ts — the WORKER-SAFE domain definition: what the domain IS (its
|
|
3
|
-
* `schema`, `methods`, `deps`, `views`, `functions`) plus its identity
|
|
4
|
-
* (`origin` defaults to `schema.domain`; `requires`; optional `postInstall`).
|
|
5
|
-
* Everything is wired EXPLICITLY here — a renamed or mistyped module is a
|
|
6
|
-
* compile error at this call, never a silently-missing route. The handlers live
|
|
7
|
-
* in `runtime/` (the composition root), the pure logic in `core/`, the
|
|
8
|
-
* external-API ports in `integrations/` — but the worker only ever sees this one
|
|
9
|
-
* wired definition.
|
|
10
|
-
*
|
|
11
|
-
* The maps it wires (`methods`, `views`, `functions`) start empty — this is a
|
|
12
|
-
* blank skeleton. Fill them in their folders and they flow through here with no
|
|
13
|
-
* change needed. Add `postInstall: functions.<key>` once you author a seed
|
|
14
|
-
* function — the kernel calls it once after install, as __SYSTEM__.
|
|
15
|
-
*
|
|
16
|
-
* The generated worker (`.astrale/worker.gen.ts`) imports THIS and spreads it,
|
|
17
|
-
* so your folder layout is invisible to it — reorganize freely; only this wiring
|
|
18
|
-
* has to stay put. The deploy adapter is attached separately in
|
|
19
|
-
* `astrale.config.ts` via `deploy(domain, …)`, keeping its node-only code
|
|
20
|
-
* (wrangler, filesystem, frontend builds) out of the worker bundle.
|
|
21
|
-
*/
|
|
22
1
|
import { defineDomain } from '@astrale-os/sdk'
|
|
23
2
|
|
|
24
3
|
import { deps } from './deps'
|
|
25
|
-
import { functions } from './functions'
|
|
26
4
|
import { methods } from './runtime'
|
|
27
5
|
import { schema } from './schema'
|
|
28
|
-
import { views } from './views'
|
|
29
6
|
|
|
30
7
|
export const domain = defineDomain({
|
|
31
8
|
schema,
|
|
32
9
|
methods,
|
|
33
10
|
deps,
|
|
34
|
-
views,
|
|
35
|
-
functions,
|
|
36
|
-
requires: [],
|
|
37
11
|
})
|
package/template/env.ts
CHANGED
|
@@ -1,24 +1,8 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Worker runtime env. The adapter injects `WORKER_URL` and the bindings; your
|
|
3
|
-
* handlers receive this (mapped by `deps.ts`) as `ctx.deps`. Secrets from
|
|
4
|
-
* `.env.<env>` arrive as extra string fields here. Add typed fields as you
|
|
5
|
-
* reference new secrets/vars.
|
|
6
|
-
*/
|
|
1
|
+
/** Cloudflare bindings and secrets available to the domain worker. */
|
|
7
2
|
export interface Env {
|
|
8
|
-
/** The worker's canonical serving URL (its `iss` identity), injected by the
|
|
9
|
-
* adapter for routed deploys. Optional — dev / workers.dev resolve it from the
|
|
10
|
-
* per-request Host. */
|
|
11
3
|
WORKER_URL?: string
|
|
12
|
-
/** Workers Assets binding (serves the client SPA under /ui/*), when present. */
|
|
13
4
|
ASSETS?: { fetch(request: Request): Promise<Response> }
|
|
14
|
-
/** Self service binding (autobinding — a handler calling its own domain).
|
|
15
|
-
* Present on Cloudflare deploys AND on current managed runtimes (the host
|
|
16
|
-
* binds every service to itself). Optional — hosts predating log/SELF
|
|
17
|
-
* support omit it; for graph reads from views/functions prefer
|
|
18
|
-
* `ctx.fn.kernel()`. */
|
|
19
5
|
SELF?: { fetch(request: Request): Promise<Response> }
|
|
20
|
-
/** Dev-only: forward /ui/* to a running Vite dev server. */
|
|
21
6
|
VIEW_DEV_URL?: string
|
|
22
|
-
|
|
23
7
|
[key: string]: unknown
|
|
24
8
|
}
|
package/template/icons.ts
CHANGED
|
@@ -1,25 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
*
|
|
6
|
-
* Markup is raw inner SVG — lift glyphs verbatim from lucide (ISC) and the
|
|
7
|
-
* `svg()` wrapper supplies the `<svg>` shell. `stroke="currentColor"` so the
|
|
8
|
-
* glyph follows text color. Add one constant per class:
|
|
9
|
-
*
|
|
10
|
-
* import { BOX_ICON } from '../icons'
|
|
11
|
-
* export const Widget = nodeClass({ icon: BOX_ICON, props: { … } })
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
const svg = (inner: string): string =>
|
|
15
|
-
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">${inner}</svg>`
|
|
16
|
-
|
|
17
|
-
/** lucide `box` — a generic starter glyph for your first class. */
|
|
18
|
-
export const BOX_ICON = svg(
|
|
19
|
-
'<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>',
|
|
20
|
-
)
|
|
21
|
-
|
|
22
|
-
/** lucide `sparkles` — for an AI / generative class. */
|
|
23
|
-
export const SPARKLES_ICON = svg(
|
|
24
|
-
'<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .962 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.962 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/>',
|
|
25
|
-
)
|
|
1
|
+
/** Wrap raw SVG paths for a schema class icon. Keep each named icon with its bounded context. */
|
|
2
|
+
export function icon(paths: string): string {
|
|
3
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">${paths}</svg>`
|
|
4
|
+
}
|
|
@@ -1,22 +1,7 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Integrations
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
is testable against a fake and the backend can be swapped per env or per node.
|
|
3
|
+
External-system ports and adapters live here, grouped by the system they integrate with. Runtime code
|
|
4
|
+
depends on narrow ports; adapters own network I/O and vendor SDKs. Simulation fakes belong under
|
|
5
|
+
`simulation/`, not beside production adapters.
|
|
7
6
|
|
|
8
|
-
|
|
9
|
-
integrations/<service>/
|
|
10
|
-
port.ts the interface the logic depends on (the narrowest surface)
|
|
11
|
-
http.ts a real adapter implementing the port
|
|
12
|
-
mock.ts an offline/deterministic adapter, for tests
|
|
13
|
-
registry.ts picks an adapter from `Env` (and optionally the target node),
|
|
14
|
-
exposed to handlers as part of `Deps`
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
`deps.ts` builds the registry from the worker `Env` once per isolate and mounts
|
|
18
|
-
it as `ctx.deps`; handlers resolve the concrete port per request. Until you add a
|
|
19
|
-
port, `deps.ts` is a passthrough (`Deps = Env`).
|
|
20
|
-
|
|
21
|
-
Delete this README once you add your first integration. Load the
|
|
22
|
-
`astrale-domain` skill for the DI / secrets / idempotency patterns.
|
|
7
|
+
Delete this file after adding the first integration.
|
package/template/package.json
CHANGED
|
@@ -11,13 +11,14 @@
|
|
|
11
11
|
"prod": "astrale-domain prod",
|
|
12
12
|
"deploy": "astrale-domain deploy",
|
|
13
13
|
"build": "astrale-domain build",
|
|
14
|
-
"typecheck": "tsgo --noEmit"
|
|
14
|
+
"typecheck": "tsgo --noEmit",
|
|
15
|
+
"test": "vitest run --config vitest.config.ts --passWithNoTests"
|
|
15
16
|
},
|
|
16
17
|
"dependencies": {
|
|
17
|
-
"@astrale-os/adapter-cloudflare": ">=0.
|
|
18
|
-
"@astrale-os/kernel-core": ">=0.7.
|
|
19
|
-
"@astrale-os/kernel-dsl": ">=0.1.
|
|
20
|
-
"@astrale-os/sdk": ">=0.4.
|
|
18
|
+
"@astrale-os/adapter-cloudflare": ">=0.4.4 <1.0.0",
|
|
19
|
+
"@astrale-os/kernel-core": ">=0.7.5 <1.0.0",
|
|
20
|
+
"@astrale-os/kernel-dsl": ">=0.1.4 <1.0.0",
|
|
21
|
+
"@astrale-os/sdk": ">=0.4.9 <1.0.0",
|
|
21
22
|
"@hono/node-server": "^1.19.0",
|
|
22
23
|
"zod": "^4.3.6"
|
|
23
24
|
},
|
|
@@ -25,6 +26,7 @@
|
|
|
25
26
|
"@types/node": "^22.0.0",
|
|
26
27
|
"@typescript/native-preview": "latest",
|
|
27
28
|
"typescript": "~6.0.1-rc",
|
|
29
|
+
"vitest": "^3.2.4",
|
|
28
30
|
"wrangler": "^4.0.0"
|
|
29
31
|
},
|
|
30
32
|
"engines": {
|
|
@@ -24,6 +24,7 @@ allowBuilds:
|
|
|
24
24
|
'@astrale-os/kernel-server': false
|
|
25
25
|
'@astrale-os/sdk': false
|
|
26
26
|
'@astrale-os/shell': false
|
|
27
|
+
'@astrale-os/shell-react': false
|
|
27
28
|
|
|
28
29
|
# @astrale-os packages ship a guarded no-op preinstall (a workspace-dev guard
|
|
29
30
|
# that exits instantly outside the monorepo); declare them intentionally
|
|
@@ -36,6 +37,7 @@ ignoredBuiltDependencies:
|
|
|
36
37
|
- '@astrale-os/kernel-server'
|
|
37
38
|
- '@astrale-os/sdk'
|
|
38
39
|
- '@astrale-os/shell'
|
|
40
|
+
- '@astrale-os/shell-react'
|
|
39
41
|
|
|
40
42
|
# pnpm v11 gates `pnpm run` on a deps-status check that misfires on the
|
|
41
43
|
# ignored preinstalls above.
|
|
@@ -1,33 +1,6 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Runtime composition root — the ONLY place request context (kernel/self/params/
|
|
3
|
-
* auth) and `deps` meet the per-feature logic. The `methods` map is what
|
|
4
|
-
* `domain.ts` mounts: one entry per class method, each delegating to the pure
|
|
5
|
-
* logic in `core/` and resolving its ports from `deps`. No business logic lives
|
|
6
|
-
* here — this file is just the wiring.
|
|
7
|
-
*
|
|
8
|
-
* It starts EMPTY (`class: {}`). Once you declare a class with methods in
|
|
9
|
-
* `schema/`, wire its handlers here:
|
|
10
|
-
*
|
|
11
|
-
* import { remoteClassMethods, remoteMethod } from '@astrale-os/sdk'
|
|
12
|
-
* const method = remoteMethod<Deps>()
|
|
13
|
-
* const classMethods = remoteClassMethods<Deps>()
|
|
14
|
-
*
|
|
15
|
-
* const greetMethod = method(schema, 'Greeter', 'greet', {
|
|
16
|
-
* authorize: async () => undefined,
|
|
17
|
-
* execute: ({ params }) => ({ message: `hi ${params.name}` }),
|
|
18
|
-
* })
|
|
19
|
-
*
|
|
20
|
-
* export const methods: SchemaMethodsImpl<typeof schema, Deps> = {
|
|
21
|
-
* class: { Greeter: classMethods(schema, 'Greeter', { greet: greetMethod }) },
|
|
22
|
-
* }
|
|
23
|
-
*
|
|
24
|
-
* Load the `astrale-domain` skill for the full handler/authorize/port patterns.
|
|
25
|
-
*/
|
|
26
1
|
import type { SchemaMethodsImpl } from '@astrale-os/sdk'
|
|
27
2
|
|
|
28
3
|
import type { Deps } from '../deps'
|
|
29
|
-
// While the map is empty, `schema` is only referenced as a type (`typeof schema`).
|
|
30
|
-
// Drop the `type` keyword once you pass `schema` to `method()` / `classMethods()`.
|
|
31
4
|
import type { schema } from '../schema'
|
|
32
5
|
|
|
33
6
|
export const methods: SchemaMethodsImpl<typeof schema, Deps> = {
|
package/template/schema/index.ts
CHANGED
|
@@ -1,17 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Schema assembly — the one place the domain's contexts come together, and the
|
|
3
|
-
* single source of the domain's stable identity. `schema.domain` (the first arg
|
|
4
|
-
* to `defineSchema`) IS the origin: `astrale.config.ts` defaults `origin` to it,
|
|
5
|
-
* and the worker signs JWTs under it. The scaffolder set this string from your
|
|
6
|
-
* slug; change it here to rename the domain's identity.
|
|
7
|
-
*
|
|
8
|
-
* The maps start EMPTY — this is prepared terrain, not a working domain. Add a
|
|
9
|
-
* context by authoring `schema/<context>.ts` (declare classes with `nodeClass`,
|
|
10
|
-
* interfaces with `nodeInterface`, edges with `edgeClass`), then import its
|
|
11
|
-
* members and list them in the `interfaces` / `classes` maps below. Wire the
|
|
12
|
-
* matching handlers in `runtime/`. Load the `astrale-domain` skill for the
|
|
13
|
-
* authoring patterns.
|
|
14
|
-
*/
|
|
15
1
|
import { defineSchema, KernelSchema } from '@astrale-os/kernel-core'
|
|
16
2
|
import { compileDomain, type Domain } from '@astrale-os/kernel-core/domain'
|
|
17
3
|
|
|
@@ -21,10 +7,4 @@ export const schema = defineSchema('astrale-domain.example.dev', {
|
|
|
21
7
|
imports: [KernelSchema],
|
|
22
8
|
})
|
|
23
9
|
|
|
24
|
-
/**
|
|
25
|
-
* The compiled domain — resolved class/interface paths + qualified prop keys.
|
|
26
|
-
* `compileDomain` is pure (no env), so this is safe to import from the worker
|
|
27
|
-
* and from build tooling alike. Read graph-facing strings (class paths, prop
|
|
28
|
-
* keys) off `D` once you add classes — never hand-write them.
|
|
29
|
-
*/
|
|
30
10
|
export const D: Domain<typeof schema> = compileDomain(schema)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Simulation
|
|
2
|
+
|
|
3
|
+
Keep domain-level samples and real business scenarios here, organized by bounded context:
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
simulation/
|
|
7
|
+
samples/<context>/
|
|
8
|
+
scenarios/<business-flow>.test.ts
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Use Vitest for scenarios. Prefer the real schema, handlers, and kernel test harness; fake only external
|
|
12
|
+
systems. Demo data must never leak into core data or install hooks.
|
package/template/tsconfig.json
CHANGED
|
@@ -17,13 +17,12 @@
|
|
|
17
17
|
"core",
|
|
18
18
|
"integrations",
|
|
19
19
|
"runtime",
|
|
20
|
-
"views",
|
|
21
|
-
"functions",
|
|
22
20
|
"deps.ts",
|
|
23
21
|
"env.ts",
|
|
24
22
|
"domain.ts",
|
|
25
23
|
"icons.ts",
|
|
26
|
-
"astrale.config.ts"
|
|
24
|
+
"astrale.config.ts",
|
|
25
|
+
"vitest.config.ts"
|
|
27
26
|
],
|
|
28
27
|
"exclude": ["node_modules", ".astrale", ".dist"]
|
|
29
28
|
}
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
# astrale-domain-client — SPA for domain views
|
|
2
|
-
|
|
3
|
-
A small React + Vite SPA that renders the domain's Views. It is loaded inside an
|
|
4
|
-
iframe mounted by the Astrale shell, runs the shell handshake (via the real
|
|
5
|
-
`@astrale-os/shell`) to learn its target node id and a kernel session, fetches
|
|
6
|
-
that node from the kernel, and renders it. The bundled status view loads a
|
|
7
|
-
StatusPage, follows its watched Monitor links, and renders the roll-up beside
|
|
8
|
-
the monitored targets, probe timestamps, latency, and HTTP status. "Check now"
|
|
9
|
-
calls the page's `check` instance method and reloads the page plus monitor rows.
|
|
10
|
-
Built into `../.dist/` and served by the generated worker (`.astrale/`) under
|
|
11
|
-
`/ui/*` via its `ASSETS` binding.
|
|
12
|
-
|
|
13
|
-
## Built on the real shell SDK
|
|
14
|
-
|
|
15
|
-
This client consumes `@astrale-os/shell` for the child handshake AND the kernel
|
|
16
|
-
client: `src/shell/use-shell.ts` boots `createShell({ mode: 'sandboxed' })`, and
|
|
17
|
-
feature hooks call kernel methods through `shell.kernel` (token refresh, codec
|
|
18
|
-
negotiation, redirect following, and delegation are all handled by the SDK — no
|
|
19
|
-
inline wire code). `@` is aliased to `src/` (mirrors `tsconfig` paths), so feature
|
|
20
|
-
code imports `@/shell`, `@/ui`, `@/status`.
|
|
21
|
-
|
|
22
|
-
## Layout
|
|
23
|
-
|
|
24
|
-
Feature-first: each feature owns its types/api/mappers/hooks/components. The
|
|
25
|
-
`shell/` and `ui/` folders are the generic, domain-neutral seams every feature
|
|
26
|
-
builds on. The template ships ONE feature, `status/`, over a StatusPage and its
|
|
27
|
-
watched Monitor nodes.
|
|
28
|
-
|
|
29
|
-
```
|
|
30
|
-
src/
|
|
31
|
-
main.tsx # entry → createRoot(App)
|
|
32
|
-
app.tsx # path router: ROUTES { '/ui/status-page': StatusView }
|
|
33
|
-
styles.css # self-contained styles (no Tailwind), design tokens
|
|
34
|
-
shell/ # GENERIC kernel/shell adapter on @astrale-os/shell
|
|
35
|
-
client.ts # prop readers (PROP, readProp, readPropBySuffix) + errors
|
|
36
|
-
invoke.ts # callMethod / invokeNode (@<id>::<method>) / nodeAddr
|
|
37
|
-
use-shell.ts # useShell() → createShell({ mode: 'sandboxed' })
|
|
38
|
-
use-node.ts # useNode(session, id) → @<id>::get (+ reload)
|
|
39
|
-
use-async.ts # generic reloadable async resource
|
|
40
|
-
use-capability.ts # write lifecycle (idle → running → done/failed)
|
|
41
|
-
transformers.ts # qualified-prop / link / node-array shaping
|
|
42
|
-
view-router.tsx # resolveView(routes) + ViewFrame (handshake gate)
|
|
43
|
-
index.ts # barrel → @/shell
|
|
44
|
-
ui/ # PURE presentation — no kernel, no hooks
|
|
45
|
-
surface.tsx # Panel / ErrorBanner / EmptyState / Spinner
|
|
46
|
-
StatusBadge.tsx # StatusBadge (up | degraded | down | unknown)
|
|
47
|
-
value.tsx # KV / Mono / ExternalLink
|
|
48
|
-
format.ts # relativeTime
|
|
49
|
-
index.ts # barrel → @/ui
|
|
50
|
-
status/ # THE status feature — StatusPage + watched Monitors
|
|
51
|
-
status.types.ts # StatusPanelRecord / CheckableRecord
|
|
52
|
-
status.api.ts # check(session, id) → @<id>::check
|
|
53
|
-
status.mappers.ts # checkableFromNode (node → record)
|
|
54
|
-
hooks/ # useCheckable.query / useCheck.mutation
|
|
55
|
-
components/ # StatusCard (container: hooks + @/ui)
|
|
56
|
-
index.ts # barrel → @/status
|
|
57
|
-
views/
|
|
58
|
-
status.tsx # StatusView = <ViewFrame>…<StatusCard/></ViewFrame>
|
|
59
|
-
__tests__/
|
|
60
|
-
harness.ts # fake shell parent (real protocol) + fake kernel (fetch stub)
|
|
61
|
-
app.test.tsx # handshake → render → check now → setTarget → fallback
|
|
62
|
-
seam.test.tsx # pure @/ui + @/status hooks in isolation (no handshake)
|
|
63
|
-
kernel.test.ts # pure helpers: prop readers, mapper, relativeTime
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
## Dev loops
|
|
67
|
-
|
|
68
|
-
```bash
|
|
69
|
-
pnpm dev # vite build --watch → ../.dist/ (worker auto-reloads, no HMR)
|
|
70
|
-
pnpm dev:hmr # vite dev on http://127.0.0.1:5173/ (React fast-refresh)
|
|
71
|
-
pnpm test # vitest run (happy-dom; fake parent speaks the real shell protocol)
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
For HMR, set `VIEW_DEV_URL=http://127.0.0.1:5173` in the worker's `.dev.vars`;
|
|
75
|
-
the generated worker forwards `/ui/*` to vite.
|
|
76
|
-
|
|
77
|
-
## How it connects
|
|
78
|
-
|
|
79
|
-
1. The domain declares each View in `../views/` with `mount: '/ui/<path>'`. The
|
|
80
|
-
SDK points the View node's iframe at `<serving url>/ui/<path>`.
|
|
81
|
-
2. The shell mounts that iframe and completes the handshake (a transferred
|
|
82
|
-
`MessagePort` + a `ctrl:handshake` carrying `kernelUrl`, the delegation token,
|
|
83
|
-
and the `targetNodeId`). `useShell()` boots `@astrale-os/shell`, surfaces the
|
|
84
|
-
kernel session, and tracks `setTarget` hot-swaps.
|
|
85
|
-
3. A feature loads its node through the session: `useNode(session, nodeId)` calls
|
|
86
|
-
`@<id>::get` over `shell.kernel`, and a mapper (`checkableFromNode`) projects
|
|
87
|
-
it to a typed record. Domain props are read by key SUFFIX (`.property.status`,
|
|
88
|
-
…) because the domain origin is unknown at build time; the name uses the kernel
|
|
89
|
-
`Named.name` key.
|
|
90
|
-
4. Writes go through `useCapability`: "Check now" runs `@<id>::check`, then calls
|
|
91
|
-
`useCheckable`'s `reload()` so the fresh status re-renders. The shell's kernel
|
|
92
|
-
client owns the credential and refreshes it before expiry — features never
|
|
93
|
-
touch the token.
|
|
94
|
-
|
|
95
|
-
## Adding a view
|
|
96
|
-
|
|
97
|
-
1. Write a `ViewComponent` (usually wrapping `ViewFrame`) under `src/views/`.
|
|
98
|
-
2. Add a `ROUTES` entry keyed by its mount path in `src/app.tsx`.
|
|
99
|
-
3. Register a matching `defineView({ mount: '/ui/<path>' })` in the domain's
|
|
100
|
-
`views/` so the shell mounts an iframe there.
|
|
101
|
-
|
|
102
|
-
For a NEW feature, mirror `src/status/`: a `*.types.ts`, `*.api.ts`,
|
|
103
|
-
`*.mappers.ts`, a `hooks/` folder of `.query`/`.mutation` hooks, and a
|
|
104
|
-
`components/` folder of containers that compose `@/ui`.
|
|
@@ -1,230 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Test harness — a FAKE shell parent + a FAKE kernel, so the client's shell
|
|
3
|
-
* handshake (now `@astrale-os/shell`, `createShell({ mode: 'sandboxed' })`) and
|
|
4
|
-
* its kernel calls can be exercised with no real GUI/kernel.
|
|
5
|
-
*
|
|
6
|
-
* `fakeKernelSession()` is the unit-test counterpart: a `KernelClient` whose
|
|
7
|
-
* `call` hits the fake kernel directly (no handshake), for hooks tested in
|
|
8
|
-
* isolation.
|
|
9
|
-
*
|
|
10
|
-
* The fake parent mirrors `runParentHandshake`
|
|
11
|
-
* (`shell/packages/shell/src/application/windowing/handshake.ts`):
|
|
12
|
-
* - replies to `astrale-shell/init-request` with `init-response` + a
|
|
13
|
-
* transferred `MessagePort` (`MessageChannel.port2`),
|
|
14
|
-
* - posts `ctrl:handshake` over the port with the session data,
|
|
15
|
-
* - resolves once it receives the child's `ctrl:handshakeAck`,
|
|
16
|
-
* - then lets a test push `setTarget` intents and `ctrl:tokenRefresh`.
|
|
17
|
-
*
|
|
18
|
-
* The happy-dom env exposes Node's REAL `MessageChannel` (its own `MessagePort`
|
|
19
|
-
* is a no-op stub), so port delivery actually works. `MessageEvent` carries
|
|
20
|
-
* `source`/`ports` via its constructor, and `window.parent` is overridden so
|
|
21
|
-
* the child believes it is framed.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
import { createKernelClientAdapter } from '@astrale-os/shell'
|
|
25
|
-
|
|
26
|
-
import type { KernelClient } from '@/shell'
|
|
27
|
-
|
|
28
|
-
const INIT_REQUEST_TYPE = 'astrale-shell/init-request'
|
|
29
|
-
const INIT_RESPONSE_TYPE = 'astrale-shell/init-response'
|
|
30
|
-
|
|
31
|
-
export type HandshakePayload = {
|
|
32
|
-
windowId: string
|
|
33
|
-
kernelUrl: string
|
|
34
|
-
functionId: string
|
|
35
|
-
delegationToken: string
|
|
36
|
-
tokenExpiresAt: number
|
|
37
|
-
targetNodeId?: string
|
|
38
|
-
mintIdentity?: string
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export type FakeParent = {
|
|
42
|
-
/** Resolves once the child acks the handshake (mirrors runParentHandshake). */
|
|
43
|
-
readonly ack: Promise<void>
|
|
44
|
-
/** Hot-swap the target node via a `setTarget` intent. */
|
|
45
|
-
setTarget(nodeId: string): void
|
|
46
|
-
/** Push a fresh delegation token via `ctrl:tokenRefresh`. */
|
|
47
|
-
tokenRefresh(delegationToken: string, tokenExpiresAt: number): void
|
|
48
|
-
/** Restore `window.parent` and detach listeners. */
|
|
49
|
-
restore(): void
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Install a fake shell parent. MUST be called BEFORE the child mounts (the
|
|
54
|
-
* child posts `init-request` to `window.parent` in a mount effect).
|
|
55
|
-
*/
|
|
56
|
-
export function installFakeParent(payload: HandshakePayload): FakeParent {
|
|
57
|
-
const originalParentDesc = Object.getOwnPropertyDescriptor(window, 'parent')
|
|
58
|
-
|
|
59
|
-
let port: MessagePort | null = null
|
|
60
|
-
let ackResolve!: () => void
|
|
61
|
-
const ack = new Promise<void>((r) => {
|
|
62
|
-
ackResolve = r
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
// The fake parent object — its `postMessage` receives the child's init-request.
|
|
66
|
-
const fakeParent = {
|
|
67
|
-
postMessage(message: unknown, _targetOrigin?: string) {
|
|
68
|
-
if (
|
|
69
|
-
message !== null &&
|
|
70
|
-
typeof message === 'object' &&
|
|
71
|
-
(message as { type?: unknown }).type === INIT_REQUEST_TYPE
|
|
72
|
-
) {
|
|
73
|
-
onInitRequest()
|
|
74
|
-
}
|
|
75
|
-
},
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function onInitRequest() {
|
|
79
|
-
const channel = new MessageChannel()
|
|
80
|
-
port = channel.port1
|
|
81
|
-
|
|
82
|
-
port.onmessage = (ev: MessageEvent) => {
|
|
83
|
-
const msg = ev.data as { type?: string; action?: string } | null
|
|
84
|
-
if (msg && msg.type === 'ctrl' && msg.action === 'handshakeAck') {
|
|
85
|
-
ackResolve()
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
port.start()
|
|
89
|
-
|
|
90
|
-
// Transfer port2 to the child via an init-response on `window`.
|
|
91
|
-
window.dispatchEvent(
|
|
92
|
-
new MessageEvent('message', {
|
|
93
|
-
data: { type: INIT_RESPONSE_TYPE, version: 1, windowId: payload.windowId },
|
|
94
|
-
source: fakeParent as unknown as Window,
|
|
95
|
-
ports: [channel.port2],
|
|
96
|
-
}),
|
|
97
|
-
)
|
|
98
|
-
|
|
99
|
-
// Then the handshake control message carrying the session data.
|
|
100
|
-
port.postMessage({
|
|
101
|
-
type: 'ctrl',
|
|
102
|
-
version: 1,
|
|
103
|
-
action: 'handshake',
|
|
104
|
-
data: {
|
|
105
|
-
delegationToken: payload.delegationToken,
|
|
106
|
-
tokenExpiresAt: payload.tokenExpiresAt,
|
|
107
|
-
windowId: payload.windowId,
|
|
108
|
-
kernelUrl: payload.kernelUrl,
|
|
109
|
-
functionId: payload.functionId,
|
|
110
|
-
targetNodeId: payload.targetNodeId,
|
|
111
|
-
mintIdentity: payload.mintIdentity,
|
|
112
|
-
},
|
|
113
|
-
})
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
Object.defineProperty(window, 'parent', { value: fakeParent, configurable: true })
|
|
117
|
-
|
|
118
|
-
return {
|
|
119
|
-
ack,
|
|
120
|
-
setTarget(nodeId: string) {
|
|
121
|
-
port?.postMessage({
|
|
122
|
-
type: 'intent',
|
|
123
|
-
version: 1,
|
|
124
|
-
envelope: { name: 'setTarget', payload: { nodeId }, sender: { windowId: '<parent>' } },
|
|
125
|
-
})
|
|
126
|
-
},
|
|
127
|
-
tokenRefresh(delegationToken: string, tokenExpiresAt: number) {
|
|
128
|
-
port?.postMessage({
|
|
129
|
-
type: 'ctrl',
|
|
130
|
-
version: 1,
|
|
131
|
-
action: 'tokenRefresh',
|
|
132
|
-
data: { delegationToken, tokenExpiresAt },
|
|
133
|
-
})
|
|
134
|
-
},
|
|
135
|
-
restore() {
|
|
136
|
-
if (port) {
|
|
137
|
-
port.onmessage = null
|
|
138
|
-
port.close()
|
|
139
|
-
}
|
|
140
|
-
if (originalParentDesc) Object.defineProperty(window, 'parent', originalParentDesc)
|
|
141
|
-
},
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// ─── Fake kernel (fetch stub) ────────────────────────────────────────────────
|
|
146
|
-
|
|
147
|
-
export type CapturedRequest = {
|
|
148
|
-
url: string
|
|
149
|
-
method: string
|
|
150
|
-
headers: Record<string, string>
|
|
151
|
-
body: { method: string; params: unknown; id: unknown }
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
export type FakeKernel = {
|
|
155
|
-
/** Every request the client made, in order. */
|
|
156
|
-
readonly calls: CapturedRequest[]
|
|
157
|
-
/** Restore the global `fetch`. */
|
|
158
|
-
restore(): void
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Replace global `fetch` with a stub that records each kernel request and
|
|
163
|
-
* returns whatever `respond` yields for it (a kernel envelope value, JSON-
|
|
164
|
-
* encoded). `respond` sees the parsed envelope body and the call index.
|
|
165
|
-
*/
|
|
166
|
-
export function installFakeKernel(
|
|
167
|
-
respond: (body: CapturedRequest['body'], index: number) => unknown,
|
|
168
|
-
): FakeKernel {
|
|
169
|
-
const calls: CapturedRequest[] = []
|
|
170
|
-
const original = globalThis.fetch
|
|
171
|
-
|
|
172
|
-
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
173
|
-
const url = typeof input === 'string' ? input : input.toString()
|
|
174
|
-
const headers: Record<string, string> = {}
|
|
175
|
-
const h = init?.headers
|
|
176
|
-
if (h && typeof h === 'object' && !Array.isArray(h)) {
|
|
177
|
-
for (const [k, v] of Object.entries(h)) headers[k.toLowerCase()] = String(v)
|
|
178
|
-
}
|
|
179
|
-
const body = JSON.parse(String(init?.body ?? '{}')) as CapturedRequest['body']
|
|
180
|
-
const captured: CapturedRequest = {
|
|
181
|
-
url,
|
|
182
|
-
method: String(init?.method ?? 'GET'),
|
|
183
|
-
headers,
|
|
184
|
-
body,
|
|
185
|
-
}
|
|
186
|
-
calls.push(captured)
|
|
187
|
-
const envelope = respond(body, calls.length - 1)
|
|
188
|
-
return new Response(JSON.stringify(envelope), {
|
|
189
|
-
status: 200,
|
|
190
|
-
headers: { 'content-type': 'application/vnd.astrale.kernel+json' },
|
|
191
|
-
})
|
|
192
|
-
}) as typeof fetch
|
|
193
|
-
|
|
194
|
-
return {
|
|
195
|
-
calls,
|
|
196
|
-
restore() {
|
|
197
|
-
globalThis.fetch = original
|
|
198
|
-
},
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/** Wrap a value as a success envelope `{ result }` (what the kernel returns). */
|
|
203
|
-
export function ok(result: unknown): { result: unknown } {
|
|
204
|
-
return { result }
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* A `KernelClient` for unit tests that exercise feature hooks WITHOUT a
|
|
209
|
-
* handshake — the REAL bound client over the fake `fetch`. `call` (and the graph
|
|
210
|
-
* sugar `get`/`query`/`mutate`/…) POST the kernel JSON envelope
|
|
211
|
-
* (`{ method, params, id }`) that `installFakeKernel` records, so `body.method`/
|
|
212
|
-
* `body.params` assertions hold — the same wire `shell.kernel` speaks. Built on
|
|
213
|
-
* `createKernelClientAdapter` so it always satisfies `KernelClient` as that
|
|
214
|
-
* surface grows; the deprecated iframe extras (`mintDelegation`/`authToken`/
|
|
215
|
-
* `url`) are stubbed, exactly as the shell adds them around the same bound view.
|
|
216
|
-
*/
|
|
217
|
-
export function fakeKernelSession(kernelUrl = 'https://k.example.test/api'): KernelClient {
|
|
218
|
-
const adapter = createKernelClientAdapter({
|
|
219
|
-
url: kernelUrl,
|
|
220
|
-
// Read `globalThis.fetch` per request so `installFakeKernel` (which swaps it
|
|
221
|
-
// in after this session is built) is the one that answers.
|
|
222
|
-
fetch: (input, init) => globalThis.fetch(input as RequestInfo, init),
|
|
223
|
-
})
|
|
224
|
-
const view = adapter.boundView(() => 'tok-A')
|
|
225
|
-
return Object.assign(view, {
|
|
226
|
-
mintDelegation: async () => '<fake>',
|
|
227
|
-
authToken: () => 'tok-A',
|
|
228
|
-
url: kernelUrl,
|
|
229
|
-
})
|
|
230
|
-
}
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The kernel-boundary plumbing every view builds on, tested with ZERO
|
|
3
|
-
* infrastructure — no shell, no kernel, no session. Props in, values out. These
|
|
4
|
-
* are the helpers `@/shell` exposes for reading qualified node props (which the
|
|
5
|
-
* kernel stores under domain-prefixed keys) plus the shared display formatters.
|
|
6
|
-
* Your feature mappers (`<feature>.mappers.ts`) build on these.
|
|
7
|
-
*/
|
|
8
|
-
import { describe, expect, it } from 'vitest'
|
|
9
|
-
|
|
10
|
-
import { qualifiedString, readProp, readPropBySuffix } from '@/shell'
|
|
11
|
-
import { relativeTime } from '@/ui'
|
|
12
|
-
|
|
13
|
-
describe('prop readers', () => {
|
|
14
|
-
it('readProp returns the string value at the exact key', () => {
|
|
15
|
-
expect(readProp({ a: 'x', b: 1 }, 'a')).toBe('x')
|
|
16
|
-
expect(readProp({ a: 'x' }, 'missing')).toBeUndefined()
|
|
17
|
-
expect(readProp({ a: 1 }, 'a')).toBeUndefined() // non-string
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
it('readPropBySuffix matches the first key ending with the suffix', () => {
|
|
21
|
-
const props = {
|
|
22
|
-
'acme.example.dev:class.Widget.property.label': 'Hello',
|
|
23
|
-
'kernel.astrale.ai:interface.Named.property.name': 'n',
|
|
24
|
-
}
|
|
25
|
-
expect(readPropBySuffix(props, '.property.label')).toBe('Hello')
|
|
26
|
-
expect(readPropBySuffix(props, '.property.status')).toBeUndefined()
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
it('qualifiedString prefers a domain key over the kernel one for the same suffix', () => {
|
|
30
|
-
const props = {
|
|
31
|
-
'kernel.astrale.ai:interface.Named.property.name': 'kernel name',
|
|
32
|
-
'acme.example.dev:class.Widget.property.name': 'domain name',
|
|
33
|
-
}
|
|
34
|
-
expect(qualifiedString(props, 'name')).toBe('domain name')
|
|
35
|
-
})
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
describe('relativeTime', () => {
|
|
39
|
-
const now = Date.parse('2026-06-14T12:00:00Z')
|
|
40
|
-
it('renders coarse buckets and a dash for missing input', () => {
|
|
41
|
-
expect(relativeTime(undefined, now)).toBe('—')
|
|
42
|
-
expect(relativeTime('2026-06-14T11:55:00Z', now)).toBe('5m ago')
|
|
43
|
-
expect(relativeTime('2026-06-14T10:00:00Z', now)).toBe('2h ago')
|
|
44
|
-
expect(relativeTime('not-a-date', now)).toBe('not-a-date')
|
|
45
|
-
})
|
|
46
|
-
})
|