diff options
| author | Adam Malczewski <[email protected]> | 2026-06-06 18:55:53 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-06 18:55:53 +0900 |
| commit | 22936857685c318b71752d625808100b1a96e63e (patch) | |
| tree | 5e10a73d616c206e3820a8d8568e5f3d4c8a302e /packages/surface-registry | |
| parent | 969afc45f895230fe3da1c737f18e64452efc8f2 (diff) | |
| download | dispatch-22936857685c318b71752d625808100b1a96e63e.tar.gz dispatch-22936857685c318b71752d625808100b1a96e63e.zip | |
feat(frontend,wire): surface system (FE slice 1) + @dispatch/wire types-only split (B2)
FE slice 1 — backend-declared, frontend-agnostic surface system (verified live): new types-only @dispatch/ui-contract (SurfaceSpec / field kinds / region / ActionRef / catalog), surface-registry (typed service handle), transport-ws (Bun WS :24205, path-agnostic upgrade), surface-loaded-extensions (first real surface); kernel HostAPI.getExtensions; host-bin wiring; bin/up. Harness: retire AGENTS 'backend only', ORCHESTRATOR §3/§7/§8, frontend-design.md locked.
B2 — wire-types split (chat-slice prerequisite): new types-only @dispatch/wire single-sources the wire ABI (AgentEvent + 11 variants; conversation model Chunk/ChatMessage/Role/TurnId/StepId + 6 chunk variants; Usage) with zero @dispatch/* deps. @dispatch/kernel re-exports via shims so its public surface is byte-identical (zero consumer blast radius). transport-contract re-exports AgentEvent from @dispatch/wire and drops its @dispatch/kernel dependency, so HTTP clients (the web frontend) consume the wire without the kernel runtime.
tsc -b + biome clean; 460 vitest + 77 bun pass.
Diffstat (limited to 'packages/surface-registry')
| -rw-r--r-- | packages/surface-registry/package.json | 12 | ||||
| -rw-r--r-- | packages/surface-registry/src/extension.ts | 23 | ||||
| -rw-r--r-- | packages/surface-registry/src/index.ts | 4 | ||||
| -rw-r--r-- | packages/surface-registry/src/registry.test.ts | 122 | ||||
| -rw-r--r-- | packages/surface-registry/src/registry.ts | 80 | ||||
| -rw-r--r-- | packages/surface-registry/src/service.ts | 4 | ||||
| -rw-r--r-- | packages/surface-registry/tsconfig.json | 6 |
7 files changed, 251 insertions, 0 deletions
diff --git a/packages/surface-registry/package.json b/packages/surface-registry/package.json new file mode 100644 index 0000000..16b0c4c --- /dev/null +++ b/packages/surface-registry/package.json @@ -0,0 +1,12 @@ +{ + "name": "@dispatch/surface-registry", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/ui-contract": "workspace:*" + } +} diff --git a/packages/surface-registry/src/extension.ts b/packages/surface-registry/src/extension.ts new file mode 100644 index 0000000..6d0ce22 --- /dev/null +++ b/packages/surface-registry/src/extension.ts @@ -0,0 +1,23 @@ +import type { Extension, Manifest } from "@dispatch/kernel"; +import { createSurfaceRegistry } from "./registry.js"; +import { surfaceRegistryHandle } from "./service.js"; + +export const manifest: Manifest = { + id: "surface-registry", + name: "Surface Registry", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + contributes: { services: ["surface-registry/registry"] }, +}; + +export function createSurfaceRegistryExtension(): Extension { + return { + manifest, + activate(host) { + const registry = createSurfaceRegistry(); + host.provideService(surfaceRegistryHandle, registry); + }, + }; +} diff --git a/packages/surface-registry/src/index.ts b/packages/surface-registry/src/index.ts new file mode 100644 index 0000000..cdfcf7e --- /dev/null +++ b/packages/surface-registry/src/index.ts @@ -0,0 +1,4 @@ +export { createSurfaceRegistryExtension, manifest } from "./extension.js"; +export type { SurfaceProvider, SurfaceRegistry } from "./registry.js"; +export { createSurfaceRegistry } from "./registry.js"; +export { surfaceRegistryHandle } from "./service.js"; diff --git a/packages/surface-registry/src/registry.test.ts b/packages/surface-registry/src/registry.test.ts new file mode 100644 index 0000000..c47c979 --- /dev/null +++ b/packages/surface-registry/src/registry.test.ts @@ -0,0 +1,122 @@ +import type { SurfaceCatalogEntry, SurfaceSpec } from "@dispatch/ui-contract"; +import { describe, expect, it } from "vitest"; +import type { SurfaceProvider } from "./registry.js"; +import { createSurfaceRegistry } from "./registry.js"; + +function fakeProvider(id: string, title?: string): SurfaceProvider { + const catalogEntry: SurfaceCatalogEntry = { + id, + region: "default", + title: title ?? `Surface ${id}`, + }; + return { + catalogEntry, + getSpec(): SurfaceSpec { + return { + id, + region: "default", + title: catalogEntry.title, + fields: [], + }; + }, + invoke() {}, + }; +} + +describe("createSurfaceRegistry", () => { + describe("register + getCatalog", () => { + it("returns the entry after registration", () => { + const registry = createSurfaceRegistry(); + registry.register(fakeProvider("a", "Surface A")); + + const catalog = registry.getCatalog(); + expect(catalog).toHaveLength(1); + expect(catalog[0]).toEqual({ + id: "a", + region: "default", + title: "Surface A", + }); + }); + + it("returns entries for multiple providers", () => { + const registry = createSurfaceRegistry(); + registry.register(fakeProvider("a")); + registry.register(fakeProvider("b")); + + const catalog = registry.getCatalog(); + expect(catalog).toHaveLength(2); + expect(catalog.map((e) => e.id)).toEqual(["a", "b"]); + }); + }); + + describe("getSurface", () => { + it("returns the provider for a known id", () => { + const registry = createSurfaceRegistry(); + const provider = fakeProvider("x"); + registry.register(provider); + + expect(registry.getSurface("x")).toBe(provider); + }); + + it("returns undefined for an unknown id", () => { + const registry = createSurfaceRegistry(); + expect(registry.getSurface("nonexistent")).toBeUndefined(); + }); + }); + + describe("disposer", () => { + it("removes the provider from catalog and lookup", () => { + const registry = createSurfaceRegistry(); + const dispose = registry.register(fakeProvider("a")); + + expect(registry.getCatalog()).toHaveLength(1); + expect(registry.getSurface("a")).toBeDefined(); + + dispose(); + + expect(registry.getCatalog()).toHaveLength(0); + expect(registry.getSurface("a")).toBeUndefined(); + }); + + it("is idempotent — calling dispose twice is safe", () => { + const registry = createSurfaceRegistry(); + const dispose = registry.register(fakeProvider("a")); + + dispose(); + dispose(); + + expect(registry.getCatalog()).toHaveLength(0); + }); + + it("does not remove a replacement provider with the same id", () => { + const registry = createSurfaceRegistry(); + const first = fakeProvider("a", "First"); + const second = fakeProvider("a", "Second"); + + const disposeFirst = registry.register(first); + registry.register(second); + + disposeFirst(); + + // The second provider should still be registered + expect(registry.getSurface("a")).toBe(second); + expect(registry.getCatalog()).toHaveLength(1); + expect(registry.getCatalog()[0]?.title).toBe("Second"); + }); + }); + + describe("duplicate-id behavior (last-wins)", () => { + it("replaces an existing provider when registering the same id", () => { + const registry = createSurfaceRegistry(); + const first = fakeProvider("a", "First"); + const second = fakeProvider("a", "Second"); + + registry.register(first); + registry.register(second); + + expect(registry.getSurface("a")).toBe(second); + expect(registry.getCatalog()).toHaveLength(1); + expect(registry.getCatalog()[0]?.title).toBe("Second"); + }); + }); +}); diff --git a/packages/surface-registry/src/registry.ts b/packages/surface-registry/src/registry.ts new file mode 100644 index 0000000..b1c8116 --- /dev/null +++ b/packages/surface-registry/src/registry.ts @@ -0,0 +1,80 @@ +import type { SurfaceCatalog, SurfaceCatalogEntry, SurfaceSpec } from "@dispatch/ui-contract"; + +/** + * What a surface-contributing extension registers with the surface registry. + * Each provider owns one surface identified by its catalog entry id. + */ +export interface SurfaceProvider { + /** Discovery metadata for the surface catalog. */ + readonly catalogEntry: SurfaceCatalogEntry; + + /** Build the current surface spec (may be async for dynamic surfaces). */ + getSpec(): SurfaceSpec | Promise<SurfaceSpec>; + + /** Run a backend action by id with an optional payload. */ + invoke(actionId: string, payload?: unknown): void | Promise<void>; + + /** + * Optional: subscribe to spec changes. Returns an unsubscribe disposer. + * When the spec changes, the caller should re-fetch via getSpec() and push. + */ + subscribe?(onChange: () => void): () => void; +} + +/** + * The surface registry service — the interface other extensions obtain via + * `host.getService(surfaceRegistryHandle)`. + */ +export interface SurfaceRegistry { + /** + * Register a surface provider. Returns an unregister disposer. + * If a provider with the same id is already registered, the new one + * replaces it (last-wins semantics). + */ + register(provider: SurfaceProvider): () => void; + + /** Return discovery metadata for all currently registered providers. */ + getCatalog(): SurfaceCatalog; + + /** Look up a provider by its surface id. */ + getSurface(id: string): SurfaceProvider | undefined; +} + +/** + * Create a pure in-memory surface registry. No I/O, no ambient state — + * the decision logic is a plain Map behind the SurfaceRegistry interface. + */ +export function createSurfaceRegistry(): SurfaceRegistry { + const providers = new Map<string, SurfaceProvider>(); + + return { + register(provider: SurfaceProvider): () => void { + const id = provider.catalogEntry.id; + providers.set(id, provider); + + let disposed = false; + return () => { + if (!disposed) { + disposed = true; + // Only delete if the current entry is still this provider + // (another register with the same id may have replaced it). + if (providers.get(id) === provider) { + providers.delete(id); + } + } + }; + }, + + getCatalog(): SurfaceCatalog { + const entries: SurfaceCatalogEntry[] = []; + for (const provider of providers.values()) { + entries.push(provider.catalogEntry); + } + return entries; + }, + + getSurface(id: string): SurfaceProvider | undefined { + return providers.get(id); + }, + }; +} diff --git a/packages/surface-registry/src/service.ts b/packages/surface-registry/src/service.ts new file mode 100644 index 0000000..a43c155 --- /dev/null +++ b/packages/surface-registry/src/service.ts @@ -0,0 +1,4 @@ +import { defineService } from "@dispatch/kernel"; +import type { SurfaceRegistry } from "./registry.js"; + +export const surfaceRegistryHandle = defineService<SurfaceRegistry>("surface-registry/registry"); diff --git a/packages/surface-registry/tsconfig.json b/packages/surface-registry/tsconfig.json new file mode 100644 index 0000000..e430ba9 --- /dev/null +++ b/packages/surface-registry/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }, { "path": "../ui-contract" }] +} |
