@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.
Files changed (46) hide show
  1. package/dist/codegen/wrangler.js +1 -1
  2. package/package.json +4 -3
  3. package/src/codegen/wrangler.ts +1 -1
  4. package/template/CLAUDE.md +8 -21
  5. package/template/README.md +30 -107
  6. package/template/client/__tests__/app.test.tsx +6 -57
  7. package/template/client/components.json +25 -0
  8. package/template/client/index.html +1 -1
  9. package/template/client/package.json +10 -2
  10. package/template/client/src/app.tsx +42 -33
  11. package/template/client/src/components/ui/cn.ts +6 -0
  12. package/template/client/src/styles.css +71 -502
  13. package/template/client/vite.config.ts +2 -18
  14. package/template/client/vitest.config.ts +0 -6
  15. package/template/core/README.md +5 -21
  16. package/template/deps.ts +2 -20
  17. package/template/domain.ts +0 -26
  18. package/template/env.ts +1 -17
  19. package/template/icons.ts +4 -25
  20. package/template/integrations/README.md +5 -20
  21. package/template/package.json +7 -5
  22. package/template/pnpm-workspace.yaml +2 -0
  23. package/template/runtime/index.ts +0 -27
  24. package/template/schema/index.ts +0 -20
  25. package/template/simulation/README.md +12 -0
  26. package/template/tsconfig.json +2 -3
  27. package/template/vitest.config.ts +7 -0
  28. package/template/.domain-studio/comments.json +0 -4
  29. package/template/client/README.md +0 -104
  30. package/template/client/__tests__/harness.ts +0 -230
  31. package/template/client/__tests__/shell.test.ts +0 -46
  32. package/template/client/src/shell/client.ts +0 -67
  33. package/template/client/src/shell/index.ts +0 -21
  34. package/template/client/src/shell/invoke.ts +0 -35
  35. package/template/client/src/shell/transformers.ts +0 -75
  36. package/template/client/src/shell/use-async.ts +0 -59
  37. package/template/client/src/shell/use-capability.ts +0 -59
  38. package/template/client/src/shell/use-node.ts +0 -81
  39. package/template/client/src/shell/use-shell.ts +0 -91
  40. package/template/client/src/shell/view-router.tsx +0 -97
  41. package/template/client/src/ui/format.ts +0 -24
  42. package/template/client/src/ui/index.ts +0 -10
  43. package/template/client/src/ui/surface.tsx +0 -56
  44. package/template/client/src/ui/value.tsx +0 -32
  45. package/template/functions/index.ts +0 -22
  46. package/template/views/index.ts +0 -19
@@ -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
- * Class icons raw SVG markup attached to a class via `nodeClass({ icon })`.
3
- * The kernel materializes the icon onto the class `self` node (the `Iconable`
4
- * interface) at boot; clients resolve an instance's icon through its class.
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
- # integrations/ — external-API ports + adapters
1
+ # Integrations
2
2
 
3
- This is the seam between your domain's logic and the outside world (an HTTP API,
4
- an SDK, a queue, …). The pattern keeps `runtime/` logic depending on a narrow
5
- **port** (an interface), never on `fetch` or a vendor SDK directly — so a handler
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.
@@ -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.3.2 <1.0.0",
18
- "@astrale-os/kernel-core": ">=0.7.3 <1.0.0",
19
- "@astrale-os/kernel-dsl": ">=0.1.2 <1.0.0",
20
- "@astrale-os/sdk": ">=0.4.4 <1.0.0",
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> = {
@@ -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.
@@ -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
  }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ['simulation/**/*.test.ts'],
6
+ },
7
+ })
@@ -1,4 +0,0 @@
1
- {
2
- "schemaVersion": "",
3
- "comments": []
4
- }
@@ -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
- })