diff --git a/code/.storybook/main.ts b/code/.storybook/main.ts index 65659abf40f3..98e20dd14f04 100644 --- a/code/.storybook/main.ts +++ b/code/.storybook/main.ts @@ -37,6 +37,10 @@ const config = defineMain({ directory: '../core/src/preview', titlePrefix: 'preview', }, + { + directory: '../core/src/shared', + titlePrefix: 'core/shared', + }, { directory: '../core/src/components/brand', titlePrefix: 'brand', diff --git a/code/.storybook/manager.tsx b/code/.storybook/manager.tsx index f7a31bd2bd06..4abd5c3ed15d 100644 --- a/code/.storybook/manager.tsx +++ b/code/.storybook/manager.tsx @@ -6,3 +6,5 @@ addons.setConfig({ renderLabel: ({ name, type }) => (type === 'story' ? name : startCase(name)), }, }); + +import '../core/src/shared/open-service/sync-test/manager.tsx'; diff --git a/code/.storybook/open-service-debug-service.ts b/code/.storybook/open-service-debug-service.ts index 79f7ff38d751..d7aad091dcba 100644 --- a/code/.storybook/open-service-debug-service.ts +++ b/code/.storybook/open-service-debug-service.ts @@ -3,7 +3,7 @@ import * as v from 'valibot'; import { logger } from 'storybook/internal/node-logger'; import type { StoryIndexGenerator } from '../core/src/core-server/utils/StoryIndexGenerator.ts'; -import { defineService } from '../core/src/shared/open-service/index.ts'; +import { defineService } from 'storybook/open-service'; import { describeService, registerService } from '../core/src/shared/open-service/server.ts'; const DEBUG_SERVICE_ID = 'storybook/internal/open-service-debug'; diff --git a/code/.storybook/preview.tsx b/code/.storybook/preview.tsx index ec3fb2a8fd75..04421ea6dd1d 100644 --- a/code/.storybook/preview.tsx +++ b/code/.storybook/preview.tsx @@ -51,6 +51,8 @@ sb.mock(import('lodash-es/sum')); sb.mock(import('uuid')); /* eslint-enable depend/ban-dependencies */ +import '../core/src/shared/open-service/sync-test/preview.ts'; + const { document } = global; globalThis.CONFIG_TYPE = 'DEVELOPMENT'; diff --git a/code/.storybook/services-preset.ts b/code/.storybook/services-preset.ts index 808e0fcbad67..c15cbe621937 100644 --- a/code/.storybook/services-preset.ts +++ b/code/.storybook/services-preset.ts @@ -1,15 +1,16 @@ import type { Options, StorybookConfigRaw } from 'storybook/internal/types'; +import { registerOpenServiceSyncDemos } from '../core/src/shared/open-service/sync-test/server.ts'; import { registerOpenServiceDebugService } from './open-service-debug-service.ts'; /** - * Preset hook that registers the internal open-service debug service. + * Preset hook that registers internal open-service examples and the opt-in debug service. * - * Lives in its own preset file so the `services` slot stays out of the public `StorybookConfig` - * surface while still letting the internal Storybook self-test the registration path. Set - * `STORYBOOK_OPEN_SERVICE_DEBUG=true` to opt in. + * Set `STORYBOOK_OPEN_SERVICE_DEBUG=true` to additionally register the debug service. */ export const services = async (_value: void, options: Options): Promise => { + registerOpenServiceSyncDemos(); + if (process.env.STORYBOOK_OPEN_SERVICE_DEBUG === 'true') { await registerOpenServiceDebugService( options.presets.apply>( diff --git a/code/.storybook/storybook.setup.ts b/code/.storybook/storybook.setup.ts index 875d71242c63..8c87819e80d7 100644 --- a/code/.storybook/storybook.setup.ts +++ b/code/.storybook/storybook.setup.ts @@ -5,6 +5,7 @@ import { setProjectAnnotations } from '@storybook/react'; import { userEvent as storybookEvent, expect as storybookExpect } from 'storybook/test'; import '../core/src/shared/utils/toHaveLiveRegion.ts'; + import preview from './preview.tsx'; vi.spyOn(console, 'warn').mockImplementation((...args) => console.log(...args)); diff --git a/code/.storybook/tsconfig.json b/code/.storybook/tsconfig.json new file mode 100644 index 000000000000..a32dd4f239d6 --- /dev/null +++ b/code/.storybook/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": ["**/*"] +} diff --git a/code/addons/vitest/src/vitest-plugin/setup-file.test.ts b/code/addons/vitest/src/vitest-plugin/setup-file.test.ts index 4d3e82e78738..32d52742d733 100644 --- a/code/addons/vitest/src/vitest-plugin/setup-file.test.ts +++ b/code/addons/vitest/src/vitest-plugin/setup-file.test.ts @@ -1,34 +1,48 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { Channel } from 'storybook/internal/channels'; +import { Channel, clearChannel, getChannel, setChannel } from 'storybook/internal/channels'; -import { type Task, initTransport, modifyErrorMessage } from './setup-file.ts'; +import { + type Task, + initTransport, + modifyErrorMessage, + restoreDefaultChannel, +} from './setup-file.ts'; describe('initTransport', () => { afterEach(() => { - // Cleanup the global channel so each test can assert initialization behavior independently. - - (globalThis as { __STORYBOOK_ADDONS_CHANNEL__?: Channel }).__STORYBOOK_ADDONS_CHANNEL__ = - undefined; + clearChannel(); }); it('should initialize the addons channel when missing', () => { - (globalThis as { __STORYBOOK_ADDONS_CHANNEL__?: Channel }).__STORYBOOK_ADDONS_CHANNEL__ = - undefined; + clearChannel(); + + initTransport(); + expect(getChannel()).toBeInstanceOf(Channel); + }); + + it('restoreDefaultChannel reinstalls the default when the slot was replaced', () => { initTransport(); + const defaultRef = getChannel(); + + setChannel(new Channel({ transport: { setHandler: vi.fn(), send: vi.fn() } })); - expect(globalThis.__STORYBOOK_ADDONS_CHANNEL__).toBeInstanceOf(Channel); + restoreDefaultChannel(); + + expect(getChannel()).toBe(defaultRef); }); it('should not overwrite an existing addons channel', () => { const transport = { setHandler: vi.fn(), send: vi.fn() }; const existingChannel = new Channel({ transport }); - globalThis.__STORYBOOK_ADDONS_CHANNEL__ = existingChannel; + clearChannel(); + (globalThis as { __STORYBOOK_ADDONS_CHANNEL__?: Channel }).__STORYBOOK_ADDONS_CHANNEL__ = + existingChannel; initTransport(); - expect(globalThis.__STORYBOOK_ADDONS_CHANNEL__).toBe(existingChannel); + expect(getChannel()).toBe(existingChannel); }); }); diff --git a/code/addons/vitest/src/vitest-plugin/setup-file.ts b/code/addons/vitest/src/vitest-plugin/setup-file.ts index af54592d924e..1c836e2a150f 100644 --- a/code/addons/vitest/src/vitest-plugin/setup-file.ts +++ b/code/addons/vitest/src/vitest-plugin/setup-file.ts @@ -2,6 +2,7 @@ import { afterEach, beforeAll, vi } from 'vitest'; import type { RunnerTask } from 'vitest'; import { Channel } from 'storybook/internal/channels'; +import { getChannel, setChannel } from 'storybook/internal/channels'; import { COMPONENT_TESTING_PANEL_ID } from '../constants.ts'; @@ -16,9 +17,31 @@ export type Task = Partial & { meta: Record; }; +let defaultChannel: Channel | null = null; + export const initTransport = () => { + const existing = getChannel(); + if (existing) { + defaultChannel ??= existing as Channel; + return; + } + const transport = { setHandler: vi.fn(), send: vi.fn() }; - globalThis.__STORYBOOK_ADDONS_CHANNEL__ ??= new Channel({ transport }); + const channel = new Channel({ transport }); + defaultChannel = channel; + setChannel(channel); +}; + +/** Restore the channel installed for story tests (e.g. after manager stories swap in a mock). */ +export const restoreDefaultChannel = () => { + if (!defaultChannel) { + initTransport(); + return; + } + + if (getChannel() !== defaultChannel) { + setChannel(defaultChannel); + } }; export const modifyErrorMessage = ({ task }: { task: Task }) => { @@ -44,4 +67,7 @@ beforeAll(() => { } }); -afterEach(modifyErrorMessage); +afterEach((ctx) => { + restoreDefaultChannel(); + modifyErrorMessage({ task: ctx.task }); +}); diff --git a/code/builders/builder-vite/src/codegen-set-addon-channel.ts b/code/builders/builder-vite/src/codegen-set-addon-channel.ts index 8354f02fc9d2..95c60ce45987 100644 --- a/code/builders/builder-vite/src/codegen-set-addon-channel.ts +++ b/code/builders/builder-vite/src/codegen-set-addon-channel.ts @@ -5,7 +5,6 @@ export async function generateAddonSetupCode() { const channel = createBrowserChannel({ page: 'preview' }); addons.setChannel(channel); - window.__STORYBOOK_ADDONS_CHANNEL__ = channel; if (window.CONFIG_TYPE === 'DEVELOPMENT'){ window.__STORYBOOK_SERVER_CHANNEL__ = channel; diff --git a/code/builders/builder-webpack5/templates/virtualModuleModernEntry.js b/code/builders/builder-webpack5/templates/virtualModuleModernEntry.js index c470929a6520..aa2b381898b6 100644 --- a/code/builders/builder-webpack5/templates/virtualModuleModernEntry.js +++ b/code/builders/builder-webpack5/templates/virtualModuleModernEntry.js @@ -30,7 +30,6 @@ const preview = new PreviewWeb(importFn, getProjectAnnotations); window.__STORYBOOK_PREVIEW__ = preview; window.__STORYBOOK_STORY_STORE__ = preview.storyStore; -window.__STORYBOOK_ADDONS_CHANNEL__ = channel; if (import.meta.webpackHot) { import.meta.webpackHot.addStatusHandler((status) => { diff --git a/code/core/build-config.ts b/code/core/build-config.ts index d5f29c96fa30..4f31aad36c01 100644 --- a/code/core/build-config.ts +++ b/code/core/build-config.ts @@ -190,6 +190,10 @@ const config: BuildEntries = { exportEntries: ['./internal/types'], entryPoint: './src/types/index.ts', }, + { + exportEntries: ['./open-service'], + entryPoint: './src/shared/open-service/index.ts', + }, ], runtime: [ { diff --git a/code/core/package.json b/code/core/package.json index d2bc9844d1dc..ecdef811ba5c 100644 --- a/code/core/package.json +++ b/code/core/package.json @@ -202,6 +202,11 @@ "code": "./src/manager-api/index.ts", "default": "./dist/manager-api/index.js" }, + "./open-service": { + "types": "./dist/shared/open-service/index.d.ts", + "code": "./src/shared/open-service/index.ts", + "default": "./dist/shared/open-service/index.js" + }, "./package.json": "./package.json", "./preview-api": { "types": "./dist/preview-api/index.d.ts", @@ -238,7 +243,6 @@ "!src/**/*" ], "dependencies": { - "@preact/signals-core": "^1.14.2", "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.2", "@testing-library/dom": "^10.4.1", @@ -247,7 +251,6 @@ "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", "@webcontainer/env": "^1.1.1", - "deepsignal": "^1.6.0", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", "open": "^10.2.0", "oxc-parser": "^0.127.0", @@ -275,6 +278,7 @@ "@happy-dom/global-registrator": "^20.0.11", "@ngard/tiny-isequal": "^1.1.0", "@polka/compression": "^1.0.0-next.28", + "@preact/signals-core": "^1.14.2", "@radix-ui/react-scroll-area": "1.2.0-rc.7", "@radix-ui/react-slot": "^1.0.2", "@react-aria/interactions": "^3.25.5", @@ -321,6 +325,7 @@ "copy-to-clipboard": "^3.3.1", "cross-spawn": "^7.0.6", "deep-object-diff": "^1.1.0", + "deepsignal": "^1.6.0", "dequal": "^2.0.2", "detect-indent": "^7.0.1", "detect-port": "^1.6.1", diff --git a/code/core/src/channels/README.md b/code/core/src/channels/README.md index 0076f33230bf..61a11baa9ff4 100644 --- a/code/core/src/channels/README.md +++ b/code/core/src/channels/README.md @@ -30,3 +30,27 @@ class Transport { ``` For more information visit: [storybook.js.org](https://storybook.js.org) + +## Channel access (internal) + +Storybook installs one shared addons channel per runtime (manager, preview iframe, dev server). +Use the channel-slot API from `storybook/internal/channels` in TypeScript — not direct reads of +`__STORYBOOK_ADDONS_CHANNEL__`. + +| Operation | API | +| --------- | --- | +| Read (nullable) | `getChannel()` | +| Read (installed) | `requireChannel()` — use when the runtime entry has already installed a channel | +| Install / replace | `setChannel(channel)` or `addons.setChannel(channel)` | +| Clear | `clearChannel()` / `setChannel(null)` | + +The global `__STORYBOOK_ADDONS_CHANNEL__` is mirrored when `setChannel` runs so builder preamble and +legacy snippets stay in sync. `getChannel()` reads the global slot first so duplicate bundles still +see the live channel. + +**Per-runtime install (call sites do not wait):** + +- **Preview iframe:** builders run generated `addons.setChannel(createBrowserChannel(...))` before `preview.ts` loads. +- **Manager:** `addons.setChannel` during manager boot, before `addons.register` callbacks. +- **Node server:** `services` preset calls `setChannel(options.channel)` before registering services; a noop channel is also bootstrapped at import in non-browser realms. +- **Tests:** `setChannel(mock)` in `beforeEach`, or rely on the Node import bootstrap noop. diff --git a/code/core/src/channels/channel-slot.test.ts b/code/core/src/channels/channel-slot.test.ts new file mode 100644 index 000000000000..031662511c42 --- /dev/null +++ b/code/core/src/channels/channel-slot.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { Channel } from './main.ts'; +import { + clearChannel, + ensureChannel, + getChannel, + installNoopChannel, + setChannel, +} from './channel-slot.ts'; + +describe('channel slot', () => { + afterEach(() => { + clearChannel(); + vi.unstubAllGlobals(); + }); + + it('returns null after clearChannel', () => { + setChannel(new Channel({})); + clearChannel(); + + expect(getChannel()).toBeNull(); + expect(globalThis.__STORYBOOK_ADDONS_CHANNEL__).toBeUndefined(); + }); + + it('mirrors setChannel to the global slot', () => { + const channel = new Channel({}); + + setChannel(channel); + + expect(getChannel()).toBe(channel); + expect(globalThis.__STORYBOOK_ADDONS_CHANNEL__).toBe(channel); + }); + + it('hydrates the module slot from a pre-existing global assignment', () => { + const channel = new Channel({}); + clearChannel(); + vi.stubGlobal('__STORYBOOK_ADDONS_CHANNEL__', channel); + + expect(getChannel()).toBe(channel); + }); + + it('prefers the global slot over a stale module-level noop', () => { + clearChannel(); + installNoopChannel(); + + const real = new Channel({ transport: { setHandler: vi.fn(), send: vi.fn() } }); + vi.stubGlobal('__STORYBOOK_ADDONS_CHANNEL__', real); + + expect(getChannel()).toBe(real); + }); + + it('installNoopChannel provides an in-process channel', () => { + clearChannel(); + installNoopChannel(); + + expect(getChannel()).toBeInstanceOf(Channel); + expect(getChannel()?.hasTransport).toBe(false); + }); + + it('ensureChannel is a no-op when a channel is already installed', () => { + const channel = new Channel({}); + setChannel(channel); + + ensureChannel(); + + expect(getChannel()).toBe(channel); + }); + + it('ensureChannel installs a noop channel when missing', () => { + clearChannel(); + + ensureChannel(); + + expect(getChannel()).toBeInstanceOf(Channel); + }); + + it('setChannel(null) clears both module and global slots', () => { + const channel = new Channel({}); + setChannel(channel); + + setChannel(null); + + expect(getChannel()).toBeNull(); + expect(globalThis.__STORYBOOK_ADDONS_CHANNEL__).toBeUndefined(); + }); + + it('replaces an existing channel on setChannel', () => { + const first = new Channel({}); + const second = new Channel({ transport: { setHandler: vi.fn(), send: vi.fn() } }); + + setChannel(first); + setChannel(second); + + expect(getChannel()).toBe(second); + expect(globalThis.__STORYBOOK_ADDONS_CHANNEL__).toBe(second); + }); +}); + +describe('module import', () => { + it('does not auto-install a channel in a browser-like environment', async () => { + vi.stubGlobal('window', {}); + vi.stubGlobal('document', {}); + vi.stubEnv('VITEST', ''); + + vi.resetModules(); + try { + const { getChannel } = await import('./channel-slot.ts'); + expect(getChannel()).toBeNull(); + } finally { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.resetModules(); + } + }); +}); diff --git a/code/core/src/channels/channel-slot.ts b/code/core/src/channels/channel-slot.ts new file mode 100644 index 000000000000..866b0e8da0c2 --- /dev/null +++ b/code/core/src/channels/channel-slot.ts @@ -0,0 +1,86 @@ +/// +/** + * Canonical install/read surface for Storybook's shared addons channel. + * + * Each runtime (manager, preview, dev server) installs one channel instance here. The module slot + * is the source of truth; {@link setChannel} also mirrors to `__STORYBOOK_ADDONS_CHANNEL__` + * so legacy code and builder preamble that read the global slot stay in sync. + */ + +import { Channel } from './main.ts'; +import type { ChannelLike } from './types.ts'; + +let channel: ChannelLike | undefined; + +function syncGlobalSlot(next: ChannelLike | undefined): void { + globalThis.__STORYBOOK_ADDONS_CHANNEL__ = next; +} + +/** + * Returns the installed addons channel, or `null` before one exists. + * + * The global slot wins over the module cache so duplicate copies of this module (e.g. dev-server + * preset code and `.storybook` service registration loading different bundles) still observe + * `setChannel` from whichever copy installed the live websocket channel. + */ +export function getChannel(): ChannelLike | null { + const fromGlobal = globalThis.__STORYBOOK_ADDONS_CHANNEL__ as ChannelLike | undefined; + if (fromGlobal) { + channel = fromGlobal; + } + + return channel ?? null; +} + +/** + * Returns the installed addons channel. + * + * Callers assume each runtime has installed a channel at its entry boundary (builder iframe setup, + * manager boot, server `services` preset, or Node module bootstrap). Prefer this over nullable + * `getChannel()` when the channel must exist. + */ +export function requireChannel(): ChannelLike { + const installed = getChannel(); + if (!installed) { + throw new Error( + 'Storybook addons channel is not installed in this runtime. Install it via setChannel() or addons.setChannel() at the runtime entry point before registering services or emitting events.' + ); + } + + return installed; +} + +/** Installs (or replaces) the shared addons channel. Pass `null` to clear. */ +export function setChannel(next: ChannelLike | null): void { + channel = next ?? undefined; + syncGlobalSlot(channel); +} + +/** Clears the shared channel slot. Alias for `setChannel(null)`. */ +export function clearChannel(): void { + setChannel(null); +} + +/** Installs a noop in-process channel — used by server presets and unit tests. */ +export function installNoopChannel(): void { + setChannel(new Channel({})); +} + +/** + * Installs a noop channel when none is present yet. + * + * Prefer explicit `setChannel` / `installNoopChannel` at runtime entry points. This helper remains for + * tests and tooling that need an in-process channel without a mock transport. + */ +export function ensureChannel(): void { + if (!getChannel()) { + installNoopChannel(); + } +} + +// Non-browser realms (Node server, Vitest without a DOM) bootstrap a noop channel at import so +// presets and tests can register services when no websocket transport exists (e.g. static build). +// Browser preview must not bootstrap here — builders install the real channel before preview config. +if (typeof window === 'undefined') { + ensureChannel(); +} diff --git a/code/core/src/channels/index.test.ts b/code/core/src/channels/index.test.ts index 22fab1c3906c..fd6ac555a0b3 100644 --- a/code/core/src/channels/index.test.ts +++ b/code/core/src/channels/index.test.ts @@ -30,12 +30,7 @@ const MockedWebsocket = vi.hoisted(() => { return { MyMockedWebsocket, ref }; }); -vi.mock('@storybook/global', () => ({ - global: { - ...global, - WebSocket: MockedWebsocket.MyMockedWebsocket, - }, -})); +vi.stubGlobal('WebSocket', MockedWebsocket.MyMockedWebsocket); describe('Channel', () => { let transport: ChannelTransport; @@ -148,7 +143,7 @@ describe('Channel', () => { }); it('should use setImmediate if async is true', () => { - global.setImmediate = vi.fn(setImmediate); + globalThis.setImmediate = vi.fn(setImmediate); channel = new Channel({ async: true, transport }); channel.addListener('event1', vi.fn()); diff --git a/code/core/src/channels/index.ts b/code/core/src/channels/index.ts index b4a32cd30047..4bb97dd65ffa 100644 --- a/code/core/src/channels/index.ts +++ b/code/core/src/channels/index.ts @@ -1,5 +1,4 @@ /// -import { global } from '@storybook/global'; import { UniversalStore } from '../shared/universal-store/index.ts'; import { Channel } from './main.ts'; @@ -7,9 +6,15 @@ import { PostMessageTransport } from './postmessage/index.ts'; import type { ChannelTransport, Config } from './types.ts'; import { WebsocketTransport } from './websocket/index.ts'; -const { CHANNEL_OPTIONS, CONFIG_TYPE } = global; - export * from './main.ts'; +export { + clearChannel, + ensureChannel, + getChannel, + installNoopChannel, + requireChannel, + setChannel, +} from './channel-slot.ts'; export default Channel; @@ -36,10 +41,10 @@ type Options = Config & { export function createBrowserChannel({ page, extraTransports = [] }: Options): Channel { const transports: ChannelTransport[] = [new PostMessageTransport({ page }), ...extraTransports]; - if (CONFIG_TYPE === 'DEVELOPMENT') { + if (globalThis.CONFIG_TYPE === 'DEVELOPMENT') { const protocol = window.location.protocol === 'http:' ? 'ws' : 'wss'; const { hostname, port } = window.location; - const { wsToken } = CHANNEL_OPTIONS || {}; + const { wsToken } = globalThis.CHANNEL_OPTIONS || {}; const channelUrl = `${protocol}://${hostname}:${port}/storybook-server-channel?token=${wsToken}`; transports.push(new WebsocketTransport({ url: channelUrl, onError: () => {}, page })); diff --git a/code/core/src/channels/mock-channel.ts b/code/core/src/channels/mock-channel.ts new file mode 100644 index 000000000000..b787bdb9e188 --- /dev/null +++ b/code/core/src/channels/mock-channel.ts @@ -0,0 +1,8 @@ +import { Channel } from './main.ts'; + +/** In-process channel with no transport — the default for unit tests and manager story mocks. */ +export function mockChannel(): Channel { + return new Channel({ + transport: { setHandler: () => {}, send: () => {} }, + }); +} diff --git a/code/core/src/channels/postmessage/index.ts b/code/core/src/channels/postmessage/index.ts index 3a8b4d8feda0..1ad6a86ca9b9 100644 --- a/code/core/src/channels/postmessage/index.ts +++ b/code/core/src/channels/postmessage/index.ts @@ -2,8 +2,6 @@ import { logger, pretty } from 'storybook/internal/client-logger'; import * as EVENTS from 'storybook/internal/core-events'; -import { global } from '@storybook/global'; - import { isJSON, parse, stringify } from 'telejson'; import invariant from 'tiny-invariant'; @@ -16,10 +14,12 @@ import type { } from '../types.ts'; import { getEventSourceUrl } from './getEventSourceUrl.ts'; -const { document, location } = global; +const { document, location } = globalThis; export const KEY = 'storybook-channel'; +const CHANNEL_OPTIONS = globalThis.CHANNEL_OPTIONS || {}; + const defaultEventOptions = { maxDepth: 25 }; // TODO: we should export a method for opening child windows here and keep track of em. @@ -36,8 +36,8 @@ export class PostMessageTransport implements ChannelTransport { constructor(private readonly config: Config) { this.buffer = []; - if (typeof global?.addEventListener === 'function') { - global.addEventListener('message', this.handleEvent.bind(this), false); + if (typeof globalThis.addEventListener === 'function') { + globalThis.addEventListener('message', this.handleEvent.bind(this), false); } // Check whether the config.page parameter has a valid value @@ -91,7 +91,7 @@ export class PostMessageTransport implements ChannelTransport { const stringifyOptions = { ...defaultEventOptions, - ...(global.CHANNEL_OPTIONS || {}), + ...CHANNEL_OPTIONS, ...eventOptions, }; @@ -156,8 +156,8 @@ export class PostMessageTransport implements ChannelTransport { return list?.length ? list : this.getCurrentFrames(); } - if (global && global.parent && global.parent !== global.self) { - return [global.parent]; + if (globalThis.parent && globalThis.parent !== globalThis.self) { + return [globalThis.parent]; } return []; @@ -170,8 +170,8 @@ export class PostMessageTransport implements ChannelTransport { ); return list.flatMap((e) => (e.contentWindow ? [e.contentWindow] : [])); } - if (global && global.parent) { - return [global.parent]; + if (globalThis.parent) { + return [globalThis.parent]; } return []; @@ -184,8 +184,8 @@ export class PostMessageTransport implements ChannelTransport { ); return list.flatMap((e) => (e.contentWindow ? [e.contentWindow] : [])); } - if (global && global.parent) { - return [global.parent]; + if (globalThis.parent) { + return [globalThis.parent]; } return []; @@ -195,7 +195,7 @@ export class PostMessageTransport implements ChannelTransport { try { const { data } = rawEvent; const { key, event, refId } = - typeof data === 'string' && isJSON(data) ? parse(data, global.CHANNEL_OPTIONS || {}) : data; + typeof data === 'string' && isJSON(data) ? parse(data, CHANNEL_OPTIONS) : data; if (key === KEY) { const pageString = @@ -230,6 +230,14 @@ export class PostMessageTransport implements ChannelTransport { ); invariant(this.handler, 'ChannelHandler should be set'); + + // Preview channel bootstraps after the iframe document loads. When the preview sends its + // first postMessage (e.g. open-service sync-start), flush any outbound events that + // were buffered while the iframe was not yet a postMessage target. + if (this.config.page === 'manager' && this.buffer.length) { + this.flush(); + } + this.handler(event); } } catch (error) { diff --git a/code/core/src/channels/test-channel.ts b/code/core/src/channels/test-channel.ts new file mode 100644 index 000000000000..277228869e83 --- /dev/null +++ b/code/core/src/channels/test-channel.ts @@ -0,0 +1,51 @@ +import { vi } from 'vitest'; +import type { Mock } from 'vitest'; + +import { clearChannel, setChannel } from './channel-slot.ts'; +import type { Channel } from './main.ts'; +import type { ChannelEvent } from './types.ts'; +import { mockChannel } from './mock-channel.ts'; + +export type TestChannel = Channel & { + /** + * Deliver an event to current listeners as if from an external peer, without going through the + * spied `emit` (so `emit.mock.calls` only reflects this runtime's own broadcasts). + */ + emitExternal(eventName: string, ...args: unknown[]): void; +}; + +export type SpiedTestChannel = TestChannel & { + on: Mock; + off: Mock; + emit: Mock; +}; + +/** + * {@link mockChannel} plus spied `on` / `off` / `emit` and {@link TestChannel.emitExternal} for tests + * that assert on channel wiring while simulating peer traffic. + */ +export function createTestChannel(): SpiedTestChannel { + const channel = mockChannel(); + const on = vi.spyOn(channel, 'on'); + const off = vi.spyOn(channel, 'off'); + const emit = vi.spyOn(channel, 'emit'); + + const emitExternal = (eventName: string, ...args: unknown[]) => { + const event: ChannelEvent = { type: eventName, from: '__test_external__', args }; + const listeners = channel.listeners(eventName); + + if (listeners) { + listeners.forEach((listener) => listener.apply(event, args)); + } + }; + + return Object.assign(channel, { on, off, emit, emitExternal }) as SpiedTestChannel; +} + +export function installTestChannel(channel: SpiedTestChannel | null): void { + if (channel === null) { + clearChannel(); + } else { + setChannel(channel); + } +} diff --git a/code/core/src/channels/websocket/index.ts b/code/core/src/channels/websocket/index.ts index 0c9a6ba5827c..8ed1f0110dc5 100644 --- a/code/core/src/channels/websocket/index.ts +++ b/code/core/src/channels/websocket/index.ts @@ -1,15 +1,11 @@ /// import * as EVENTS from 'storybook/internal/core-events'; -import { global } from '@storybook/global'; - import { isJSON, parse, stringify } from 'telejson'; import invariant from 'tiny-invariant'; import type { ChannelHandler, ChannelTransport, Config } from '../types.ts'; -const { WebSocket } = global; - type OnError = (message: Event) => void; interface WebsocketTransportArgs extends Partial { @@ -20,6 +16,8 @@ interface WebsocketTransportArgs extends Partial { export const HEARTBEAT_INTERVAL = 15000; export const HEARTBEAT_MAX_LATENCY = 5000; +const CHANNEL_OPTIONS = globalThis.CHANNEL_OPTIONS || {}; + export class WebsocketTransport implements ChannelTransport { private buffer: string[] = []; @@ -42,6 +40,7 @@ export class WebsocketTransport implements ChannelTransport { } constructor({ url, onError, page }: WebsocketTransportArgs) { + // eslint-disable-next-line compat/compat this.socket = new WebSocket(url); this.socket.onopen = () => { this.isReady = true; @@ -95,7 +94,7 @@ export class WebsocketTransport implements ChannelTransport { private sendNow(event: any) { const data = stringify(event, { maxDepth: 15, - ...global.CHANNEL_OPTIONS, + ...CHANNEL_OPTIONS, }); this.socket.send(data); } diff --git a/code/core/src/common/index.ts b/code/core/src/common/index.ts index 22767cc4da4a..482406e899f5 100644 --- a/code/core/src/common/index.ts +++ b/code/core/src/common/index.ts @@ -55,3 +55,4 @@ export * from './node-version.ts'; export { versions }; export { createFileSystemCache, FileSystemCache } from './utils/file-cache.ts'; +export { registerService } from '../shared/open-service/server.ts'; diff --git a/code/core/src/core-server/build-dev.ts b/code/core/src/core-server/build-dev.ts index cb4e3b589b76..860f92c310df 100644 --- a/code/core/src/core-server/build-dev.ts +++ b/code/core/src/core-server/build-dev.ts @@ -295,6 +295,7 @@ export async function buildDevStandalone( const features = await presets.apply('features'); global.FEATURES = features; + await applyServicesPresetOnce(presets); await presets.apply('experimental_serverChannel', channel); diff --git a/code/core/src/core-server/load.ts b/code/core/src/core-server/load.ts index 2cea939d4073..af67320ce15b 100644 --- a/code/core/src/core-server/load.ts +++ b/code/core/src/core-server/load.ts @@ -1,4 +1,4 @@ -import { Channel } from 'storybook/internal/channels'; +import { Channel, setChannel } from 'storybook/internal/channels'; import { getProjectRoot, loadAllPresets, @@ -32,6 +32,10 @@ export async function loadStorybook( options.configDir = configDir; options.cacheKey = cacheKey; + // no-op channel, as it's only relevant in dev mode + const channel = new Channel({}); + setChannel(channel); + const config = await loadMainConfig(options); const { framework } = config; const corePresets = []; @@ -49,10 +53,6 @@ export async function loadStorybook( // Load first pass: We need to determine the builder // We need to do this because builders might introduce 'overridePresets' which we need to take into account // We hope to remove this in SB8 - - // no-op channel, as it's only relevant in dev mode - const channel = new Channel({}); - let presets = await loadAllPresets({ corePresets, overridePresets: [ diff --git a/code/core/src/core-server/utils/get-server-channel.ts b/code/core/src/core-server/utils/get-server-channel.ts index 2f1ca4a4ddb0..b3e5192504fe 100644 --- a/code/core/src/core-server/utils/get-server-channel.ts +++ b/code/core/src/core-server/utils/get-server-channel.ts @@ -1,7 +1,7 @@ import type { IncomingMessage } from 'node:http'; import type { ChannelHandler } from 'storybook/internal/channels'; -import { Channel, HEARTBEAT_INTERVAL } from 'storybook/internal/channels'; +import { Channel, HEARTBEAT_INTERVAL, setChannel } from 'storybook/internal/channels'; import { isJSON, parse, stringify } from 'telejson'; import WebSocket, { WebSocketServer } from 'ws'; @@ -105,6 +105,8 @@ export function getServerChannel(server: Server, options: ServerChannelTransport const channel = new Channel({ transports, async: true }); + setChannel(channel); + UniversalStore.__prepare(channel, UniversalStore.Environment.SERVER); return channel; diff --git a/code/core/src/manager-api/index.mock.ts b/code/core/src/manager-api/index.mock.ts index 0758862d3706..c91c66eb0b2b 100644 --- a/code/core/src/manager-api/index.mock.ts +++ b/code/core/src/manager-api/index.mock.ts @@ -21,3 +21,10 @@ export { fullTestProviderStore as internal_fullTestProviderStore, universalTestProviderStore as internal_universalTestProviderStore, } from './stores/__mocks__/test-provider.ts'; + +/** Real open-service surface — not mocked; used by the internal sync-test demo. */ +export { + registerService, + useServiceCommand, + useServiceQuery, +} from '../shared/open-service/manager.ts'; diff --git a/code/core/src/manager-api/index.ts b/code/core/src/manager-api/index.ts index 14d161deb3e0..9b29151e0049 100644 --- a/code/core/src/manager-api/index.ts +++ b/code/core/src/manager-api/index.ts @@ -24,3 +24,10 @@ export { } from './stores/checklist.ts'; export { Tag } from '../shared/constants/tags.ts'; + +/** OPEN SERVICE API (manager relay hub + React hooks; types on `storybook/open-service`) */ +export { + registerService, + useServiceCommand, + useServiceQuery, +} from '../shared/open-service/manager.ts'; diff --git a/code/core/src/manager-api/lib/addons.ts b/code/core/src/manager-api/lib/addons.ts index 6c3cb7bc851c..5266275b9334 100644 --- a/code/core/src/manager-api/lib/addons.ts +++ b/code/core/src/manager-api/lib/addons.ts @@ -16,10 +16,12 @@ import type { } from 'storybook/internal/types'; import { Addon_TypesEnum } from 'storybook/internal/types'; -import { global } from '@storybook/global'; - +import { + getChannel as readInstalledChannel, + setChannel as installStorybookChannel, +} from '../../channels/channel-slot.ts'; +import { mockChannel } from '../../channels/mock-channel.ts'; import type { API } from '../root.tsx'; -import { mockChannel } from './storybook-channel-mock.ts'; export type { Addon_Type as Addon }; export { Addon_TypesEnum as types }; @@ -48,9 +50,15 @@ export class AddonStore { private resolve: any; getChannel = (): Channel => { - // this.channel should get overwritten by setChannel. If it wasn't called (e.g. in non-browser environment), set a mock instead. if (!this.channel) { - this.setChannel(mockChannel()); + const installed = readInstalledChannel(); + if (installed) { + this.channel = installed as Channel; + this.resolve(); + return this.channel; + } + + this.setChannel(mockChannel() as unknown as Channel); } return this.channel!; @@ -58,10 +66,11 @@ export class AddonStore { ready = (): Promise => this.promise; - hasChannel = (): boolean => !!this.channel; + hasChannel = (): boolean => !!this.channel || !!readInstalledChannel(); setChannel = (channel: Channel): void => { this.channel = channel; + installStorybookChannel(channel); this.resolve(); }; @@ -142,10 +151,10 @@ export class AddonStore { const KEY = '__STORYBOOK_ADDONS_MANAGER'; function getAddonsStore(): AddonStore { - if (!global[KEY]) { - global[KEY] = new AddonStore(); + if (!globalThis[KEY]) { + globalThis[KEY] = new AddonStore(); } - return global[KEY]; + return globalThis[KEY]; } export const addons = getAddonsStore(); diff --git a/code/core/src/manager-api/lib/storybook-channel-mock.ts b/code/core/src/manager-api/lib/storybook-channel-mock.ts deleted file mode 100644 index 23fce7c5ff8a..000000000000 --- a/code/core/src/manager-api/lib/storybook-channel-mock.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Channel } from 'storybook/internal/channels'; - -export function mockChannel() { - const transport = { - setHandler: () => {}, - send: () => {}, - }; - - return new Channel({ transport }); -} diff --git a/code/core/src/manager-api/modules/provider.ts b/code/core/src/manager-api/modules/provider.ts index bac3db966708..94bda7dd4447 100644 --- a/code/core/src/manager-api/modules/provider.ts +++ b/code/core/src/manager-api/modules/provider.ts @@ -6,12 +6,9 @@ export interface SubAPI { renderPreview?: API_IframeRenderer; } -export const init: ModuleFn = ({ provider, fullAPI }) => { +export const init: ModuleFn = ({ provider }) => { return { api: provider.renderPreview ? { renderPreview: provider.renderPreview } : {}, state: {}, - init: () => { - provider.handleAPI(fullAPI); - }, }; }; diff --git a/code/core/src/manager-api/root.tsx b/code/core/src/manager-api/root.tsx index 06aa5207a985..cf510420a4c2 100644 --- a/code/core/src/manager-api/root.tsx +++ b/code/core/src/manager-api/root.tsx @@ -194,6 +194,10 @@ class ManagerProvider extends Component { this.state = state; this.api = api; + + // Run addon register callbacks before the first render mounts the preview iframe, so manager-side + // listeners (e.g. open-service) exist before preview JS can emit sync-start. + props.provider.handleAPI(this.api); } static getDerivedStateFromProps(props: ManagerProviderProps, state: State): State { diff --git a/code/core/src/manager/globals-runtime.ts b/code/core/src/manager/globals-runtime.ts index b918fc838fc2..00a50f031d58 100644 --- a/code/core/src/manager/globals-runtime.ts +++ b/code/core/src/manager/globals-runtime.ts @@ -1,6 +1,6 @@ /// +import { getChannel } from 'storybook/internal/channels'; import { TELEMETRY_ERROR } from 'storybook/internal/core-events'; - import { globalPackages, globalsNameReferenceMap } from './globals/globals.ts'; import { globalsNameValueMap } from './globals/runtime.ts'; import { prepareForTelemetry, shouldSkipError } from './utils/prepareForTelemetry.ts'; @@ -17,7 +17,7 @@ globalThis.sendTelemetryError = (error) => { return; } - const channel = globalThis.__STORYBOOK_ADDONS_CHANNEL__; + const channel = getChannel(); const preparedError = prepareForTelemetry(error); if (!channel) { diff --git a/code/core/src/manager/globals/exports.ts b/code/core/src/manager/globals/exports.ts index 1836eedcf4e2..abc09be167c7 100644 --- a/code/core/src/manager/globals/exports.ts +++ b/code/core/src/manager/globals/exports.ts @@ -347,6 +347,7 @@ export default { 'merge', 'mockChannel', 'optionOrAltSymbol', + 'registerService', 'shortcutMatchesShortcut', 'shortcutToAriaKeyshortcuts', 'shortcutToHumanString', @@ -358,6 +359,8 @@ export default { 'useGlobalTypes', 'useGlobals', 'useParameter', + 'useServiceCommand', + 'useServiceQuery', 'useSharedState', 'useStoryPrepared', 'useStorybookApi', @@ -486,7 +489,13 @@ export default { 'HEARTBEAT_MAX_LATENCY', 'PostMessageTransport', 'WebsocketTransport', + 'clearChannel', 'createBrowserChannel', + 'ensureChannel', + 'getChannel', + 'installNoopChannel', + 'requireChannel', + 'setChannel', ], 'storybook/internal/client-logger': ['deprecate', 'logger', 'once', 'pretty'], 'storybook/internal/components': [ diff --git a/code/core/src/manager/index.stories.tsx b/code/core/src/manager/index.stories.tsx index a62c064dc247..a5493ad4d56b 100644 --- a/code/core/src/manager/index.stories.tsx +++ b/code/core/src/manager/index.stories.tsx @@ -8,6 +8,8 @@ import { global } from '@storybook/global'; import { FailedIcon } from '@storybook/icons'; import { HelmetProvider } from 'react-helmet-async'; +import { getChannel, setChannel } from 'storybook/internal/channels'; + import type { API, AddonStore } from 'storybook/manager-api'; import { addons, mockChannel } from 'storybook/manager-api'; import { screen, within } from 'storybook/test'; @@ -18,7 +20,9 @@ import { Main } from './index.tsx'; import Provider from './provider.ts'; const WS_DISCONNECTED_NOTIFICATION_ID = 'CORE/WS_DISCONNECTED'; +const MOCK_STORY_PATH = '/?path=/story/example-button--primary'; +const originalChannel = getChannel(); const channel = mockChannel() as unknown as Channel; const originalGetItem = Storage.prototype.getItem; @@ -50,12 +54,8 @@ class ReactProvider extends Provider { constructor() { super(); - addons.setChannel(channel); - channel.emit(CHANNEL_CREATED); - this.addons = addons; this.channel = channel; - global.__STORYBOOK_ADDONS_CHANNEL__ = channel; } getElements(type: Addon_Types) { @@ -111,11 +111,19 @@ const meta = preview.meta({ beforeEach: () => { global.PREVIEW_URL = 'about:blank'; + addons.setChannel(channel); + channel.emit(CHANNEL_CREATED); + Storage.prototype.getItem = () => null; Storage.prototype.setItem = () => {}; Storage.prototype.clear = () => {}; }, afterEach: () => { + if (originalChannel) { + setChannel(originalChannel); + addons.setChannel(originalChannel as Channel); + } + Storage.prototype.getItem = originalGetItem; Storage.prototype.setItem = originalSetItem; Storage.prototype.clear = originalClear; @@ -123,7 +131,7 @@ const meta = preview.meta({ decorators: [ (Story) => ( - + diff --git a/code/core/src/manager/runtime.tsx b/code/core/src/manager/runtime.tsx index 0fcb48bc4246..655279c9ef31 100644 --- a/code/core/src/manager/runtime.tsx +++ b/code/core/src/manager/runtime.tsx @@ -47,7 +47,6 @@ class ReactProvider extends Provider { this.addons = addons; this.channel = channel; - global.__STORYBOOK_ADDONS_CHANNEL__ = channel; } getElements(type: Addon_Types) { diff --git a/code/core/src/preview-api/index.ts b/code/core/src/preview-api/index.ts index d93172548b2d..96a8a3bd102d 100644 --- a/code/core/src/preview-api/index.ts +++ b/code/core/src/preview-api/index.ts @@ -87,3 +87,13 @@ export { waitForAnimations, } from './preview-web.ts'; export type { SelectionStore, View } from './preview-web.ts'; + +/** OPEN SERVICE API (preview leaf — register only; types on `storybook/open-service`) */ +export { registerService } from '../shared/open-service/preview.ts'; +export { + clearChannel, + ensureChannel, + getChannel, + installNoopChannel, + setChannel, +} from '../channels/channel-slot.ts'; diff --git a/code/core/src/preview-api/modules/addons/index.ts b/code/core/src/preview-api/modules/addons/index.ts index d25665252b86..1df361260e64 100644 --- a/code/core/src/preview-api/modules/addons/index.ts +++ b/code/core/src/preview-api/modules/addons/index.ts @@ -1,4 +1,4 @@ export * from './main.ts'; export * from './hooks.ts'; export * from './make-decorator.ts'; -export * from './storybook-channel-mock.ts'; +export { mockChannel } from '../../../channels/mock-channel.ts'; diff --git a/code/core/src/preview-api/modules/addons/main.ts b/code/core/src/preview-api/modules/addons/main.ts index 9a263cc44533..b4b16fe31e80 100644 --- a/code/core/src/preview-api/modules/addons/main.ts +++ b/code/core/src/preview-api/modules/addons/main.ts @@ -1,8 +1,9 @@ import type { Channel } from 'storybook/internal/channels'; - -import { global } from '@storybook/global'; - -import { mockChannel } from './storybook-channel-mock.ts'; +import { + getChannel as readInstalledChannel, + setChannel as installStorybookChannel, +} from 'storybook/internal/channels'; +import { mockChannel } from '../../../channels/mock-channel.ts'; export class AddonStore { constructor() { @@ -18,9 +19,15 @@ export class AddonStore { private resolve: any; getChannel = (): Channel => { - // this.channel should get overwritten by setChannel. If it wasn't called (e.g. in non-browser environment), set a mock instead. if (!this.channel) { - const channel = mockChannel(); + const installed = readInstalledChannel(); + if (installed) { + this.channel = installed as Channel; + this.resolve(); + return this.channel; + } + + const channel = mockChannel() as unknown as Channel; this.setChannel(channel); return channel; } @@ -30,10 +37,11 @@ export class AddonStore { ready = (): Promise => this.promise; - hasChannel = (): boolean => !!this.channel; + hasChannel = (): boolean => !!this.channel || !!readInstalledChannel(); setChannel = (channel: Channel): void => { this.channel = channel; + installStorybookChannel(channel); this.resolve(); }; } @@ -42,10 +50,10 @@ export class AddonStore { const KEY = '__STORYBOOK_ADDONS_PREVIEW'; function getAddonsStore(): AddonStore { - if (!global[KEY]) { - global[KEY] = new AddonStore(); + if (!globalThis[KEY]) { + globalThis[KEY] = new AddonStore(); } - return global[KEY]; + return globalThis[KEY]; } export const addons = getAddonsStore(); diff --git a/code/core/src/preview-api/modules/addons/storybook-channel-mock.ts b/code/core/src/preview-api/modules/addons/storybook-channel-mock.ts deleted file mode 100644 index 23fce7c5ff8a..000000000000 --- a/code/core/src/preview-api/modules/addons/storybook-channel-mock.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Channel } from 'storybook/internal/channels'; - -export function mockChannel() { - const transport = { - setHandler: () => {}, - send: () => {}, - }; - - return new Channel({ transport }); -} diff --git a/code/core/src/preview/runtime.ts b/code/core/src/preview/runtime.ts index ac6dc9dfd5e7..a38f92d5d590 100644 --- a/code/core/src/preview/runtime.ts +++ b/code/core/src/preview/runtime.ts @@ -1,7 +1,7 @@ +import { getChannel } from 'storybook/internal/channels'; import { MANAGER_INERT_ATTRIBUTE_CHANGED, TELEMETRY_ERROR } from 'storybook/internal/core-events'; import { global } from '@storybook/global'; - import { globalPackages, globalsNameReferenceMap } from './globals/globals.ts'; import { globalsNameValueMap } from './globals/runtime.ts'; import { maybeSetupPreviewNavigator } from './preview-navigator.ts'; @@ -27,7 +27,11 @@ export function setup() { }); global.sendTelemetryError = (error: any) => { - const channel = global.__STORYBOOK_ADDONS_CHANNEL__; + const channel = getChannel(); + if (!channel) { + return; + } + channel.emit(TELEMETRY_ERROR, prepareForTelemetry(error)); }; @@ -39,7 +43,11 @@ export function setup() { * could reach a deadlock state and be unusable. */ document.addEventListener('DOMContentLoaded', () => { - const channel = global.__STORYBOOK_ADDONS_CHANNEL__; + const channel = getChannel(); + if (!channel) { + return; + } + channel.on(MANAGER_INERT_ATTRIBUTE_CHANGED, (isInert: boolean) => { if (isInert) { document.body.setAttribute('inert', 'true'); diff --git a/code/core/src/server-errors.ts b/code/core/src/server-errors.ts index 27fd9cfd5f59..d4a25bae08ac 100644 --- a/code/core/src/server-errors.ts +++ b/code/core/src/server-errors.ts @@ -252,6 +252,30 @@ export class OpenServiceDocgenMissingComponentError extends StorybookError { } } +export class OpenServiceMissingChannelError extends StorybookError { + constructor(public data: { serviceId?: ServiceId } = {}) { + super({ + name: 'OpenServiceMissingChannelError', + category: Category.CORE_COMMON, + code: 13, + message: data.serviceId + ? `Cannot register service "${data.serviceId}": the Storybook addons channel is not installed in this runtime.` + : 'The Storybook addons channel is not installed in this runtime.', + }); + } +} + +export class OpenServiceRemoteCommandDisconnectedError extends StorybookError { + constructor(public data: { serviceId: ServiceId }) { + super({ + name: 'OpenServiceRemoteCommandDisconnectedError', + category: Category.CORE_COMMON, + code: 14, + message: `Service "${data.serviceId}" was unregistered before a remote command resolved.`, + }); + } +} + export class WebpackMissingStatsError extends StorybookError { constructor() { super({ diff --git a/code/core/src/shared/open-service/README.md b/code/core/src/shared/open-service/README.md index 5b00cc64ff10..a03490b285dc 100644 --- a/code/core/src/shared/open-service/README.md +++ b/code/core/src/shared/open-service/README.md @@ -16,10 +16,17 @@ fit together, where behavior lives, and how to define new services correctly. ## Public Surface -External callers should import from one of two entrypoints: +External callers import from one of these entrypoints: -- [index.ts](./index.ts) for environment-agnostic definition helpers and shared types -- [server.ts](./server.ts) for server-only registration, discovery, and static snapshot writing +- `storybook/open-service` for environment-agnostic service definitions — `defineService` and shared types (no React, no registration) +- `storybook/manager-api` for manager addons — `registerService` (relay hub), `useServiceQuery`, `useServiceCommand`, and shared types +- `storybook/preview-api` for preview code — `registerService` (leaf) and shared types (no React hooks) +- [server.ts](./server.ts) for server-side registration, discovery, and static snapshot writing + +`registerService` is the **single** registration function across every runtime. It lives in +[service-registry.ts](./service-registry.ts) and is re-exported from each entrypoint with the right +`relay` default — server and manager are relay hubs, the preview is a leaf. There is no separate +client/server registration API. The environment-agnostic API consists of: @@ -36,20 +43,37 @@ The server-only API consists of: - `buildStaticFiles` - `writeOpenServiceStaticFiles` +The browser API consists of: + +- `registerService` — creates a local runtime and joins the channel sync protocol +- `unregisterService` — tears down one service's channel listeners and removes it from the registry +- `clearRegistry` — removes all registrations (use in `afterEach` in tests) +- `useServiceQuery` — React hook backed by `useSyncExternalStore` (manager entrypoint) +- `useServiceCommand` — React hook returning a stable command reference (manager entrypoint) + Internal tests and implementation code may import from the individual modules directly. ## File Layout - [index.ts](./index.ts): environment-agnostic barrel for definition helpers and shared types -- [server.ts](./server.ts): server-only entrypoint that re-exports registration APIs and owns static snapshot building/writing +- [server.ts](./server.ts): server entrypoint that re-exports registration APIs (relay hub) and owns static snapshot building/writing +- [manager.ts](./manager.ts): manager entrypoint (`relay: true`) re-exported via `storybook/manager-api`; adds `useServiceQuery` and `useServiceCommand` +- [preview.ts](./preview.ts): preview entrypoint (`relay: false`, leaf) re-exported via `storybook/preview-api`; registration only, no React hooks - [types.ts](./types.ts): core type model for definitions, contexts, runtime instances, and static build data - [service-definition.ts](./service-definition.ts): `defineService()` typing that preserves inline inference when declaring services - [service-validation.ts](./service-validation.ts): sync + async schema validation helpers and error wrapping - [errors.ts](./errors.ts): validation metadata formatting helpers - [service-runtime.ts](./service-runtime.ts): signal-backed runtime construction, in-flight load registry, drain logic, and subscriptions -- [service-registration.ts](./service-registration.ts): server-side global registry implementation and the shared registry API passed into runtimes +- [service-registry.ts](./service-registry.ts): the single `registerService`, the realm-global registry, and the shared registry API passed into runtimes — used identically by server, manager, and preview +- [service-channel.ts](./service-channel.ts): `ServiceChannel` interface, event name constants, and payload types +- [service-error-serialization.ts](./service-error-serialization.ts): transport-safe (de)serialization of thrown errors and their `cause` chains, used by remote command replies +- [channel-slot.ts](../../channels/channel-slot.ts): `getChannel` / `setChannel` — the shared channel install surface +- [service-transport.ts](./service-transport.ts): shared channel transport — wraps commands to broadcast, wires the sync-start initialization + patch listeners (hub or leaf), and runs the remote-command-execution protocol +- [service-sync.ts](./service-sync.ts): last-write-wins ordering, the `deepReconcile` structural merge, and the per-service snapshot reconciler +- [use-service-query.ts](./use-service-query.ts): `useServiceQuery` React hook backed by `useSyncExternalStore` +- [use-service-command.ts](./use-service-command.ts): `useServiceCommand` React hook returning a stable command reference - [fixtures.ts](./fixtures.ts): scenario fixtures used by the test suite -- `*.test.ts`: focused tests for runtime behavior, validation behavior, server registration, and server static builds +- `*.test.ts` / `*.test.tsx`: focused tests for runtime behavior, validation, registration, static builds, channel sync, and React hooks ## Core Concepts @@ -58,7 +82,7 @@ Internal tests and implementation code may import from the individual modules di A service is a state container with: - a stable `id` -- an `initialState` +- an `initialState` — **must be a plain object** (see [State must be an object](#state-must-be-an-object)) - a `queries` map - a `commands` map - optional descriptions on the service and each operation @@ -122,6 +146,12 @@ A command is: Commands receive a `CommandCtx` whose `self` includes `state`, `queries`, `commands`, and `setState`. +A command handler may be implemented in only **some** runtimes — most often a handler supplied at +server registration that needs Node APIs or server context. A runtime that lacks a local handler does +not throw when the command is called; it requests **remote command execution** from a peer that does +implement it (see [Remote Command Execution](#remote-command-execution)). Queries stay local-only and +still throw `OpenServiceUnimplementedOperationError` when no handler exists. + ### Cross-service composition Handlers resolve other registered services through `ctx.getService(serviceId)`. Without a type @@ -196,8 +226,9 @@ That split is intentional: - [index.ts](./index.ts) stays environment-agnostic so preview, manager, and server code can share one definition surface -- [server.ts](./server.ts) owns the concrete registry and static snapshot writing for the current - server process +- [server.ts](./server.ts) exposes server-side registration (a relay hub) and owns static snapshot + writing for the current server process; the registry itself lives in + [service-registry.ts](./service-registry.ts), shared with the browser entrypoints `registerService(definition)` throws `OpenServiceDuplicateRegistrationError` if a service with the same id is already registered. The default `services` preset hook in @@ -211,15 +242,15 @@ and `yarn storybook:ui:build` runs do not register the debug service. ## Runtime Flow -When a server registers a service definition: +When any runtime registers a service definition: -1. [service-registration.ts](./service-registration.ts) merges any registration-time handler overrides. +1. [service-registry.ts](./service-registry.ts) merges any registration-time `staticInputs` overrides for queries and handler overrides for commands. 2. It passes the shared registry API into [service-runtime.ts](./service-runtime.ts). 3. [service-runtime.ts](./service-runtime.ts) creates a signal-backed state container from `initialState`. 4. It builds a writable `commandSelf` reference around that state. 5. It builds commands that validate input, run handlers, and validate output. 6. It builds queries that validate input synchronously, fire any pending `load` in the background (deduped while in flight), run the handler synchronously, and validate the output. -7. [service-registration.ts](./service-registration.ts) stores the resulting runtime behind the server registry entry for later lookup. +7. [service-registry.ts](./service-registry.ts) wraps the commands to broadcast post-mutation snapshots, joins the channel sync protocol when a channel is present (as a hub or leaf), and stores the resulting instance behind the registry entry for later lookup. ## In-flight Load Registry @@ -267,6 +298,33 @@ Cross-service `ctx.getService(id).queries.*` calls inside a load body are **not* ## State and reactivity +### State must be an object + +A service's `initialState` (and therefore its whole state) **must be a plain object**. Primitives, +`null`, `undefined`, and arrays are rejected at the `defineService` authoring boundary by the +`ServiceState` type (see [types.ts](./types.ts)). This is enforced for two structural reasons, not as +an arbitrary style choice: + +- **Reactivity.** State is wrapped in a `deepSignal` proxy for fine-grained per-field tracking, and + `deepSignal` throws (`"this object can't be observed"`) on scalars, `null`, and `undefined` — there + are no fields to track on a scalar. +- **Sync.** Cross-peer reconciliation (`deepReconcile` in [service-sync.ts](./service-sync.ts)) merges + state by walking object keys; it has no concept of replacing a whole scalar. + +Arrays are a special case: `deepSignal` *can* observe them, but `deepReconcile` replaces arrays +wholesale rather than merging by key, so a **top-level** array state would silently fail to sync +between peers. They are therefore rejected too. Wrap collections in a field instead: + +```ts +// ❌ not allowed +initialState: [] as Item[], +// ✅ wrap it +initialState: { items: [] as Item[] }, +``` + +Nested arrays *inside* the state object are completely fine — only the top-level state must be a +keyed object. The `extends object` bound still accepts both `interface` and `type` state shapes. + State is a **deep reactive proxy** (`deepSignal` from `deepsignal`, backed by `@preact/signals-core`) created in [service-runtime.ts](./service-runtime.ts). There is no top-level state atom and no Immer: @@ -305,7 +363,7 @@ Tests should use `vi.waitFor(...)` when asserting the first emission or follow-u queries that define: - `staticPath` at definition time -- `load` (definition or registration) +- `load` on the definition - `staticInputs` (definition or registration) For each static input it: @@ -358,6 +416,239 @@ flowchart TD J --> K[writeOpenServiceStaticFiles outputDir] ``` +## Client Architecture (Multi-Master) + +Browser processes (manager and preview) each run their own full `ServiceRuntime` — identical in shape to the server-side one. State is reconciled peer-to-peer through Storybook's existing manager↔preview channel using a sync-start initialization + patch-broadcast protocol. + +```text +┌─────────────────────────┐ channel (services:*) ┌─────────────────────────┐ +│ Manager process │ ◄────────────────────────► │ Preview process │ +│ │ │ │ +│ registerService │ │ registerService │ +│ ┌─────────────────┐ │ │ ┌─────────────────┐ │ +│ │ ServiceRuntime │ │ │ │ ServiceRuntime │ │ +│ │ (deep signals) │ │ │ │ (deep signals) │ │ +│ └─────────────────┘ │ │ └─────────────────┘ │ +└─────────────────────────┘ └─────────────────────────┘ +``` + +### Channel setup + +There is no open-service-specific channel install step. `getChannel()` from `storybook/internal/channels` +reads the live channel — the manager sets it via `addons.setChannel`, both builders inject it into the +preview iframe, and the dev server installs it in the `services` preset before any service registers. + +Until a channel is installed, service runtimes operate in isolation — all reads and writes are local +only. Unit tests can install a mock channel with `setChannel(mock)` (or `clearChannel()` to assert +registration fails without one). + +### `registerService` + +Creates a local `ServiceRuntime` from the service definition (identical across runtimes) and wires it into the sync protocol: + +1. **On registration** — emits `services:sync-start` so any existing peer can reply with its current snapshot. +2. **On sync-start-reply** — applies the received snapshot into the local runtime so the new peer bootstraps from existing state. +3. **After each local command** — broadcasts the full post-mutation state as `services:patches` so all peers stay in sync. +4. **On incoming patches** — applies the received state into the local runtime via `commandSelf.setState`, which triggers fine-grained signal updates and re-renders subscribed components. + +### Loop prevention + +Every channel event carries the emitter's `clientId` (generated per `registerService` call). Listeners silently ignore events whose `clientId` matches their own, so peers never re-apply state they just emitted. + +### State application without re-broadcast + +Incoming state (from sync-start-reply or patches) is applied via `serviceRuntime.commandSelf.setState(...)` directly — not through the wrapped commands — so no broadcast is triggered for received state. + +### `deepReconcile` + +Rather than replacing the entire state object on each patch (which would invalidate all signal subscriptions), `deepReconcile` (in [service-sync.ts](./service-sync.ts)) recursively merges plain-object values in place: arrays and primitives are replaced directly, keys absent from the snapshot are deleted so deletions propagate, and `__proto__`/`constructor`/`prototype` are skipped to block prototype pollution. This keeps fine-grained subscriptions on unaffected nested fields from firing spuriously. + +### State sync sequence + +```text +Peer A (manager) Channel Peer B (preview) +───────────────────────────────────────────────────────────────── +registerService() + └─ emit sync-start ──────────────────────────────────────► + (no peer yet; silence) + + registerService() +◄─────────────────────────── emit sync-start ────────────── + └─ reply with snapshot ──────────────────────────────────────► + └─ apply snapshot + +service.commands.foo() + └─ local runtime mutates + └─ emit patches ─────────────────────────────────────────────► + └─ apply state +``` + +### Server participation + +The dev server is a full peer, not a passive observer. `registerService` on the server registers as a relay hub (`relay: true`): it wraps commands to broadcast their post-mutation snapshots, responds to sync-starts, applies incoming patches, and re-broadcasts every adopted snapshot so peers on its other transports (each connected manager tab) converge. This is wired automatically at registration once the `services` preset has installed the channel — there is no separate connect step. + +## Remote Command Execution + +Queries are local-only, but commands are not. A command's handler can be supplied in only **some** +runtimes — the canonical case is a handler added at server registration that needs Node APIs or +server context. The runtimes that lack the handler must still be able to invoke the command (from +`useServiceCommand`, a test, or another service), so they ask a peer that can run it. + +`registerService` decides this **per command at registration time** by checking whether the resolved +definition has a `handler`: + +- **Has a local handler** → the command runs locally and broadcasts its post-mutation state as usual + (the normal multi-master path), **and** the runtime listens for invoke requests so it can run the + command on behalf of peers that cannot. +- **No local handler** → the command becomes a **remote invoker**: calling it sends a request over + the channel and returns a promise that settles when a peer answers. + +The protocol lives in [service-transport.ts](./service-transport.ts) (`connectCommandTransport`) and +is covered by the command transport tests. + +### Roles + +Every registered runtime plays **both** roles at once, decided per command: + +- **Requester** (no local handler): calling the command emits `services:command-invoke` carrying a + freshly generated `callId` and returns a promise. The promise resolves with the `result` of the + first `services:command-result` for that `callId`, or rejects with the reconstructed error from the + first `services:command-error`. +- **Responder** (has a local handler): on a matching `services:command-invoke` it emits + `services:command-ack` **immediately** (before running), executes the command locally — which + validates input, mutates state, and broadcasts the post-mutation snapshot through the normal command + wrappers so every peer converges — then emits `services:command-result` or `services:command-error`. + +A runtime never requests a command it implements (it runs that locally), so a responder never answers +its own invoke echo: `onInvoke` only acts on commands in its `implementedCommandNames` set. + +### Events + +All four events are namespaced under `services:` and carry the `serviceId` so a runtime that hosts +several services routes them correctly. + +| Event | Direction | Payload | +| ------------------------- | ------------------------ | ----------------------------------------------------- | +| `services:command-invoke` | requester → implementers | `{ serviceId, commandName, input, callId, clientId }` | +| `services:command-ack` | implementer → requester | `{ serviceId, callId, clientId }` | +| `services:command-result` | implementer → requester | `{ serviceId, callId, result, clientId }` | +| `services:command-error` | implementer → requester | `{ serviceId, callId, error, clientId }` | + +- `callId` is the per-invocation correlation id (see [Correlation and parallel calls](#correlation-and-parallel-calls)). +- `clientId` is the id of the runtime that emitted the envelope — the requester on an invoke, the + responder on a reply. +- `error` is a transport-safe serialization of the thrown value, including its full `cause` chain (and + arrays such as the `.loaded()` drain's `cause.aggregated`) plus Storybook fields like + `code`/`fromStorybook`. See [service-error-serialization.ts](./service-error-serialization.ts); + `Error` instances cannot cross a websocket (JSON) or postMessage (structured clone) boundary intact, + so they are flattened to a plain shape and rebuilt into a real `Error` on the requester. + +### Sequence + +One runtime calls a command implemented only on the dev server: + +```text +Requester Channel Server (responder) +────────────────────────────────────────────────────────────────────────── +service.commands.example(...) + └─ new callId + └─ emit command-invoke ───────────────────────────────────► + └─ emit command-ack ──┐ + ◄───────────────────────────────────────────────────────────────────┘ + └─ run command locally + └─ mutate + emit patches ──► + ◄── apply patches (state converges) ─────────────────────────────────── + └─ emit command-result ──┐ + ◄───────────────────────────────────────────────────────────────────────┘ + └─ promise resolves with result +``` + +State still flows through the normal patch-broadcast path, so the requester gets the new state via +`services:patches` and the resolved value via `services:command-result` — two independent channels of +truth that both converge. + +### Awaiting + +A command can be awaited from any runtime, even when it runs remotely: the returned promise resolves +on success and rejects on failure exactly as if the handler had been local. Callers never need to know +where the handler lives. + +### Correlation and parallel calls + +`callId` is generated fresh (`generateClientId()`) for **every** call, so it is effectively a unique +execution id. This is what makes concurrent calls safe: + +- Two parallel calls — even with identical input — get two distinct `callId`s, two `pending` promise + entries, and two independent invoke envelopes. Replies are matched strictly by `callId`, so they can + never cross-wire and resolving one call never settles the other. +- A reply whose `callId` is unknown (already settled, or for a different runtime's call) or whose + `serviceId` does not match is ignored. + +`callId` correlates **replies**; it does not make a command idempotent. Each invoke triggers a real +execution on every responder that receives it. + +### Multiple implementers (at-most-once is not guaranteed) + +If several peers implement the same command, each one runs it (side effects and all) and replies. The +requester keeps the **first** reply per `callId` and ignores the rest — so the *promise* is deduped, +but the *execution* is not. A command can therefore legitimately run in more than one runtime. + +This is intentional for now and constrained by convention, not enforced by the protocol: **implement a +command in exactly one runtime when its effects must not be duplicated.** Guaranteeing at-most-once +across implementers would require electing a single executor per call, which this slice does not do. + +### Topology limits and timeouts + +Replies travel back over the same channel the invoke went out on, and command events are **not** +relayed across a hub's other transports (unlike `services:patches`, which a relay hub re-broadcasts). +The manager is connected to both the dev server and the preview, so it can invoke a command implemented +in either; but a preview cannot directly invoke a server-only command, and vice versa — route such +calls through the manager, or implement the command on a directly-connected peer. + +There is **no timeout**. Invoking a command that no reachable peer implements leaves the promise +pending forever, until the service is unregistered — `disconnect` rejects every outstanding call with +`OpenServiceRemoteCommandDisconnectedError`. + +## React Hooks + +### `useServiceQuery` + +Subscribes to a service query and returns its current value, re-rendering when it changes. + +```tsx +import { useServiceQuery } from 'storybook/manager-api'; + +// With input +const fields = useServiceQuery(service, 'getRecordFields', { entryId: 'a' }); + +// Void-input query (no third argument) +const summary = useServiceQuery(service, 'getSummary'); +``` + +Backed by `useSyncExternalStore`. The subscription is torn down and recreated when `service`, `queryName`, or `input` changes. + +**Referential stability:** `getSnapshot` compares the new value with the previous via `isEqual` and returns the cached reference when they are deeply equal, so React bails out of re-rendering when the value hasn't logically changed. + +**Memoize complex inputs at the call site.** The input participates in the hook's React dependency array. Passing a new object literal on every render recreates the subscription each render. Wrap object inputs in `useMemo` or extract them to module scope. + +### `useServiceCommand` + +Returns a stable reference to a service command. The reference is stable as long as `service` and `commandName` do not change, so it is safe to pass to child components or include in effect dependency arrays. + +```tsx +import { useServiceCommand } from 'storybook/manager-api'; + +const assignField = useServiceCommand(service, 'assignRecordField'); + +return ( + +); +``` + +Fire-and-forget: the returned function returns a Promise. Callers manage their own loading and error state with `useState`, `useReducer`, TanStack Query, or whatever fits. + ## How To Define A Service Define queries and commands inline inside `defineService()` so the service-level schema maps can contextually type every handler, load hook, and `ctx.self.commands.*` call: @@ -365,7 +656,7 @@ Define queries and commands inline inside `defineService()` so the service-level ```ts import * as v from 'valibot'; -import { defineService } from './index.ts'; +import { defineService } from 'storybook/open-service'; import { registerService } from './server.ts'; type ExampleState = { @@ -419,28 +710,42 @@ const ready = await exampleService.queries.getValue.loaded({ entryId: 'a' }); ## Design Rules +- State must be a plain object — no primitives, `null`, or top-level arrays (see [State must be an object](#state-must-be-an-object)). Wrap collections in a field: `{ items: [] }`. - Always declare both `input` and `output` schemas on every query and command. - Use `load` for read-side warming. The hook is async and must mutate via commands. - **Keep `load` bodies minimal — ideally one line that calls a command.** Push input resolution, side effects, and state mutation into the command itself so it stays callable, testable, and reusable on its own. - Query handlers are strict readers: sync, no commands, no `setState`. - Use commands for all state mutation. -- Keep environment-agnostic imports on [index.ts](./index.ts) and server-only imports on [server.ts](./server.ts). Import internal modules directly only from tests or implementation code in this directory. +- Manager addons import from `storybook/manager-api`; preview code imports from `storybook/preview-api`. Server presets use [server.ts](./server.ts). Import modules in this directory directly only from tests or implementation code. - Use `.loaded()` when a caller wants to await the full state; use the sync form when "current best" is fine. +- No channel install is needed in manager/preview — `getChannel()` returns the channel `addons.setChannel` installed. +- Call `clearRegistry()` in `afterEach` in tests that register services. Use `setChannel(mock)` before `registerService` when a test needs sync. Node bootstraps a noop channel at import; browser preview gets the real channel from builders before `preview.ts` loads. ## Testing Guidance - Runtime behavior belongs in [service-runtime.test.ts](./service-runtime.test.ts) - Validation behavior belongs in [service-validation.test.ts](./service-validation.test.ts) - Server registration and static snapshot behavior belong in [server.test.ts](./server.test.ts) +- Leaf channel sync (`relay: false`, preview path) belongs in [service-transport-leaf.test.ts](./service-transport-leaf.test.ts); hub channel sync (dev server) in [service-registration-sync.test.ts](./service-registration-sync.test.ts) +- Remote command execution (requester/responder protocol) belongs in [service-command-transport.test.ts](./service-command-transport.test.ts); error (de)serialization in [service-error-serialization.test.ts](./service-error-serialization.test.ts) +- React hook behavior belongs in [use-service-query.test.tsx](./use-service-query.test.tsx) and [use-service-command.test.tsx](./use-service-command.test.tsx) - Reusable scenario definitions belong in [fixtures.ts](./fixtures.ts) When adding validation tests, prefer asserting the full exact error message. That keeps the tests useful as executable documentation for callers and agents. +React hook tests must include `// @vitest-environment happy-dom` as the first line and add `clearRegistry()` in `afterEach`. Use `waitFor(...)` (not `act`) when asserting state changes that flow through preact signals — `act` only flushes React's scheduler and is unaware of the signal effect queue. + ## Agent Notes - If you need to change runtime behavior, start in [service-runtime.ts](./service-runtime.ts). -- If you need to change server registration, start in [service-registration.ts](./service-registration.ts). +- If you need to change registration or the registry (any runtime), start in [service-registry.ts](./service-registry.ts). - If you need to change static snapshot building or writing, start in [server.ts](./server.ts). - If you need to change validation wording, start in [errors.ts](./errors.ts). - If you need to change schema handling, start in [service-validation.ts](./service-validation.ts). - If you need to change service authoring ergonomics, start in [service-definition.ts](./service-definition.ts) and [types.ts](./types.ts). +- If you need to change channel transport, relay behavior, or remote command execution, start in [service-transport.ts](./service-transport.ts). +- If you need to change how thrown errors cross the channel for remote commands, start in [service-error-serialization.ts](./service-error-serialization.ts). +- If you need to change last-write-wins ordering or the structural merge, start in [service-sync.ts](./service-sync.ts). +- If you need to change the channel protocol (event names, payloads, channel reader), start in [service-channel.ts](./service-channel.ts). +- If you need to change the React query hook, start in [use-service-query.ts](./use-service-query.ts). +- If you need to change the React command hook, start in [use-service-command.ts](./use-service-command.ts). diff --git a/code/core/src/shared/open-service/fixtures.ts b/code/core/src/shared/open-service/fixtures.ts index 9551811b03f1..79ec7378c9fe 100644 --- a/code/core/src/shared/open-service/fixtures.ts +++ b/code/core/src/shared/open-service/fixtures.ts @@ -265,6 +265,27 @@ export function createDerivedBooleanFromChildQueryServiceDef() { }); } +/** + * Fixture exposing a query whose output type is legitimately `undefined` (a `void` output schema). + * + * Used to verify that consumers treat `undefined` as a real value rather than an "uninitialised" + * sentinel — e.g. the `useServiceQuery` lazy-init must not recompute on every render here. + */ +export const undefinedOutputQueryServiceDef = defineService({ + id: 'internal-fixture/undefined-output-query', + description: 'Exposes a query that always returns undefined.', + initialState: {} as Record, + queries: { + getNothing: { + description: 'Always returns undefined.', + input: noInputSchema, + output: voidOutputSchema, + handler: () => undefined, + }, + }, + commands: {}, +}); + /** Creates a fixture that intentionally returns an invalid query output. */ export function createInvalidQueryOutputServiceDef() { return defineService({ diff --git a/code/core/src/shared/open-service/index.test-d.ts b/code/core/src/shared/open-service/index.test-d.ts index fdc4b7060c4e..c566f48023ce 100644 --- a/code/core/src/shared/open-service/index.test-d.ts +++ b/code/core/src/shared/open-service/index.test-d.ts @@ -172,4 +172,43 @@ describe('open-service type inference', () => { commands: {}, }); }); + + it('accepts both interface and type-alias object state', () => { + interface InterfaceState { + color: string; + } + + // An `interface` (no implicit index signature) must still be a valid state shape. + defineService({ + id: 'internal-fixture/interface-state', + initialState: { color: 'red' } as InterfaceState, + queries: { + getColor: { + input: v.void(), + output: v.string(), + handler: (_input, ctx) => { + expectTypeOf(ctx.self.state).toEqualTypeOf(); + return ctx.self.state.color; + }, + }, + }, + commands: {}, + }); + }); + + it('rejects non-object state (primitive, null, or array)', () => { + const base = { queries: {}, commands: {} } as const; + + // @ts-expect-error state must be a plain object, not a number + defineService({ id: 'internal-fixture/number-state', initialState: 42, ...base }); + + // @ts-expect-error state must be a plain object, not a string + defineService({ id: 'internal-fixture/string-state', initialState: 'nope', ...base }); + + // @ts-expect-error state must be a plain object, not null + defineService({ id: 'internal-fixture/null-state', initialState: null, ...base }); + + // @ts-expect-error state must be a plain object, not an array + defineService({ id: 'internal-fixture/array-state', initialState: [1, 2, 3], ...base }); + }); }); diff --git a/code/core/src/shared/open-service/index.ts b/code/core/src/shared/open-service/index.ts index ec77df044730..b9c8a932b795 100644 --- a/code/core/src/shared/open-service/index.ts +++ b/code/core/src/shared/open-service/index.ts @@ -1,9 +1,9 @@ /** - * Public API for the open-service system. + * Environment-agnostic open-service API (`storybook/open-service`). * - * This barrel intentionally exposes only the authoring and runtime entry points that callers - * outside this directory should rely on. Tests and internal modules can import implementation - * files directly without widening the supported public surface. + * Use this entrypoint for shared service definitions imported by manager, preview, and server. + * Register in the manager with `storybook/manager-api` (hooks), in preview with `storybook/preview-api`, + * or on the server via core-server experimental APIs. */ export { defineService } from './service-definition.ts'; @@ -29,6 +29,7 @@ export type { ServiceInstance, ServiceInstanceOf, ServiceRegistrationOptions, + ServiceState, ServiceSummary, StaticStore, } from './types.ts'; diff --git a/code/core/src/shared/open-service/manager.ts b/code/core/src/shared/open-service/manager.ts new file mode 100644 index 000000000000..e382f661cbe6 --- /dev/null +++ b/code/core/src/shared/open-service/manager.ts @@ -0,0 +1,51 @@ +/** + * Manager-side entrypoint for the open-service architecture. + * + * Import from here in manager (React) code. Define services with `storybook/open-service`. + * + * Quick start: + * + * ```ts + * import { registerService, useServiceQuery, useServiceCommand } from 'storybook/manager-api'; + * + * // Inside an addons.register callback: + * const service = registerService(myServiceDef); + * + * function MyTool() { + * const value = useServiceQuery(service, 'getValue'); + * const setValue = useServiceCommand(service, 'setValue'); + * return ; + * } + * ``` + */ + +import { registerService as registerServiceCore } from './service-registry.ts'; +import type { + Commands, + Queries, + ServiceDefinition, + ServiceInstance, + ServiceRegistrationOptions, + ServiceRegistryApi, +} from './types.ts'; + +export { useServiceCommand } from './use-service-command.ts'; +export { useServiceQuery } from './use-service-query.ts'; + +/** + * Registers a service in the manager and returns its runtime surface. + * + * The manager is a relay hub: it bridges the dev server and the preview iframes. The channel is read + * via `getChannel()` from `storybook/internal/channels`, which the manager runtime installs before any + * `addons.register` callback runs, so no manual channel setup is needed. + */ +export function registerService< + TState, + TQueries extends Queries, + TCommands extends Commands, +>( + definition: ServiceDefinition, + registration?: ServiceRegistrationOptions +): ServiceInstance & ServiceRegistryApi { + return registerServiceCore(definition, registration, { relay: true }); +} diff --git a/code/core/src/shared/open-service/preview.ts b/code/core/src/shared/open-service/preview.ts new file mode 100644 index 000000000000..6aa353168865 --- /dev/null +++ b/code/core/src/shared/open-service/preview.ts @@ -0,0 +1,49 @@ +/** + * Preview-side entrypoint for the open-service architecture. + * + * Import from here in preview (renderer) code. This entrypoint is intentionally renderer-agnostic — + * it exposes only registration with no React dependencies. Use `.subscribe()` on queries to react + * to state changes in your renderer. + * + * Define services with `storybook/open-service`. The manager entrypoint (`./manager.ts`) adds + * `useServiceQuery` and `useServiceCommand` on top of relay registration. + * + * Quick start: + * + * ```ts + * import { registerService } from 'storybook/preview-api'; + * + * const service = registerService(myServiceDef); + * + * service.queries.getColor.subscribe(undefined, (color) => { + * document.body.style.background = color; + * }); + * ``` + */ + +import { registerService as registerServiceCore } from './service-registry.ts'; +import type { + Commands, + Queries, + ServiceDefinition, + ServiceInstance, + ServiceRegistrationOptions, + ServiceRegistryApi, +} from './types.ts'; + +/** + * Registers a service in the preview and returns its runtime surface. + * + * The preview is a leaf (`relay: false`). Builders install the addons channel before preview config + * loads, so registration can assume the channel is already present. + */ +export function registerService< + TState, + TQueries extends Queries, + TCommands extends Commands, +>( + definition: ServiceDefinition, + registration?: ServiceRegistrationOptions +): ServiceInstance & ServiceRegistryApi { + return registerServiceCore(definition, registration); +} diff --git a/code/core/src/shared/open-service/server.test-d.ts b/code/core/src/shared/open-service/server.test-d.ts index 603b865129f5..5ddd53ea85e9 100644 --- a/code/core/src/shared/open-service/server.test-d.ts +++ b/code/core/src/shared/open-service/server.test-d.ts @@ -18,6 +18,21 @@ const registrationOnlyServiceDef = defineService({ getValue: { input: entryIdInputSchema, output: v.nullable(v.string()), + handler: (input, ctx) => { + expectTypeOf(input).toEqualTypeOf<{ entryId: string }>(); + expectTypeOf(ctx.self.state.valuesById[input.entryId]).toEqualTypeOf(); + // @ts-expect-error query handlers do not receive commands on self + void ctx.self.commands; + expectTypeOf(ctx.getService).parameter(0).toEqualTypeOf(); + expectTypeOf( + ctx.getService('internal-fixture/missing-service') + ).toEqualTypeOf(); + + return ctx.self.state.valuesById[input.entryId] ?? null; + }, + load: async (input, ctx) => { + await ctx.self.commands.preloadValue(input); + }, staticPath: (input) => { expectTypeOf(input).toEqualTypeOf<{ entryId: string }>(); return `${input.entryId}.json`; @@ -39,25 +54,6 @@ const registrationOnlyServiceDef = defineService({ const registeredService = registerService(registrationOnlyServiceDef, { queries: { getValue: { - handler: (input, ctx) => { - expectTypeOf(input).toEqualTypeOf<{ entryId: string }>(); - expectTypeOf(ctx.self.state.valuesById[input.entryId]).toEqualTypeOf(); - // @ts-expect-error query handlers do not receive commands on self - void ctx.self.commands; - expectTypeOf(ctx.getService).parameter(0).toEqualTypeOf(); - expectTypeOf( - ctx.getService('internal-fixture/missing-service') - ).toEqualTypeOf(); - - return ctx.self.state.valuesById[input.entryId] ?? null; - }, - load: async (input, ctx) => { - expectTypeOf(input).toEqualTypeOf<{ entryId: string }>(); - expectTypeOf(ctx.self.commands.preloadValue).parameter(0).toEqualTypeOf<{ - entryId: string; - }>(); - await ctx.self.commands.preloadValue(input); - }, staticInputs: () => [{ entryId: 'entry-a' }], }, }, @@ -107,8 +103,17 @@ describe('open-service registration types', () => { registerService(registrationOnlyServiceDef, { queries: { getValue: { - // @ts-expect-error query registration output must match the declared schema - handler: () => 123, + // @ts-expect-error query handlers belong on the definition, not at registration + handler: () => 'wrong', + }, + }, + }); + + registerService(registrationOnlyServiceDef, { + queries: { + getValue: { + // @ts-expect-error load must be declared on the definition, not at registration + load: async () => {}, }, }, }); @@ -127,27 +132,34 @@ describe('open-service registration types', () => { it('types cross-service lookups when getService receives a definition generic', () => { registerService(mutableRecordLookupServiceDef); - registerService(registrationOnlyServiceDef, { - queries: { - getValue: { - handler: (_input, ctx) => { - const lookup = ctx.getService( - 'internal-fixture/mutable-record-lookup' - ); + registerService( + defineService({ + id: 'internal-fixture/open-service-registration-cross-service', + initialState: { valuesById: {} as Record }, + queries: { + getValue: { + input: entryIdInputSchema, + output: v.nullable(v.string()), + handler: (_input, ctx) => { + const lookup = ctx.getService( + 'internal-fixture/mutable-record-lookup' + ); - expectTypeOf(lookup.queries.getRecordFields).returns.toEqualTypeOf | null>(); - const missingService = ctx.getService('internal-fixture/missing-service'); - expectTypeOf(missingService).toEqualTypeOf(); - // @ts-expect-error getRecordFields requires an entryId string - lookup.queries.getRecordFields({}); + expectTypeOf(lookup.queries.getRecordFields).returns.toEqualTypeOf | null>(); + const missingService = ctx.getService('internal-fixture/missing-service'); + expectTypeOf(missingService).toEqualTypeOf(); + // @ts-expect-error getRecordFields requires an entryId string + lookup.queries.getRecordFields({}); - return null; + return null; + }, }, }, - }, - }); + commands: {}, + }) + ); }); }); diff --git a/code/core/src/shared/open-service/server.ts b/code/core/src/shared/open-service/server.ts index 222e4c8689f4..11c027e7a04e 100644 --- a/code/core/src/shared/open-service/server.ts +++ b/code/core/src/shared/open-service/server.ts @@ -10,9 +10,9 @@ import { getRegisteredServices, getService, listServices, - registerService, + registerService as registerServiceCore, serviceRegistryApi, -} from './service-registration.ts'; +} from './service-registry.ts'; import { createServiceRuntime, resolveStaticPath } from './service-runtime.ts'; import { validateSchema } from './service-validation.ts'; import type { @@ -21,20 +21,34 @@ import type { Commands, Queries, ServiceDefinition, + ServiceInstance, + ServiceRegistrationOptions, + ServiceRegistryApi, StaticStore, } from './types.ts'; type RuntimeServiceDefinition = ServiceDefinition, Commands>; type RuntimeQueryDefinition = AnyQueryDefinition; -export { - clearRegistry, - describeService, - getRegisteredServices, - getService, - listServices, - registerService, -}; +export { clearRegistry, describeService, getRegisteredServices, getService, listServices }; + +/** + * Registers a service on the dev server and returns its runtime surface. + * + * The server is a relay hub: when a channel is installed (the `services` preset does this on a real + * websocket transport) it bridges every connected manager tab. Without a channel — static builds and + * the index builder — the runtime stays local-only. + */ +export function registerService< + TState, + TQueries extends Queries, + TCommands extends Commands, +>( + definition: ServiceDefinition, + registration?: ServiceRegistrationOptions +): ServiceInstance & ServiceRegistryApi { + return registerServiceCore(definition, registration, { relay: true }); +} /** * Builds serialized static-state snapshots for `load`-enabled queries across every service diff --git a/code/core/src/shared/open-service/service-channel.ts b/code/core/src/shared/open-service/service-channel.ts new file mode 100644 index 000000000000..6d2a68257ea8 --- /dev/null +++ b/code/core/src/shared/open-service/service-channel.ts @@ -0,0 +1,135 @@ +/** + * Channel transport constants and helpers for the open-service peer-to-peer sync protocol. + * + * Services use Storybook's existing manager↔preview channel so that a service registered + * in the manager or preview can automatically synchronise its state with other connected + * peers. The `services:` prefix keeps service events trivially filterable from other + * channel traffic. + * + * The live channel is read via {@link getChannel} from `storybook/internal/channels`. + */ +import { nanoid } from 'nanoid'; +import * as v from 'valibot'; + +import type { ChannelLike } from '../../channels/types.ts'; +import type { SerializedError } from './service-error-serialization.ts'; + +/** + * Minimal channel contract needed for service sync. + * + * Structurally matches `Pick` so test mocks and the full + * {@link Channel} class both satisfy this type. + */ +export type ServiceChannel = Pick; + +export const SERVICE_SYNC_START = 'services:sync-start' as const; +export const SERVICE_SYNC_START_REPLY = 'services:sync-start-reply' as const; +export const SERVICE_PATCHES = 'services:patches' as const; +export const SERVICE_COMMAND_INVOKE = 'services:command-invoke' as const; +export const SERVICE_COMMAND_ACK = 'services:command-ack' as const; +export const SERVICE_COMMAND_RESULT = 'services:command-result' as const; +export const SERVICE_COMMAND_ERROR = 'services:command-error' as const; + +/** + * Channel payloads are untrusted input, so each event has a Valibot schema. Listeners narrow with + * `v.safeParse(schema, payload)`; the payload *types* are derived from the schemas so the wire shape + * and the static type can never drift. Field-level notes: + * + * - `state` must be a *plain* object: `v.record` accepts arrays, so a custom check rejects them + * (an array snapshot would corrupt the structural merge in `service-sync.ts`). + * - `version` is a non-negative safe integer — the last-write-wins logical clock. + * - `input` / `result` are optional: a `void` command input or output serializes to `undefined`, + * which JSON / telejson transports drop entirely, so the key is legitimately absent on the wire. + */ + +/** A plain (non-array, non-null) object — the shape every synced state snapshot must take. */ +const stateSnapshotSchema = v.custom>( + (value) => typeof value === 'object' && value !== null && !Array.isArray(value) +); + +/** Sent by a newly-registered peer to initialize its state from any existing peer. */ +export const syncStartSchema = v.object({ + serviceId: v.string(), + clientId: v.string(), +}); +export type SyncStartPayload = v.InferOutput; + +/** + * A full state snapshot stamped for last-write-wins ordering. Shared by `services:patches` (broadcast + * after every local command) and `services:sync-start-reply` (the response that bootstraps a freshly + * registered peer). Recipients apply it only when it is strictly newer than their own (see `isNewer` + * in `service-sync.ts`), which suppresses echoes, breaks relay cycles, and converges concurrent writes. + */ +export const stampedSnapshotSchema = v.object({ + serviceId: v.string(), + state: stateSnapshotSchema, + version: v.pipe(v.number(), v.safeInteger(), v.minValue(0)), + clientId: v.string(), +}); +export type StampedSnapshotPayload = v.InferOutput; +export type PatchesPayload = StampedSnapshotPayload; +export type SyncStartReplyPayload = StampedSnapshotPayload; + +/** + * Sent by a runtime that wants a command executed but has no local handler for it. + * + * Any peer that *does* implement the command runs it and replies with an ack, then a result or + * error carrying the same `callId`. The requester awaits its promise until one of those arrives. + * `input` is the raw (unvalidated) command input; the implementing peer validates it before running. + */ +export const commandInvokeSchema = v.object({ + serviceId: v.string(), + commandName: v.string(), + input: v.optional(v.unknown()), + callId: v.string(), + clientId: v.string(), +}); +export type CommandInvokePayload = v.InferOutput; + +/** + * Emitted by an implementing peer the moment it accepts a `services:command-invoke`. + * + * Purely informational: it tells observers (and the requester) that at least one peer has picked + * the call up. The requester still resolves/rejects only on the result/error reply. + */ +export const commandAckSchema = v.object({ + serviceId: v.string(), + callId: v.string(), + clientId: v.string(), +}); +export type CommandAckPayload = v.InferOutput; + +/** Sent by an implementing peer after a remote command resolves successfully. */ +export const commandResultSchema = v.object({ + serviceId: v.string(), + callId: v.string(), + result: v.optional(v.unknown()), + clientId: v.string(), +}); +export type CommandResultPayload = v.InferOutput; + +/** + * Sent by an implementing peer after a remote command throws. `error` is a serialized error + * (including its `cause` chain) so the requester can rethrow a real `Error`; it is only checked for + * "is a plain object" here and reconstructed in `service-error-serialization.ts`. + */ +export const commandErrorSchema = v.object({ + serviceId: v.string(), + callId: v.string(), + error: v.custom( + (value) => typeof value === 'object' && value !== null && !Array.isArray(value) + ), + clientId: v.string(), +}); +export type CommandErrorPayload = v.InferOutput; + +/** + * Generates a unique id for one runtime instance (and for one remote-command `callId`). + * + * The id is identity-critical: it is the last-write-wins tiebreak for equal versions, the loop guard + * that drops a peer's own echoes, and the correlation key matching command replies to their calls. + * `nanoid` is used (over `Math.random`) so collisions cannot silently break that determinism. + */ +export function generateClientId(): string { + return nanoid(); +} diff --git a/code/core/src/shared/open-service/service-command-transport.test.ts b/code/core/src/shared/open-service/service-command-transport.test.ts new file mode 100644 index 000000000000..582be998a38d --- /dev/null +++ b/code/core/src/shared/open-service/service-command-transport.test.ts @@ -0,0 +1,281 @@ +/** + * Tests for remote command execution: a runtime without a local handler requests execution from a + * peer, and a runtime that has one responds. The protocol (`service-transport.ts`) is documented on + * {@link connectCommandTransport}. + * + * Peers are simulated with the test channel's `emitExternal`, the same approach the sync tests use. + */ +import * as v from 'valibot'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { mutableRecordLookupServiceDef } from './fixtures.ts'; +import { defineService } from './service-definition.ts'; +import { + SERVICE_COMMAND_ACK, + SERVICE_COMMAND_ERROR, + SERVICE_COMMAND_INVOKE, + SERVICE_COMMAND_RESULT, + SERVICE_PATCHES, + type CommandErrorPayload, + type CommandInvokePayload, +} from './service-channel.ts'; +import { deserializeError } from './service-error-serialization.ts'; +import { clearRegistry, registerService, unregisterService } from './service-registry.ts'; +import { createTestChannel, installTestChannel } from '../../channels/test-channel.ts'; + +const remoteOnlyServiceDef = defineService({ + id: 'internal-fixture/remote-only-command', + description: 'Declares a command with no local handler so it must run remotely.', + initialState: {} as Record, + queries: {}, + commands: { + doThing: { + description: 'Has no local handler in this runtime.', + input: v.object({ value: v.string() }), + output: v.string(), + }, + }, +}); + +const throwingCommandServiceDef = defineService({ + id: 'internal-fixture/throwing-command', + description: 'Implements a command that always throws, to exercise error replies.', + initialState: {} as Record, + queries: {}, + commands: { + boom: { + description: 'Throws an error with a cause.', + input: v.object({}), + output: v.void(), + handler: async () => { + throw new Error('kaboom', { cause: new Error('root cause') }); + }, + }, + }, +}); + +function emittedCalls(channel: ReturnType, event: string) { + return channel.emit.mock.calls.filter(([name]) => name === event); +} + +afterEach(() => { + clearRegistry(); + installTestChannel(null); +}); + +describe('remote command requester (no local handler)', () => { + it('emits a command-invoke envelope when the command is called', () => { + const channel = createTestChannel(); + installTestChannel(channel); + + const service = registerService(remoteOnlyServiceDef); + // Never settled in this test; swallow the unregister rejection clearRegistry triggers in afterEach. + service.commands.doThing({ value: 'hi' }).catch(() => {}); + + const invokes = emittedCalls(channel, SERVICE_COMMAND_INVOKE); + expect(invokes).toHaveLength(1); + expect(invokes[0][1]).toMatchObject({ + serviceId: remoteOnlyServiceDef.id, + commandName: 'doThing', + input: { value: 'hi' }, + callId: expect.any(String), + clientId: expect.any(String), + }); + }); + + it('resolves with the result of the matching command-result reply', async () => { + const channel = createTestChannel(); + installTestChannel(channel); + + const service = registerService(remoteOnlyServiceDef); + const promise = service.commands.doThing({ value: 'hi' }); + + const { callId } = emittedCalls(channel, SERVICE_COMMAND_INVOKE)[0][1] as CommandInvokePayload; + channel.emitExternal(SERVICE_COMMAND_RESULT, { + serviceId: remoteOnlyServiceDef.id, + callId, + result: 'done', + clientId: 'peer', + }); + + await expect(promise).resolves.toBe('done'); + }); + + it('rejects with the reconstructed error from a command-error reply', async () => { + const channel = createTestChannel(); + installTestChannel(channel); + + const service = registerService(remoteOnlyServiceDef); + const promise = service.commands.doThing({ value: 'hi' }); + + const { callId } = emittedCalls(channel, SERVICE_COMMAND_INVOKE)[0][1] as CommandInvokePayload; + channel.emitExternal(SERVICE_COMMAND_ERROR, { + serviceId: remoteOnlyServiceDef.id, + callId, + clientId: 'peer', + error: { + __openServiceError__: true, + name: 'OpenServiceValidationError', + message: 'invalid input', + properties: { fromStorybook: true, code: 5 }, + }, + }); + + await expect(promise).rejects.toMatchObject({ + message: 'invalid input', + fromStorybook: true, + code: 5, + }); + }); + + it('keeps only the first reply when several peers answer one call', async () => { + const channel = createTestChannel(); + installTestChannel(channel); + + const service = registerService(remoteOnlyServiceDef); + const promise = service.commands.doThing({ value: 'hi' }); + + const { callId } = emittedCalls(channel, SERVICE_COMMAND_INVOKE)[0][1] as CommandInvokePayload; + channel.emitExternal(SERVICE_COMMAND_RESULT, { + serviceId: remoteOnlyServiceDef.id, + callId, + result: 'first', + clientId: 'peer-1', + }); + // A later reply for the same call (a second implementer) must be ignored, not throw. + expect(() => + channel.emitExternal(SERVICE_COMMAND_RESULT, { + serviceId: remoteOnlyServiceDef.id, + callId, + result: 'second', + clientId: 'peer-2', + }) + ).not.toThrow(); + + await expect(promise).resolves.toBe('first'); + }); + + it('ignores replies addressed to a different service or an unknown call', async () => { + const channel = createTestChannel(); + installTestChannel(channel); + + const service = registerService(remoteOnlyServiceDef); + const promise = service.commands.doThing({ value: 'hi' }); + + const { callId } = emittedCalls(channel, SERVICE_COMMAND_INVOKE)[0][1] as CommandInvokePayload; + channel.emitExternal(SERVICE_COMMAND_RESULT, { + serviceId: 'some/other-service', + callId, + result: 'wrong-service', + clientId: 'peer', + }); + channel.emitExternal(SERVICE_COMMAND_RESULT, { + serviceId: remoteOnlyServiceDef.id, + callId: 'unknown-call', + result: 'wrong-call', + clientId: 'peer', + }); + channel.emitExternal(SERVICE_COMMAND_RESULT, { + serviceId: remoteOnlyServiceDef.id, + callId, + result: 'correct', + clientId: 'peer', + }); + + await expect(promise).resolves.toBe('correct'); + }); + + it('rejects in-flight remote calls when the service is unregistered', async () => { + const channel = createTestChannel(); + installTestChannel(channel); + + const service = registerService(remoteOnlyServiceDef); + const promise = service.commands.doThing({ value: 'hi' }); + + unregisterService(remoteOnlyServiceDef.id); + + await expect(promise).rejects.toThrow(/unregistered before a remote command resolved/); + }); +}); + +describe('remote command responder (has local handler)', () => { + it('acknowledges, runs the command, broadcasts state, and replies with the result', async () => { + const channel = createTestChannel(); + installTestChannel(channel); + + const service = registerService(mutableRecordLookupServiceDef); + + channel.emitExternal(SERVICE_COMMAND_INVOKE, { + serviceId: mutableRecordLookupServiceDef.id, + commandName: 'assignRecordField', + input: { entryId: 'a', fieldKey: 'k', fieldValue: 'v' }, + callId: 'call-1', + clientId: 'requester', + }); + + // The ack is emitted synchronously on receipt, before the command runs. + expect(channel.emit).toHaveBeenCalledWith( + SERVICE_COMMAND_ACK, + expect.objectContaining({ + serviceId: mutableRecordLookupServiceDef.id, + callId: 'call-1', + clientId: expect.any(String), + }) + ); + + await vi.waitFor(() => + expect(channel.emit).toHaveBeenCalledWith( + SERVICE_COMMAND_RESULT, + expect.objectContaining({ callId: 'call-1', serviceId: mutableRecordLookupServiceDef.id }) + ) + ); + + expect(service.queries.getRecordFields({ entryId: 'a' })).toEqual({ k: 'v' }); + expect(emittedCalls(channel, SERVICE_PATCHES).length).toBeGreaterThan(0); + }); + + it('replies with a serialized error (including the cause) when the handler throws', async () => { + const channel = createTestChannel(); + installTestChannel(channel); + + registerService(throwingCommandServiceDef); + + channel.emitExternal(SERVICE_COMMAND_INVOKE, { + serviceId: throwingCommandServiceDef.id, + commandName: 'boom', + input: {}, + callId: 'call-err', + clientId: 'requester', + }); + + await vi.waitFor(() => expect(emittedCalls(channel, SERVICE_COMMAND_ERROR)).toHaveLength(1)); + + const payload = emittedCalls(channel, SERVICE_COMMAND_ERROR)[0][1] as CommandErrorPayload; + expect(payload).toMatchObject({ serviceId: throwingCommandServiceDef.id, callId: 'call-err' }); + + const restored = deserializeError(payload.error); + expect(restored.message).toBe('kaboom'); + expect((restored.cause as Error).message).toBe('root cause'); + }); + + it('ignores invokes for commands it does not implement', async () => { + const channel = createTestChannel(); + installTestChannel(channel); + + // This runtime has no local handler for `doThing`, so it must not answer the invoke. + registerService(remoteOnlyServiceDef); + + channel.emitExternal(SERVICE_COMMAND_INVOKE, { + serviceId: remoteOnlyServiceDef.id, + commandName: 'doThing', + input: { value: 'hi' }, + callId: 'call-unhandled', + clientId: 'requester', + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(emittedCalls(channel, SERVICE_COMMAND_ACK)).toHaveLength(0); + expect(emittedCalls(channel, SERVICE_COMMAND_RESULT)).toHaveLength(0); + }); +}); diff --git a/code/core/src/shared/open-service/service-definition.ts b/code/core/src/shared/open-service/service-definition.ts index 2f9c389448e2..7854fe2db712 100644 --- a/code/core/src/shared/open-service/service-definition.ts +++ b/code/core/src/shared/open-service/service-definition.ts @@ -5,6 +5,7 @@ import type { QueryDefinition, ServiceDefinition, ServiceId, + ServiceState, } from './types.ts'; /** @@ -68,7 +69,10 @@ type DefinedCommands< * before it has correlated each inline object's `input` and `output` properties. */ export const defineService = < - TState, + // `extends object` rejects primitives, `null`, and `undefined` (while still accepting both + // `interface` and `type` state shapes); `ServiceState` additionally rejects arrays. State must be a + // plain object — see `ServiceState` for the deep-signal / deep-reconcile reasons. + TState extends object, const TQueryInputSchemas extends OperationInputSchemas, const TQueryOutputSchemas extends MatchingOutputSchemas, const TCommandInputSchemas extends OperationInputSchemas, @@ -76,7 +80,7 @@ export const defineService = < >(def: { id: ServiceId; description?: string; - initialState: TState; + initialState: ServiceState; queries: DefinedQueries< TState, TQueryInputSchemas, diff --git a/code/core/src/shared/open-service/service-error-serialization.test.ts b/code/core/src/shared/open-service/service-error-serialization.test.ts new file mode 100644 index 000000000000..5bea0cae306e --- /dev/null +++ b/code/core/src/shared/open-service/service-error-serialization.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; + +import { OpenServiceValidationError } from '../../server-errors.ts'; +import { deserializeError, serializeError } from './service-error-serialization.ts'; + +describe('serializeError / deserializeError', () => { + it('round-trips name, message, and stack', () => { + const original = new TypeError('boom'); + const restored = deserializeError(serializeError(original)); + + expect(restored).toBeInstanceOf(Error); + expect(restored.name).toBe('TypeError'); + expect(restored.message).toBe('boom'); + expect(restored.stack).toBe(original.stack); + }); + + it('preserves the nested cause chain as real Errors', () => { + const root = new Error('root'); + const middle = new Error('middle', { cause: root }); + const top = new Error('top', { cause: middle }); + + const restored = deserializeError(serializeError(top)); + + expect(restored.message).toBe('top'); + expect((restored.cause as Error).message).toBe('middle'); + expect((restored.cause as Error).cause).toBeInstanceOf(Error); + expect(((restored.cause as Error).cause as Error).message).toBe('root'); + }); + + it('preserves an aggregated array of causes (as produced by the .loaded() drain)', () => { + const primary = new Error('primary'); + primary.cause = { aggregated: [new Error('a'), new Error('b')] }; + + const restored = deserializeError(serializeError(primary)); + const aggregated = (restored.cause as { aggregated: Error[] }).aggregated; + + expect(aggregated).toHaveLength(2); + expect(aggregated[0]).toBeInstanceOf(Error); + expect(aggregated[0].message).toBe('a'); + expect(aggregated[1].message).toBe('b'); + }); + + it('retains Storybook error fields like code and fromStorybook', () => { + const original = new OpenServiceValidationError({ + kind: 'command', + serviceId: 'svc', + name: 'run', + phase: 'input', + issues: [{ message: 'nope' }], + }); + + const restored = deserializeError(serializeError(original)) as Error & { + code?: number; + fromStorybook?: boolean; + }; + + expect(restored.message).toBe(original.message); + expect(restored.fromStorybook).toBe(true); + expect(restored.code).toBe(5); + }); + + it('produces a transport-safe (structured-cloneable) payload', () => { + const error = new Error('with fn'); + (error as unknown as Record).callback = () => 'dropped'; + (error as unknown as Record).count = 3; + + const serialized = serializeError(error); + + expect(() => structuredClone(serialized)).not.toThrow(); + expect(serialized.properties).toMatchObject({ count: 3 }); + expect(serialized.properties?.callback).toBeUndefined(); + }); + + it('wraps non-Error throws in a real Error', () => { + const restored = deserializeError(serializeError('just a string')); + + expect(restored).toBeInstanceOf(Error); + expect(restored.message).toBe('just a string'); + }); +}); diff --git a/code/core/src/shared/open-service/service-error-serialization.ts b/code/core/src/shared/open-service/service-error-serialization.ts new file mode 100644 index 000000000000..1cac41ad4272 --- /dev/null +++ b/code/core/src/shared/open-service/service-error-serialization.ts @@ -0,0 +1,157 @@ +/** + * Error (de)serialization for remote command execution. + * + * A command invoked from a runtime that has no local handler runs on a peer; when it throws, the + * thrown value has to cross the channel and be rethrown on the requester. `Error` instances are not + * structured-cloneable in a way that survives a websocket (JSON) or postMessage transport with their + * `name`, `stack`, `cause`, and Storybook-specific fields (`code`, `fromStorybook`, …) intact, so we + * convert to and from a plain, transport-safe shape here. + * + * The conversion is recursive: an error's `cause` (and any nested arrays/objects, e.g. the + * `cause.aggregated` array used by the `.loaded()` drain) is walked so a multi-cause failure arrives + * on the requester with its chain reconstructed rather than flattened to a single message. + */ + +/** Marks a serialized object as a reconstructable `Error` rather than a plain payload object. */ +const ERROR_MARKER = '__openServiceError__' as const; + +/** Keys handled explicitly so they are not duplicated into the extra-properties bag. */ +const RESERVED_KEYS = new Set([ERROR_MARKER, 'name', 'message', 'stack', 'cause']); + +/** Transport-safe representation of a thrown `Error`, including its recursive `cause` chain. */ +export interface SerializedError { + [ERROR_MARKER]: true; + name: string; + message: string; + stack?: string; + /** The error's `cause`, itself serialized (another error, a plain object, an array, …). */ + cause?: unknown; + /** Extra own enumerable fields (e.g. Storybook's `code`, `fromStorybook`), serialized. */ + properties?: Record; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isSerializedError(value: unknown): value is SerializedError { + return isPlainObject(value) && value[ERROR_MARKER] === true; +} + +/** + * Reduces any value to one a channel transport can clone. + * + * Functions, symbols, and `undefined` are dropped (structuredClone throws on functions/symbols); + * `bigint` is stringified; plain objects and arrays are walked; `Error`s become {@link SerializedError}. + */ +function toTransportSafe(value: unknown): unknown { + if (value instanceof Error) { + return serializeError(value); + } + + if (Array.isArray(value)) { + return value.map((entry) => toTransportSafe(entry)); + } + + if (isPlainObject(value)) { + const result: Record = {}; + for (const [key, entry] of Object.entries(value)) { + result[key] = toTransportSafe(entry); + } + return result; + } + + if (value === null) { + return null; + } + + const kind = typeof value; + if (kind === 'string' || kind === 'number' || kind === 'boolean') { + return value; + } + if (kind === 'bigint') { + return (value as bigint).toString(); + } + + // function / symbol / undefined: not cloneable and not meaningful on the wire. + return undefined; +} + +/** + * Converts a thrown value into a transport-safe {@link SerializedError}. + * + * Non-`Error` throws (a string, an object, …) are wrapped in a synthetic error whose message is the + * stringified value, so the requester always receives a real `Error` to reject with. + */ +export function serializeError(value: unknown): SerializedError { + if (!(value instanceof Error)) { + return { [ERROR_MARKER]: true, name: 'Error', message: String(value) }; + } + + const properties: Record = {}; + for (const key of Object.keys(value)) { + if (!RESERVED_KEYS.has(key)) { + properties[key] = toTransportSafe((value as unknown as Record)[key]); + } + } + + return { + [ERROR_MARKER]: true, + name: value.name, + message: value.message, + ...(value.stack !== undefined ? { stack: value.stack } : {}), + ...(value.cause !== undefined ? { cause: toTransportSafe(value.cause) } : {}), + ...(Object.keys(properties).length > 0 ? { properties } : {}), + }; +} + +/** Inverse of {@link toTransportSafe}: rebuilds nested errors while passing other values through. */ +function fromTransportSafe(value: unknown): unknown { + if (isSerializedError(value)) { + return deserializeError(value); + } + + if (Array.isArray(value)) { + return value.map((entry) => fromTransportSafe(entry)); + } + + if (isPlainObject(value)) { + const result: Record = {}; + for (const [key, entry] of Object.entries(value)) { + result[key] = fromTransportSafe(entry); + } + return result; + } + + return value; +} + +/** + * Rebuilds an `Error` from a {@link SerializedError}, restoring its `name`, `stack`, `cause` chain, + * and any extra fields (so a Storybook error arrives with `code`/`fromStorybook` intact). + * + * The result is a plain `Error`, not the original subclass — reconstructing arbitrary classes across + * a realm boundary is neither possible nor needed; callers branch on the restored fields instead. + */ +export function deserializeError(serialized: SerializedError): Error { + // A generic Error is intentional: this reconstructs an arbitrary error thrown in another runtime + // (its original class cannot cross the realm boundary). Storybook-specific fields like `code` and + // `fromStorybook` are restored from `properties`, so callers can still branch on them. + // eslint-disable-next-line local-rules/no-uncategorized-errors + const error = new Error(serialized.message); + error.name = serialized.name; + + if (serialized.stack !== undefined) { + error.stack = serialized.stack; + } + if ('cause' in serialized) { + error.cause = fromTransportSafe(serialized.cause); + } + if (serialized.properties) { + for (const [key, value] of Object.entries(serialized.properties)) { + (error as unknown as Record)[key] = fromTransportSafe(value); + } + } + + return error; +} diff --git a/code/core/src/shared/open-service/service-registration-sync.test.ts b/code/core/src/shared/open-service/service-registration-sync.test.ts new file mode 100644 index 000000000000..f3b663ba45ab --- /dev/null +++ b/code/core/src/shared/open-service/service-registration-sync.test.ts @@ -0,0 +1,370 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { OpenServiceMissingChannelError } from '../../server-errors.ts'; +import { mutableRecordLookupServiceDef } from './fixtures.ts'; +import { + SERVICE_PATCHES, + SERVICE_SYNC_START_REPLY, + SERVICE_SYNC_START, +} from './service-channel.ts'; +import { clearRegistry, registerService } from './server.ts'; +import { createTestChannel, installTestChannel } from '../../channels/test-channel.ts'; + +const { id: recordServiceId } = mutableRecordLookupServiceDef; + +const createMockChannel = createTestChannel; +const installChannel = installTestChannel; + +afterEach(() => { + clearRegistry(); + installChannel(null); +}); + +// These tests exercise the server transport that `registerService` wires when a channel is present +// BEFORE registration — the dev server installs it in its `services` preset, so there is no separate +// connect step. The server is always a relay hub: one dev server bridges every connected manager tab. + +describe('registerService: channel wiring', () => { + it('wires the installed channel listeners on registration', () => { + const channel = createMockChannel(); + installChannel(channel); + + registerService(mutableRecordLookupServiceDef); + + expect(channel.on).toHaveBeenCalledWith(SERVICE_SYNC_START, expect.any(Function)); + expect(channel.on).toHaveBeenCalledWith(SERVICE_SYNC_START_REPLY, expect.any(Function)); + expect(channel.on).toHaveBeenCalledWith(SERVICE_PATCHES, expect.any(Function)); + }); + + it('throws when the addons channel is not installed', () => { + installChannel(null); + + expect(() => registerService(mutableRecordLookupServiceDef)).toThrow( + OpenServiceMissingChannelError + ); + }); +}); + +describe('server: command push', () => { + it('broadcasts the post-mutation snapshot after a local command', async () => { + const channel = createMockChannel(); + installChannel(channel); + + const service = registerService(mutableRecordLookupServiceDef); + + await service.commands.assignRecordField({ entryId: 'a', fieldKey: 'k', fieldValue: 'v' }); + + const patches = channel.emit.mock.calls.filter(([event]) => event === SERVICE_PATCHES); + // Exactly one: the broadcast echoes back on the shared bus, but its equal stamp fails `isNewer` + // so it is dropped instead of re-broadcast. + expect(patches).toHaveLength(1); + expect(patches[0][1]).toEqual( + expect.objectContaining({ + serviceId: recordServiceId, + state: expect.objectContaining({ a: { k: 'v' } }), + version: 1, + clientId: expect.any(String), + }) + ); + expect(service.queries.getRecordFields({ entryId: 'a' })).toEqual({ k: 'v' }); + }); + + it('advances the version on each subsequent command, keeping a stable clientId', async () => { + const channel = createMockChannel(); + installChannel(channel); + + const service = registerService(mutableRecordLookupServiceDef); + + await service.commands.assignRecordField({ entryId: 'a', fieldKey: 'k', fieldValue: '1' }); + await service.commands.assignRecordField({ entryId: 'a', fieldKey: 'k', fieldValue: '2' }); + + const patches = channel.emit.mock.calls.filter(([event]) => event === SERVICE_PATCHES); + expect(patches.map(([, p]) => (p as { version: number }).version)).toEqual([1, 2]); + expect((patches[1][1] as { clientId: string }).clientId).toBe( + (patches[0][1] as { clientId: string }).clientId + ); + expect(service.queries.getRecordFields({ entryId: 'a' })).toEqual({ k: '2' }); + }); +}); + +describe('server: sync-start initialization', () => { + it('replies to a sync-start with its current snapshot and stamp', () => { + const channel = createMockChannel(); + installChannel(channel); + + const service = registerService(mutableRecordLookupServiceDef); + + // Server adopts a peer patch: this both populates state and advances the server's stamp to the + // peer's (version, clientId). + channel.emitExternal(SERVICE_PATCHES, { + serviceId: recordServiceId, + state: { a: { k: 'v' } }, + version: 1, + clientId: 'peer-1', + }); + expect(service.queries.getRecordFields({ entryId: 'a' })).toEqual({ k: 'v' }); + + // A different peer comes online and asks for current state; the server answers with the snapshot + // it now holds, stamped with the version/clientId it adopted (not its own initial clientId). + channel.emitExternal(SERVICE_SYNC_START, { + serviceId: recordServiceId, + clientId: 'peer-2', + }); + + expect(channel.emit).toHaveBeenCalledWith( + SERVICE_SYNC_START_REPLY, + expect.objectContaining({ + serviceId: recordServiceId, + state: expect.objectContaining({ a: { k: 'v' } }), + version: 1, + clientId: 'peer-1', + }) + ); + }); + + it('does not reply to a sync-start for a different service id', () => { + const channel = createMockChannel(); + installChannel(channel); + + registerService(mutableRecordLookupServiceDef); + + channel.emitExternal(SERVICE_SYNC_START, { + serviceId: 'some-other-service', + clientId: 'peer-2', + }); + + const replyCalls = channel.emit.mock.calls.filter( + ([event]) => event === SERVICE_SYNC_START_REPLY + ); + expect(replyCalls).toHaveLength(0); + }); +}); + +describe('server: patch application', () => { + it('applies a version-gated patch from a peer', () => { + const channel = createMockChannel(); + installChannel(channel); + + const service = registerService(mutableRecordLookupServiceDef); + + channel.emitExternal(SERVICE_PATCHES, { + serviceId: recordServiceId, + state: { entry: { marker: 'set' } }, + version: 1, + clientId: 'peer', + }); + + expect(service.queries.getRecordFields({ entryId: 'entry' })).toEqual({ marker: 'set' }); + }); + + it('drops a stale (lower-version) patch arriving after a newer one', () => { + const channel = createMockChannel(); + installChannel(channel); + + const service = registerService(mutableRecordLookupServiceDef); + + channel.emitExternal(SERVICE_PATCHES, { + serviceId: recordServiceId, + state: { entry: { marker: 'new' } }, + version: 2, + clientId: 'peer', + }); + channel.emitExternal(SERVICE_PATCHES, { + serviceId: recordServiceId, + state: { entry: { marker: 'stale' } }, + version: 1, + clientId: 'peer', + }); + + expect(service.queries.getRecordFields({ entryId: 'entry' })).toEqual({ marker: 'new' }); + }); + + it('ignores patches for a different service id', () => { + const channel = createMockChannel(); + installChannel(channel); + + const service = registerService(mutableRecordLookupServiceDef); + + channel.emitExternal(SERVICE_PATCHES, { + serviceId: 'some-other-service', + state: { entry: { x: '1' } }, + version: 1, + clientId: 'peer', + }); + + expect(service.queries.getRecordFields({ entryId: 'entry' })).toBeNull(); + }); + + it('drops malformed patches without throwing or mutating state', () => { + const channel = createMockChannel(); + installChannel(channel); + + const service = registerService(mutableRecordLookupServiceDef); + + const malformed: unknown[] = [ + null, + {}, + { serviceId: recordServiceId, state: { a: { k: 'v' } }, clientId: 'p' }, + { serviceId: recordServiceId, state: 'nope', version: 1, clientId: 'p' }, + ]; + + for (const payload of malformed) { + expect(() => channel.emitExternal(SERVICE_PATCHES, payload)).not.toThrow(); + } + + expect(service.queries.getRecordFields({ entryId: 'a' })).toBeNull(); + }); +}); + +describe('server: teardown via clearRegistry', () => { + it('detaches channel listeners so later patches are ignored', () => { + const channel = createMockChannel(); + installChannel(channel); + + const service = registerService(mutableRecordLookupServiceDef); + + channel.emitExternal(SERVICE_PATCHES, { + serviceId: recordServiceId, + state: { entry: { marker: 'set' } }, + version: 1, + clientId: 'peer', + }); + expect(service.queries.getRecordFields({ entryId: 'entry' })).toEqual({ marker: 'set' }); + + clearRegistry(); + + expect(channel.off).toHaveBeenCalledWith(SERVICE_SYNC_START, expect.any(Function)); + expect(channel.off).toHaveBeenCalledWith(SERVICE_SYNC_START_REPLY, expect.any(Function)); + expect(channel.off).toHaveBeenCalledWith(SERVICE_PATCHES, expect.any(Function)); + + // A strictly-newer patch after teardown must not reach the now-detached runtime. + channel.emitExternal(SERVICE_PATCHES, { + serviceId: recordServiceId, + state: { entry: { marker: 'after' } }, + version: 2, + clientId: 'peer', + }); + expect(service.queries.getRecordFields({ entryId: 'entry' })).toEqual({ marker: 'set' }); + }); +}); + +describe('server: bootstrap on registration', () => { + it('emits a sync-start so a freshly-registered server can catch up', () => { + const channel = createMockChannel(); + installChannel(channel); + + registerService(mutableRecordLookupServiceDef); + + expect(channel.emit).toHaveBeenCalledWith( + SERVICE_SYNC_START, + expect.objectContaining({ serviceId: recordServiceId }) + ); + }); + + it('adopts state from a sync-start-reply (a late/restarted server catches up)', () => { + const channel = createMockChannel(); + installChannel(channel); + + const service = registerService(mutableRecordLookupServiceDef); + + // A peer answers the server's bootstrap request with state authored while the server was down. + channel.emitExternal(SERVICE_SYNC_START_REPLY, { + serviceId: recordServiceId, + state: { a: { k: 'v' } }, + version: 3, + clientId: 'peer-1', + }); + + expect(service.queries.getRecordFields({ entryId: 'a' })).toEqual({ k: 'v' }); + }); + + it('does not treat its own sync-start echo as incoming state', () => { + const channel = createMockChannel(); + installChannel(channel); + + const service = registerService(mutableRecordLookupServiceDef); + + // The bootstrap sync-start echoes back through the shared bus; it must not reply to itself + // nor mutate state. With no peer to answer, state stays empty. + expect(service.queries.getRecordFields({ entryId: 'a' })).toBeNull(); + const replyCalls = channel.emit.mock.calls.filter( + ([event]) => event === SERVICE_SYNC_START_REPLY + ); + expect(replyCalls).toHaveLength(0); + }); +}); + +describe('server: relay role', () => { + // The server is always a relay hub: one dev server bridges every connected manager tab. The mock + // is a shared bus, so a relayed emit bounces back to the server's own onPatches; the version gate + // must drop that echo instead of relaying it again. + function patchEmits(channel: ReturnType) { + return channel.emit.mock.calls.filter(([event]) => event === SERVICE_PATCHES); + } + + it('re-broadcasts a peer patch it adopts, preserving the original stamp', () => { + const channel = createMockChannel(); + installChannel(channel); + + registerService(mutableRecordLookupServiceDef); + + channel.emitExternal(SERVICE_PATCHES, { + serviceId: recordServiceId, + state: { entry: { marker: 'set' } }, + version: 1, + clientId: 'peer-1', + }); + + const relays = patchEmits(channel); + expect(relays).toHaveLength(1); + expect(relays[0][1]).toEqual( + expect.objectContaining({ + serviceId: recordServiceId, + state: expect.objectContaining({ entry: { marker: 'set' } }), + version: 1, + clientId: 'peer-1', + }) + ); + }); + + it('relays state it adopts during bootstrap (sync-start-reply)', () => { + const channel = createMockChannel(); + installChannel(channel); + + registerService(mutableRecordLookupServiceDef); + + channel.emitExternal(SERVICE_SYNC_START_REPLY, { + serviceId: recordServiceId, + state: { entry: { marker: 'boot' } }, + version: 4, + clientId: 'peer-1', + }); + + const relays = patchEmits(channel); + expect(relays).toHaveLength(1); + expect((relays[0][1] as { version: number }).version).toBe(4); + }); + + it('does not relay a patch it drops as stale, and terminates on the echo', () => { + const channel = createMockChannel(); + installChannel(channel); + + registerService(mutableRecordLookupServiceDef); + + const base = { serviceId: recordServiceId, clientId: 'peer-1' }; + channel.emitExternal(SERVICE_PATCHES, { + ...base, + state: { entry: { marker: 'new' } }, + version: 2, + }); + channel.emitExternal(SERVICE_PATCHES, { + ...base, + state: { entry: { marker: 'stale' } }, + version: 1, + }); + + const relays = patchEmits(channel); + expect(relays).toHaveLength(1); + expect((relays[0][1] as { version: number }).version).toBe(2); + }); +}); diff --git a/code/core/src/shared/open-service/service-registration.test.ts b/code/core/src/shared/open-service/service-registration.test.ts index 3fad6bfa7a27..e6a00ebf9041 100644 --- a/code/core/src/shared/open-service/service-registration.test.ts +++ b/code/core/src/shared/open-service/service-registration.test.ts @@ -87,11 +87,13 @@ describe('service registration', () => { ); }); - it('throws a Storybook error when a registered query or command is missing its handler', async () => { + it('throws a Storybook error when a registered query is missing its handler', () => { + // A command without a local handler does NOT throw here — it requests remote execution from a + // peer that implements it (see service-command-transport.test.ts). Queries stay local-only. const service = registerService( defineService({ id: 'internal-fixture/unimplemented-operations', - description: 'Leaves handlers undefined so registration can supply them later.', + description: 'Leaves the query handler undefined so registration can supply it later.', initialState: {} as Record, queries: { getValue: { @@ -100,25 +102,13 @@ describe('service registration', () => { output: v.string(), }, }, - commands: { - run: { - description: 'Runs a command that is not implemented in this environment.', - input: v.undefined(), - output: voidOutputSchema, - }, - }, + commands: {}, }) ); expect(() => service.queries.getValue(undefined)).toThrow( 'Query "internal-fixture/unimplemented-operations.getValue" is not implemented for this environment.' ); - await expect(service.commands.run(undefined)).rejects.toMatchObject({ - fromStorybook: true, - code: 8, - message: - 'Command "internal-fixture/unimplemented-operations.run" is not implemented for this environment.', - }); }); it('lets handlers resolve another registered service by id through ctx.getService', async () => { @@ -217,10 +207,10 @@ describe('service registration', () => { expect(descriptor.queries.getPreloadedValue.staticPath).toBe(true); }); - it('allows load and staticInputs to be supplied only at registration time', async () => { + it('allows staticInputs to be supplied only at registration time', async () => { const serviceDef = defineService({ id: 'internal-fixture/registration-only-static-build', - description: 'Declares staticPath in the definition and load at registration.', + description: 'Declares staticPath and load in the definition; staticInputs at registration.', initialState: { value: null as string | null }, queries: { getValue: { @@ -228,8 +218,10 @@ describe('service registration', () => { input: v.object({ build: v.literal('once') }), output: v.nullable(v.string()), handler: (_input, ctx) => ctx.self.state.value, + load: async (_input, ctx) => { + await ctx.self.commands.setValue(undefined); + }, staticPath: () => 'state.json', - staticInputs: async () => [{ build: 'once' as const }], }, }, commands: { @@ -244,9 +236,7 @@ describe('service registration', () => { registerService(serviceDef, { queries: { getValue: { - load: async (_input, ctx) => { - await ctx.self.commands.setValue(undefined); - }, + staticInputs: async () => [{ build: 'once' as const }], }, }, commands: { diff --git a/code/core/src/shared/open-service/service-registration.ts b/code/core/src/shared/open-service/service-registration.ts deleted file mode 100644 index 939dde0864dd..000000000000 --- a/code/core/src/shared/open-service/service-registration.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { createServiceRuntime } from './service-runtime.ts'; -import { - OpenServiceDuplicateRegistrationError, - OpenServiceMissingServiceError, -} from '../../server-errors.ts'; -import type { - AnyServiceDefinition, - AnyQueryDefinition, - Commands, - Queries, - RegisteredStaticInputs, - RuntimeService, - ServiceDefinition, - ServiceDescriptor, - ServiceId, - ServiceInstance, - ServiceInstanceOf, - ServiceRegistrationOptions, - ServiceRegistryApi, - ServiceSummary, -} from './types.ts'; -type RegistryEntry = { - definition: AnyServiceDefinition; - runtime: RuntimeService; - summary: ServiceSummary; - descriptor: ServiceDescriptor; -}; - -const OPEN_SERVICE_REGISTRY_SYMBOL = Symbol.for('storybook.open-service.registry'); - -/** - * Returns the process-global registry backing server-side service registration. - * - * The registry is anchored on a symbol-keyed `globalThis` slot so all modules in the same process - * share one registration map even if this file is imported through different paths. That keeps - * runtime lookups, static builds, and tests pointed at the same service inventory. - */ -function getRegistry(): Map { - const registryGlobal = globalThis as { - [key: symbol]: Map | undefined; - }; - - // Lazily create the registry so importing the module does not eagerly mutate global state. - registryGlobal[OPEN_SERVICE_REGISTRY_SYMBOL] ??= new Map(); - - return registryGlobal[OPEN_SERVICE_REGISTRY_SYMBOL]; -} - -/** - * Converts one service definition into the serializable descriptor returned by registry metadata - * APIs. - * - * Descriptors intentionally expose schemas and descriptions, but not runtime handlers, so callers - * can inspect the contract of a registered service without gaining access to executable behavior. - */ -function describeDefinition(definition: AnyServiceDefinition): ServiceDescriptor { - return { - id: definition.id, - description: definition.description, - queries: Object.fromEntries( - Object.entries(definition.queries).map(([name, query]) => [ - name, - { - name, - description: query.description, - input: query.input, - output: query.output, - ...(query.staticPath ? { staticPath: true as const } : {}), - }, - ]) - ), - commands: Object.fromEntries( - Object.entries(definition.commands).map(([name, command]) => [ - name, - { - name, - description: command.description, - input: command.input, - output: command.output, - }, - ]) - ), - }; -} - -/** - * Derives the lightweight summary returned by `listServices()` from a full descriptor. - * - * Keeping this separate avoids recomputing names from the live definition shape whenever callers - * only need discovery metadata for navigation or debugging UIs. - */ -function summarizeDescriptor(descriptor: ServiceDescriptor): ServiceSummary { - return { - id: descriptor.id, - description: descriptor.description, - queryNames: Object.keys(descriptor.queries), - commandNames: Object.keys(descriptor.commands), - }; -} - -/** - * Resolves the static input enumerator stored on a registered query. - * - * Registration may override the authored definition. When it does not, the definition's - * `staticInputs` is forwarded as-is so ctx-aware enumerators keep receiving `LoadCtx` at call time. - */ -function resolveRegisteredStaticInputs( - query: AnyQueryDefinition, - registrationQuery?: { staticInputs?: RegisteredStaticInputs } -): RegisteredStaticInputs | undefined { - if (registrationQuery?.staticInputs) { - return registrationQuery.staticInputs; - } - - return query.staticInputs; -} - -/** - * Applies optional server-side overrides to an authored service definition. - * - * Registration overrides are shallow merges over the authored definition. That lets the server - * swap handlers, load hooks, or dependency-aware static input enumerators per operation while the - * original schema contract, `staticPath`, and operation names remain the source of truth. - */ -function applyRegistration< - TState, - TQueries extends Queries, - TCommands extends Commands, ->( - definition: ServiceDefinition, - registration?: ServiceRegistrationOptions -): ServiceDefinition { - return { - ...definition, - queries: Object.fromEntries( - Object.entries(definition.queries).map(([name, query]) => { - const registrationQuery = registration?.queries?.[name as keyof TQueries]; - const staticInputs = resolveRegisteredStaticInputs(query, registrationQuery); - - return [ - name, - { - ...query, - ...registrationQuery, - ...(staticInputs ? { staticInputs } : {}), - }, - ]; - }) - ) as TQueries, - commands: Object.fromEntries( - Object.entries(definition.commands).map(([name, command]) => [ - name, - registration?.commands?.[name as keyof TCommands] - ? { ...command, ...registration.commands[name as keyof TCommands] } - : command, - ]) - ) as TCommands, - }; -} - -/** - * Shared registry API injected into registered runtimes and static-build runtimes. - * - * Exporting the object keeps all call sites on the same lookup implementation instead of each - * environment assembling a structurally identical wrapper. - */ -export const serviceRegistryApi: ServiceRegistryApi = { - listServices, - describeService, - getService, -}; - -/** - * Registers one service definition in the process-global registry and returns its runtime surface. - * - * Registration resolves any server-side operation overrides first, then builds the runtime that - * query and command callers will use, and finally stores both the runtime and its metadata in the - * shared registry. Duplicate ids are rejected up front so lookups remain deterministic. - */ -export function registerService< - TState, - TQueries extends Queries, - TCommands extends Commands, ->( - definition: ServiceDefinition, - registration?: ServiceRegistrationOptions -): ServiceInstance & ServiceRegistryApi { - const registry = getRegistry(); - - if (registry.has(definition.id)) { - throw new OpenServiceDuplicateRegistrationError({ serviceId: definition.id }); - } - - const resolvedDefinition = applyRegistration(definition, registration); - // The runtime mutates its state object in place, so give it a copy rather than the definition's - // shared `initialState` (which would otherwise leak state across registrations). - const runtime = createServiceRuntime( - resolvedDefinition, - { registryApi: serviceRegistryApi }, - structuredClone(resolvedDefinition.initialState) - ); - const registeredRuntime = { - queries: runtime.queries, - commands: runtime.commands, - ...serviceRegistryApi, - } as ServiceInstance & ServiceRegistryApi; - const descriptor = describeDefinition(resolvedDefinition as AnyServiceDefinition); - - // Persist the runtime together with precomputed metadata so later lookups stay cheap and do not - // need to rebuild descriptors from the authored definition each time. - registry.set(definition.id, { - definition: resolvedDefinition as AnyServiceDefinition, - runtime: registeredRuntime as unknown as RuntimeService, - descriptor, - summary: summarizeDescriptor(descriptor), - }); - - return registeredRuntime; -} - -/** - * Returns the authored definitions currently registered in this server process. - * - * Static build code uses this to discover which services contribute static snapshots. - */ -export function getRegisteredServices(): AnyServiceDefinition[] { - return Array.from(getRegistry().values(), ({ definition }) => definition); -} - -/** - * Returns one summary entry per registered service. - * - * This is the lowest-cost discovery endpoint for callers that only need ids, descriptions, and - * operation names. - */ -export async function listServices(): Promise { - return Array.from(getRegistry().values(), ({ summary }) => summary); -} - -/** - * Returns the schema-backed descriptor for one registered service. - * - * The descriptor mirrors the public contract of the service without exposing handlers or state. - */ -export async function describeService(serviceId: ServiceId): Promise { - const entry = getRegistry().get(serviceId); - - if (!entry) { - throw new OpenServiceMissingServiceError({ serviceId }); - } - - return entry.descriptor; -} - -/** - * Resolves a registered runtime service by id from the current server process. - * - * Query and command contexts delegate cross-service calls through this lookup so one service can - * reuse another service's runtime contract. Synchronous because callers need it available inside - * sync query handlers. - */ -export function getService(serviceId: ServiceId): RuntimeService; -export function getService( - serviceId: ServiceId -): ServiceInstanceOf; -export function getService( - serviceId: ServiceId -): RuntimeService | ServiceInstanceOf { - const entry = getRegistry().get(serviceId); - - if (!entry) { - throw new OpenServiceMissingServiceError({ serviceId }); - } - - return entry.runtime as unknown as ServiceInstanceOf; -} - -/** - * Clears the process-global registry. - * - * Tests call this after each case so registrations from one scenario do not leak into the next. - */ -export function clearRegistry(): void { - getRegistry().clear(); -} diff --git a/code/core/src/shared/open-service/service-registry.ts b/code/core/src/shared/open-service/service-registry.ts new file mode 100644 index 000000000000..f92458fae470 --- /dev/null +++ b/code/core/src/shared/open-service/service-registry.ts @@ -0,0 +1,358 @@ +/** + * Unified service registry for the open-service multi-master architecture. + * + * One implementation backs every runtime — the dev server (Node), the manager (top window), and each + * preview iframe. Registration builds a local `ServiceRuntime` and, when a channel is present, wires it + * into the cross-peer sync protocol through the shared transport. The only thing that differs per + * runtime is the `relay` role: the dev server and the manager are hubs (`relay: true`) that bridge + * their other channel transports, while a preview is a leaf (`relay: false`) — a single transport has + * nothing to forward. The handshake + patch-broadcast protocol lives in `service-transport.ts` and the + * last-write-wins reconciliation in `service-sync.ts`; both transports drive them identically. + * + * The registry is anchored on a symbol-keyed `globalThis` slot so every module in one realm shares a + * single registration map even if this file is reached through different import paths. Server (Node), + * manager (top window), and preview (iframe) are already isolated realms, so one symbol is correct for + * all three — they never share a map at runtime. + */ + +import { + OpenServiceDuplicateRegistrationError, + OpenServiceMissingChannelError, + OpenServiceMissingServiceError, +} from '../../server-errors.ts'; +import { getChannel } from '../../channels/channel-slot.ts'; +import { generateClientId } from './service-channel.ts'; +import { createServiceRuntime } from './service-runtime.ts'; +import { createSnapshotReconciler } from './service-sync.ts'; +import { connectServiceToChannel } from './service-transport.ts'; +import type { + AnyServiceDefinition, + Commands, + Queries, + RuntimeService, + ServiceDefinition, + ServiceDescriptor, + ServiceId, + ServiceInstance, + ServiceInstanceOf, + ServiceRegistrationOptions, + ServiceRegistryApi, + ServiceSummary, +} from './types.ts'; + +type RegistryEntry = { + definition: AnyServiceDefinition; + /** The public, channel-wrapped runtime surface returned from `registerService` and `getService`. */ + instance: RuntimeService; + descriptor: ServiceDescriptor; + summary: ServiceSummary; + /** Tears down this service's channel listeners. */ + disconnect: () => void; +}; + +const REGISTRY_SYMBOL = Symbol.for('storybook.open-service.registry'); + +/** + * Returns the realm-global registry backing service registration. + * + * Lazily created so importing the module does not eagerly mutate global state. Anchoring it on a + * `globalThis` symbol keeps runtime lookups, static builds, and tests pointed at one service inventory + * even when the module is reached through different import paths. + */ +function getRegistry(): Map { + const registryGlobal = globalThis as { + [key: symbol]: Map | undefined; + }; + + registryGlobal[REGISTRY_SYMBOL] ??= new Map(); + + return registryGlobal[REGISTRY_SYMBOL]; +} + +/** + * Converts one service definition into the serializable descriptor returned by registry metadata APIs. + * + * Descriptors expose schemas and descriptions but not runtime handlers, so callers can inspect a + * service's contract without gaining access to executable behavior. `staticPath: true` is surfaced so + * manager code can choose between live runtime queries and prebuilt JSON snapshots. + */ +function describeDefinition(definition: AnyServiceDefinition): ServiceDescriptor { + return { + id: definition.id, + description: definition.description, + queries: Object.fromEntries( + Object.entries(definition.queries).map(([name, query]) => [ + name, + { + name, + description: query.description, + input: query.input, + output: query.output, + ...(query.staticPath ? { staticPath: true as const } : {}), + }, + ]) + ), + commands: Object.fromEntries( + Object.entries(definition.commands).map(([name, command]) => [ + name, + { + name, + description: command.description, + input: command.input, + output: command.output, + }, + ]) + ), + }; +} + +/** Derives the lightweight summary returned by `listServices()` from a full descriptor. */ +function summarizeDescriptor(descriptor: ServiceDescriptor): ServiceSummary { + return { + id: descriptor.id, + description: descriptor.description, + queryNames: Object.keys(descriptor.queries), + commandNames: Object.keys(descriptor.commands), + }; +} + +/** + * Applies optional registration overrides to an authored definition. + * + * Query registration may supply dependency-aware `staticInputs` (used by server static builds); + * command registration may override the `handler`. Anything not overridden is forwarded unchanged so + * every runtime shares the same contract. + */ +function applyRegistration< + TState, + TQueries extends Queries, + TCommands extends Commands, +>( + definition: ServiceDefinition, + registration?: ServiceRegistrationOptions +): ServiceDefinition { + if (!registration) { + return definition; + } + + return { + ...definition, + queries: Object.fromEntries( + Object.entries(definition.queries).map(([name, query]) => { + const override = registration.queries?.[name as keyof TQueries]; + // Only `staticInputs` is a supported query override. Picking it explicitly stops untyped JS + // callers from merging unsupported keys (e.g. `handler`, `load`) into the definition. + return [ + name, + override && 'staticInputs' in override + ? { ...query, staticInputs: override.staticInputs } + : query, + ]; + }) + ) as TQueries, + commands: Object.fromEntries( + Object.entries(definition.commands).map(([name, command]) => { + const override = registration.commands?.[name as keyof TCommands]; + // Only `handler` is a supported command override. Picking it explicitly stops untyped JS + // callers from merging unsupported keys into the definition. + return [ + name, + override && 'handler' in override ? { ...command, handler: override.handler } : command, + ]; + }) + ) as TCommands, + }; +} + +/** + * Shared registry API injected into registered runtimes and static-build runtimes. + * + * Exporting the object keeps all call sites on one lookup implementation instead of each environment + * assembling a structurally identical wrapper. + */ +export const serviceRegistryApi: ServiceRegistryApi = { + listServices, + describeService, + getService, +}; + +/** Channel-sync options that depend on the entrypoint rather than the service definition. */ +export interface ServiceRegisterOptions { + /** + * Whether this runtime acts as a relay hub. Hubs (the dev server, the manager) re-broadcast every + * peer snapshot they adopt so peers on their *other* channel transports converge; leaves (a preview + * iframe) keep the default `false` — with a single transport there is nothing to forward. + */ + relay?: boolean; +} + +/** + * Registers one service definition in the realm-global registry and returns its runtime surface. + * + * Registration resolves any registration-time overrides, builds the runtime that query and command + * callers use, wraps commands to broadcast their post-mutation state, and joins the cross-peer sync + * protocol as a hub or leaf (`relay`). Each runtime must install the addons channel at its entry + * boundary before calling this (builders, manager boot, server `services` preset, or Node import + * bootstrap). Duplicate ids are rejected up front so lookups remain deterministic. + */ +export function registerService< + TState, + TQueries extends Queries, + TCommands extends Commands, +>( + definition: ServiceDefinition, + registration?: ServiceRegistrationOptions, + { relay = false }: ServiceRegisterOptions = {} +): ServiceInstance & ServiceRegistryApi { + const registry = getRegistry(); + + if (registry.has(definition.id)) { + throw new OpenServiceDuplicateRegistrationError({ serviceId: definition.id }); + } + + const ownClientId = generateClientId(); + const resolvedDefinition = applyRegistration(definition, registration); + + // The runtime mutates its state object in place, so give it a copy rather than the definition's + // shared `initialState` (which would otherwise leak state across registrations). + const runtime = createServiceRuntime( + resolvedDefinition, + { registryApi: serviceRegistryApi }, + structuredClone(resolvedDefinition.initialState) + ); + + // Owns the per-service last-write-wins stamp and the adopt/advance logic. Adopting a peer snapshot + // goes through `commandSelf.setState` — not the wrapped commands below — which is how the broadcast + // loop is prevented. + const reconciler = createSnapshotReconciler({ + setState: (mutate) => + runtime.commandSelf.setState((state) => mutate(state as Record)), + initialStamp: { version: 0, clientId: ownClientId }, + }); + + const getSnapshot = (): Record => + runtime.getStateSnapshot() as Record; + + const descriptor = describeDefinition(resolvedDefinition as AnyServiceDefinition); + + const channel = getChannel(); + if (!channel) { + throw new OpenServiceMissingChannelError({ serviceId: definition.id }); + } + + // A command may only have a handler in some runtimes (e.g. supplied at server registration). Where + // a local handler exists, callers run it locally and broadcast; where it does not, the resulting + // command routes calls to a peer that implements it and awaits the reply. + const implementedCommandNames = new Set( + Object.entries(resolvedDefinition.commands) + .filter(([, command]) => typeof command.handler === 'function') + .map(([name]) => name) + ); + + // Wire the runtime to the channel end to end against the one channel captured above: broadcast-wrap + // commands, run the remote-command protocol, and attach the sync-start + patch listeners. + const { commands, disconnect } = connectServiceToChannel({ + serviceId: definition.id, + ownClientId, + reconciler, + getSnapshot, + channel, + relay, + commands: runtime.commands as Record Promise>, + implementedCommandNames, + commandNames: Object.keys(resolvedDefinition.commands), + }); + + const instance = { + queries: runtime.queries, + commands: commands as ServiceInstance['commands'], + ...serviceRegistryApi, + } as ServiceInstance & ServiceRegistryApi; + + registry.set(definition.id, { + definition: resolvedDefinition as AnyServiceDefinition, + instance: instance as unknown as RuntimeService, + descriptor, + summary: summarizeDescriptor(descriptor), + disconnect, + }); + + return instance; +} + +/** + * Returns the authored definitions currently registered in this realm. + * + * The server static build uses this to discover which services contribute snapshots. + */ +export function getRegisteredServices(): AnyServiceDefinition[] { + return Array.from(getRegistry().values(), ({ definition }) => definition); +} + +/** Returns one summary entry per registered service — the lowest-cost discovery endpoint. */ +export async function listServices(): Promise { + return Array.from(getRegistry().values(), ({ summary }) => summary); +} + +/** Returns the schema-backed descriptor for one registered service. */ +export async function describeService(serviceId: ServiceId): Promise { + const entry = getRegistry().get(serviceId); + + if (!entry) { + throw new OpenServiceMissingServiceError({ serviceId }); + } + + return entry.descriptor; +} + +/** + * Resolves a registered runtime service by id from the current realm. + * + * Query and command contexts delegate cross-service calls through this lookup so one service can reuse + * another's runtime contract. Synchronous because callers need it inside sync query handlers. + */ +export function getService(serviceId: ServiceId): RuntimeService; +export function getService( + serviceId: ServiceId +): ServiceInstanceOf; +export function getService( + serviceId: ServiceId +): RuntimeService | ServiceInstanceOf { + const entry = getRegistry().get(serviceId); + + if (!entry) { + throw new OpenServiceMissingServiceError({ serviceId }); + } + + return entry.instance as unknown as ServiceInstanceOf; +} + +/** + * Removes one registered service and tears down its channel listeners. + * + * After this the id can be re-registered — useful for hot-reload scenarios where a definition changes. + */ +export function unregisterService(serviceId: ServiceId): void { + const entry = getRegistry().get(serviceId); + + if (entry) { + entry.disconnect(); + getRegistry().delete(serviceId); + } +} + +/** + * Clears the registry, tearing down each service's channel listeners first. + * + * Tests call this after each case so registrations — and the channel listeners a registration attaches + * — from one scenario do not leak into the next. + */ +export function clearRegistry(): void { + const registry = getRegistry(); + + for (const entry of registry.values()) { + entry.disconnect(); + } + + registry.clear(); +} diff --git a/code/core/src/shared/open-service/service-runtime.test.ts b/code/core/src/shared/open-service/service-runtime.test.ts index f9f34a9e7a72..467329c552d7 100644 --- a/code/core/src/shared/open-service/service-runtime.test.ts +++ b/code/core/src/shared/open-service/service-runtime.test.ts @@ -2,7 +2,7 @@ import * as v from 'valibot'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { defineService } from './service-definition.ts'; -import { serviceRegistryApi } from './service-registration.ts'; +import { serviceRegistryApi } from './service-registry.ts'; import { createServiceRuntime } from './service-runtime.ts'; import { clearRegistry, registerService } from './server.ts'; import { diff --git a/code/core/src/shared/open-service/service-sync.ts b/code/core/src/shared/open-service/service-sync.ts new file mode 100644 index 000000000000..5323b0dddd4e --- /dev/null +++ b/code/core/src/shared/open-service/service-sync.ts @@ -0,0 +1,173 @@ +/** + * Shared sync primitives for the open-service multi-master protocol. + * + * Every runtime — server (Node), manager (top window), preview (iframe) — runs a full + * `ServiceRuntime` and reconciles incoming state with the same two rules — last-write-wins ordering + * and structural merge — so this module is the single source of truth for all of them. The + * transport that moves snapshots on and off the channel lives in `service-transport.ts`, which every + * `registerService` entrypoint drives through these primitives (see `service-transport-leaf.test.ts` + * and `service-registration-sync.test.ts`). + * + * ## 1. `isNewer` — last-write-wins ordering + * + * Each synced snapshot carries a `(version, clientId)` stamp. `version` is a logical clock for the + * state lineage: a runtime bumps it on every local command and adopts the incoming value when it + * accepts a peer's snapshot. Equal versions mean concurrent writes; the lexicographically greater + * `clientId` wins so every runtime independently converges on the same snapshot regardless of the + * order events arrive in. + * + * Crucially, an *equal* stamp is **not** newer. That single fact is what makes the protocol + * echo-safe and relay-safe: a snapshot a runtime already holds (its own broadcast bouncing back, + * or a hub re-emitting an already-applied patch) fails `isNewer` and is dropped instead of + * re-applied and re-broadcast, so update storms terminate. + * + * The stamp lives in the channel envelope, never inside the user state object. Service authors and + * consumers never declare it, read it, or subscribe to it — whole-state-per-service LWW is a + * documented semantic of the protocol, not a field anyone has to think about. + * + * ## 2. `deepReconcile` — structural merge + * + * Applies a received snapshot onto the live state object in place so that deep-signal subscriptions + * only re-fire for the fields that actually changed. Keys absent from the source are deleted (so + * deletions propagate), arrays are replaced wholesale, primitives are assigned only when changed, + * and the dangerous `__proto__`/`constructor`/`prototype` keys are skipped on both read and delete + * so a hostile channel payload cannot pollute the prototype chain. + */ + +/** Per-service last-write-wins stamp carried alongside every synced snapshot. */ +export type SyncStamp = { + /** Logical clock for the state lineage. Bumped on every local command, adopted on accept. */ + version: number; + /** Id of the runtime that produced this version; the deterministic tiebreak for equal versions. */ + clientId: string; +}; + +/** + * Returns whether `incoming` should replace `local` under last-write-wins ordering. + * + * Higher `version` always wins. At an equal version (concurrent writes) the lexicographically + * greater `clientId` wins so every runtime picks the same winner. An equal stamp is **not** newer — + * that is precisely what makes echoes and relayed re-broadcasts terminate rather than loop. + */ +export function isNewer(incoming: SyncStamp, local: SyncStamp): boolean { + if (incoming.version !== local.version) { + return incoming.version > local.version; + } + + return incoming.clientId > local.clientId; +} + +/** Keys never copied from an untrusted payload, to block prototype-pollution. */ +const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Deep-merges `source` onto `target` in place. + * + * - Recurses into plain objects so nested deep-signal subscriptions stay intact and only changed + * leaves notify. + * - Deletes keys present in `target` but absent from `source` so deletions propagate between peers + * (the old additive-only merge could never remove a key). + * - Replaces arrays wholesale and assigns primitives directly, writing only when the value actually + * changed to avoid spurious signal invalidation. + * - Skips `__proto__`/`constructor`/`prototype` on both the delete and the assign pass so a + * malicious payload cannot walk up the prototype chain. + */ +export function deepReconcile( + target: Record, + source: Record +): void { + // Remove keys the source no longer carries (deletion propagation). + for (const key of Object.keys(target)) { + if (FORBIDDEN_KEYS.has(key)) { + continue; + } + + if (!Object.prototype.hasOwnProperty.call(source, key)) { + delete target[key]; + } + } + + // Merge or assign keys the source provides. + for (const key of Object.keys(source)) { + if (FORBIDDEN_KEYS.has(key)) { + continue; + } + + const sourceValue = source[key]; + const targetValue = target[key]; + + if (isPlainObject(sourceValue) && isPlainObject(targetValue)) { + deepReconcile(targetValue, sourceValue); + } else if (targetValue !== sourceValue) { + target[key] = sourceValue; + } + } +} + +/** In-place mutation of a runtime's live state object, as exposed by `commandSelf.setState`. */ +export type StateMutator = (state: Record) => void; + +/** + * The per-service reconciler shared by every runtime's channel integration. + * + * It owns the last-write-wins stamp and exposes the only two stamp transitions the protocol allows: + * advancing for a locally authored change, and adopting a strictly-newer peer snapshot. Centralizing + * this here is deliberate — the client and server transports used to each carry their own copy of + * the merge logic, which is exactly how they could silently drift apart. + */ +export type SnapshotReconciler = { + /** The current local stamp (read for sync-start-reply / broadcast envelopes). */ + readonly stamp: SyncStamp; + /** + * Records a locally authored change: bumps `version` and re-stamps with `clientId`. Call this + * before broadcasting so the broadcast's own echo is recognized as not-newer and dropped. + */ + advanceLocal(clientId: string): SyncStamp; + /** + * Adopts an incoming snapshot iff it is strictly newer (LWW). Returns whether it was adopted, so + * relay hubs can re-broadcast only on a real advance. + */ + tryAdopt(incoming: SyncStamp, state: Record): boolean; +}; + +/** + * Builds a {@link SnapshotReconciler} bound to one runtime's state. + * + * @param setState - The runtime's batched in-place mutator (`commandSelf.setState`), adapted to a + * plain record. Adopting goes through this rather than the wrapped commands so it never triggers + * a re-broadcast. + * @param initialStamp - Starting stamp, typically `{ version: 0, clientId: }`. + */ +export function createSnapshotReconciler(options: { + setState: (mutate: StateMutator) => void; + initialStamp: SyncStamp; +}): SnapshotReconciler { + const { setState, initialStamp } = options; + let localStamp = initialStamp; + + return { + get stamp(): SyncStamp { + return localStamp; + }, + + advanceLocal(clientId: string): SyncStamp { + localStamp = { version: localStamp.version + 1, clientId }; + return localStamp; + }, + + tryAdopt(incoming: SyncStamp, state: Record): boolean { + if (!isNewer(incoming, localStamp)) { + return false; + } + + localStamp = { version: incoming.version, clientId: incoming.clientId }; + setState((current) => deepReconcile(current, state)); + + return true; + }, + }; +} diff --git a/code/core/src/shared/open-service/service-transport-leaf.test.ts b/code/core/src/shared/open-service/service-transport-leaf.test.ts new file mode 100644 index 000000000000..f51a5c22bd06 --- /dev/null +++ b/code/core/src/shared/open-service/service-transport-leaf.test.ts @@ -0,0 +1,382 @@ +/** + * Channel sync tests for the default leaf registration path (`relay: false`). + * + * Hub (dev-server) behavior lives in {@link ./service-registration-sync.test.ts}. + * Runtime queries, subscriptions, and registry metadata live in + * {@link ./service-runtime.test.ts} and {@link ./service-registration.test.ts}. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { mutableRecordLookupServiceDef } from './fixtures.ts'; +import { + SERVICE_PATCHES, + SERVICE_SYNC_START_REPLY, + SERVICE_SYNC_START, +} from './service-channel.ts'; +import { clearRegistry, registerService, unregisterService } from './service-registry.ts'; +import { createTestChannel, installTestChannel } from '../../channels/test-channel.ts'; + +const createMockChannel = createTestChannel; +const installChannel = installTestChannel; + +afterEach(() => { + clearRegistry(); + installChannel(null); +}); + +describe('registerService (leaf)', () => { + it('allows re-registration after unregisterService', () => { + installChannel(createMockChannel()); + registerService(mutableRecordLookupServiceDef); + unregisterService(mutableRecordLookupServiceDef.id); + + expect(() => registerService(mutableRecordLookupServiceDef)).not.toThrow(); + }); +}); + +describe('channel: sync-start initialization (leaf)', () => { + it('adopts a sync-start-reply from a hub that was not listening at registration time', async () => { + const channel = createMockChannel(); + installChannel(channel); + + const preview = registerService(mutableRecordLookupServiceDef, undefined, { + relay: false, + }); + + expect(channel.emit).toHaveBeenCalledWith( + SERVICE_SYNC_START, + expect.objectContaining({ serviceId: mutableRecordLookupServiceDef.id }) + ); + + channel.emitExternal(SERVICE_SYNC_START_REPLY, { + serviceId: mutableRecordLookupServiceDef.id, + state: { 'entry-late': { marker: 'synced' } }, + version: 1, + clientId: 'manager-hub', + }); + + expect(preview.queries.getRecordFields({ entryId: 'entry-late' })).toEqual({ + marker: 'synced', + }); + + expect(channel.emit.mock.calls.filter(([event]) => event === SERVICE_SYNC_START).length).toBe( + 1 + ); + }); + + it('converges via patches when a sync-start-reply carried stale v0 state', async () => { + const channel = createMockChannel(); + installChannel(channel); + + const preview = registerService(mutableRecordLookupServiceDef, undefined, { + relay: false, + }); + + channel.emitExternal(SERVICE_SYNC_START_REPLY, { + serviceId: mutableRecordLookupServiceDef.id, + state: { 'entry-stale': { marker: 'v0' } }, + version: 0, + clientId: 'early-hub', + }); + + channel.emitExternal(SERVICE_PATCHES, { + serviceId: mutableRecordLookupServiceDef.id, + state: { 'entry-stale': { marker: 'v1' } }, + version: 1, + clientId: 'early-hub', + }); + + expect(preview.queries.getRecordFields({ entryId: 'entry-stale' })).toEqual({ + marker: 'v1', + }); + }); +}); + +describe('channel: patch broadcast (leaf)', () => { + it('does not re-apply its own patch echo (loop prevention)', async () => { + const channel = createMockChannel(); + installChannel(channel); + + const service = registerService(mutableRecordLookupServiceDef); + + const received: unknown[] = []; + service.queries.getRecordFields.subscribe({ entryId: 'a' }, (v) => received.push(v)); + await vi.waitFor(() => expect(received).toHaveLength(1)); + + await service.commands.assignRecordField({ entryId: 'a', fieldKey: 'k', fieldValue: 'v' }); + await vi.waitFor(() => expect(received).toHaveLength(2)); + + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(received).toHaveLength(2); + }); +}); + +describe('channel: last-write-wins convergence', () => { + it('converges on the higher clientId for concurrent (equal-version) writes', async () => { + const channel = createMockChannel(); + installChannel(channel); + + const service = registerService(mutableRecordLookupServiceDef); + + channel.emitExternal(SERVICE_PATCHES, { + serviceId: mutableRecordLookupServiceDef.id, + state: { item: { color: 'red' } }, + version: 1, + clientId: 'aaa', + }); + channel.emitExternal(SERVICE_PATCHES, { + serviceId: mutableRecordLookupServiceDef.id, + state: { item: { color: 'blue' } }, + version: 1, + clientId: 'zzz', + }); + + await vi.waitFor(() => + expect(service.queries.getRecordFields({ entryId: 'item' })).toEqual({ color: 'blue' }) + ); + }); + + it('converges on the same winner regardless of arrival order', async () => { + const channel = createMockChannel(); + installChannel(channel); + + const service = registerService(mutableRecordLookupServiceDef); + + channel.emitExternal(SERVICE_PATCHES, { + serviceId: mutableRecordLookupServiceDef.id, + state: { item: { color: 'blue' } }, + version: 1, + clientId: 'zzz', + }); + channel.emitExternal(SERVICE_PATCHES, { + serviceId: mutableRecordLookupServiceDef.id, + state: { item: { color: 'red' } }, + version: 1, + clientId: 'aaa', + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(service.queries.getRecordFields({ entryId: 'item' })).toEqual({ color: 'blue' }); + }); + + it('a higher version wins even against a greater clientId', async () => { + const channel = createMockChannel(); + installChannel(channel); + + const service = registerService(mutableRecordLookupServiceDef); + + channel.emitExternal(SERVICE_PATCHES, { + serviceId: mutableRecordLookupServiceDef.id, + state: { item: { color: 'blue' } }, + version: 1, + clientId: 'zzz', + }); + channel.emitExternal(SERVICE_PATCHES, { + serviceId: mutableRecordLookupServiceDef.id, + state: { item: { color: 'green' } }, + version: 2, + clientId: 'aaa', + }); + + await vi.waitFor(() => + expect(service.queries.getRecordFields({ entryId: 'item' })).toEqual({ color: 'green' }) + ); + }); + + it('drops a stale (lower-version) patch arriving after a newer one', async () => { + const channel = createMockChannel(); + installChannel(channel); + + const service = registerService(mutableRecordLookupServiceDef); + + channel.emitExternal(SERVICE_PATCHES, { + serviceId: mutableRecordLookupServiceDef.id, + state: { item: { color: 'green' } }, + version: 2, + clientId: 'peer', + }); + channel.emitExternal(SERVICE_PATCHES, { + serviceId: mutableRecordLookupServiceDef.id, + state: { item: { color: 'red' } }, + version: 1, + clientId: 'peer', + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(service.queries.getRecordFields({ entryId: 'item' })).toEqual({ color: 'green' }); + }); +}); + +describe('channel: multi-peer sync-start bootstrap', () => { + it('converges on the newest sync-start-reply when several peers answer out of order', async () => { + const channel = createMockChannel(); + installChannel(channel); + + const service = registerService(mutableRecordLookupServiceDef); + + channel.emitExternal(SERVICE_SYNC_START_REPLY, { + serviceId: mutableRecordLookupServiceDef.id, + state: { item: { v: '1' } }, + version: 1, + clientId: 'p1', + }); + channel.emitExternal(SERVICE_SYNC_START_REPLY, { + serviceId: mutableRecordLookupServiceDef.id, + state: { item: { v: '3' } }, + version: 3, + clientId: 'p3', + }); + channel.emitExternal(SERVICE_SYNC_START_REPLY, { + serviceId: mutableRecordLookupServiceDef.id, + state: { item: { v: '2' } }, + version: 2, + clientId: 'p2', + }); + + await vi.waitFor(() => + expect(service.queries.getRecordFields({ entryId: 'item' })).toEqual({ v: '3' }) + ); + }); +}); + +describe('channel: deletion propagation', () => { + it('deletes keys that are absent from a newer snapshot', async () => { + const channel = createMockChannel(); + installChannel(channel); + + const service = registerService(mutableRecordLookupServiceDef); + + channel.emitExternal(SERVICE_PATCHES, { + serviceId: mutableRecordLookupServiceDef.id, + state: { a: { k: 'v' }, b: { k: 'w' } }, + version: 1, + clientId: 'peer', + }); + await vi.waitFor(() => + expect(service.queries.getRecordFields({ entryId: 'b' })).toEqual({ k: 'w' }) + ); + + channel.emitExternal(SERVICE_PATCHES, { + serviceId: mutableRecordLookupServiceDef.id, + state: { a: { k: 'v' } }, + version: 2, + clientId: 'peer', + }); + + await vi.waitFor(() => expect(service.queries.getRecordFields({ entryId: 'b' })).toBeNull()); + expect(service.queries.getRecordFields({ entryId: 'a' })).toEqual({ k: 'v' }); + }); +}); + +describe('channel: untrusted payloads', () => { + it('does not pollute Object.prototype from a hostile snapshot', async () => { + const channel = createMockChannel(); + installChannel(channel); + + const service = registerService(mutableRecordLookupServiceDef); + + const hostileState = JSON.parse( + '{"good":{"k":"v"},"__proto__":{"polluted":"yes"},"constructor":{"polluted":"yes"}}' + ); + + expect(() => + channel.emitExternal(SERVICE_PATCHES, { + serviceId: mutableRecordLookupServiceDef.id, + state: hostileState, + version: 1, + clientId: 'attacker', + }) + ).not.toThrow(); + + await vi.waitFor(() => + expect(service.queries.getRecordFields({ entryId: 'good' })).toEqual({ k: 'v' }) + ); + expect(({} as Record).polluted).toBeUndefined(); + expect((Object.prototype as Record).polluted).toBeUndefined(); + }); + + it('drops malformed sync-start-reply and patch payloads without mutating state', async () => { + const channel = createMockChannel(); + installChannel(channel); + + const service = registerService(mutableRecordLookupServiceDef); + + const malformed: unknown[] = [ + null, + {}, + { serviceId: mutableRecordLookupServiceDef.id, state: { a: { k: 'v' } }, clientId: 'p' }, + { + serviceId: mutableRecordLookupServiceDef.id, + state: 'not-an-object', + version: 1, + clientId: 'p', + }, + ]; + + for (const payload of malformed) { + expect(() => channel.emitExternal(SERVICE_PATCHES, payload)).not.toThrow(); + expect(() => channel.emitExternal(SERVICE_SYNC_START_REPLY, payload)).not.toThrow(); + } + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(service.queries.getRecordFields({ entryId: 'a' })).toBeNull(); + }); +}); + +describe('channel: relay role (leaf)', () => { + function patchEmits(channel: ReturnType) { + return channel.emit.mock.calls.filter(([event]) => event === SERVICE_PATCHES); + } + + it('adopts a peer patch but never re-broadcasts it', () => { + const channel = createMockChannel(); + installChannel(channel); + + const service = registerService(mutableRecordLookupServiceDef); + + channel.emitExternal(SERVICE_PATCHES, { + serviceId: mutableRecordLookupServiceDef.id, + state: { item: { color: 'red' } }, + version: 1, + clientId: 'peer-1', + }); + + expect(service.queries.getRecordFields({ entryId: 'item' })).toEqual({ color: 'red' }); + expect(patchEmits(channel)).toHaveLength(0); + }); +}); + +describe('channel: disconnect on unregister', () => { + it('detaches listeners and ignores later peer patches', async () => { + const channel = createMockChannel(); + installChannel(channel); + + const service = registerService(mutableRecordLookupServiceDef); + + channel.emitExternal(SERVICE_PATCHES, { + serviceId: mutableRecordLookupServiceDef.id, + state: { entry: { marker: 'before' } }, + version: 1, + clientId: 'peer', + }); + await vi.waitFor(() => + expect(service.queries.getRecordFields({ entryId: 'entry' })).toEqual({ marker: 'before' }) + ); + + unregisterService(mutableRecordLookupServiceDef.id); + + expect(channel.off).toHaveBeenCalledWith(SERVICE_SYNC_START, expect.any(Function)); + expect(channel.off).toHaveBeenCalledWith(SERVICE_SYNC_START_REPLY, expect.any(Function)); + expect(channel.off).toHaveBeenCalledWith(SERVICE_PATCHES, expect.any(Function)); + + channel.emitExternal(SERVICE_PATCHES, { + serviceId: mutableRecordLookupServiceDef.id, + state: { entry: { marker: 'after' } }, + version: 2, + clientId: 'peer', + }); + + expect(service.queries.getRecordFields({ entryId: 'entry' })).toEqual({ marker: 'before' }); + }); +}); diff --git a/code/core/src/shared/open-service/service-transport.ts b/code/core/src/shared/open-service/service-transport.ts new file mode 100644 index 000000000000..ba8f312c5ad3 --- /dev/null +++ b/code/core/src/shared/open-service/service-transport.ts @@ -0,0 +1,470 @@ +/** + * Shared channel-transport helpers for the open-service multi-master protocol. + * + * Every runtime that participates in cross-peer sync — the manager (top window), a preview iframe, + * and the dev server (Node) — does the same two things with its channel: it broadcasts the state its + * own commands author, and it listens for peers' snapshots so it can reconcile. This module owns both + * halves so leaf registration (`service-registry.ts`, `relay: false`) and hub registration + * (`server.ts`, `relay: true`) cannot drift apart in how they wrap commands, gate echoes, or relay + * adopted state. + * + * - {@link connectServiceToChannel} is the single entry point `registerService` uses. It wires all + * three halves below against one channel so they can never be assembled inconsistently, and returns + * the command map callers expose plus a combined teardown. + * - {@link wrapCommandsForBroadcast} wraps a runtime's commands so each local call, after it resolves, + * advances the last-write-wins stamp and broadcasts the full post-mutation snapshot. + * - {@link connectRuntimeToChannel} attaches the sync-start initialization and patch listeners, emits + * the bootstrap sync-start, and returns a teardown. A `relay` hub re-broadcasts every snapshot it + * adopts so peers on its *other* transports converge; leaves keep `relay: false`. + * - {@link connectCommandTransport} bridges the gap where a command is only implemented in *some* + * runtimes (e.g. a handler supplied at server registration). A runtime without a local handler + * requests remote execution; a runtime that has one listens for those requests, runs the command, + * and replies. See its docs for the request/ack/result/error protocol. + * + * The merge and ordering rules themselves live in `service-sync.ts`; this module only moves snapshots + * on and off the channel. + */ + +import * as v from 'valibot'; + +import { OpenServiceRemoteCommandDisconnectedError } from '../../server-errors.ts'; +import { + SERVICE_COMMAND_ACK, + SERVICE_COMMAND_ERROR, + SERVICE_COMMAND_INVOKE, + SERVICE_COMMAND_RESULT, + SERVICE_PATCHES, + SERVICE_SYNC_START, + SERVICE_SYNC_START_REPLY, + type CommandAckPayload, + type CommandErrorPayload, + type CommandInvokePayload, + type CommandResultPayload, + type PatchesPayload, + type ServiceChannel, + type SyncStartPayload, + type SyncStartReplyPayload, + commandErrorSchema, + commandInvokeSchema, + commandResultSchema, + generateClientId, + stampedSnapshotSchema, + syncStartSchema, +} from './service-channel.ts'; +import { deserializeError, serializeError } from './service-error-serialization.ts'; +import type { SnapshotReconciler } from './service-sync.ts'; +import type { ServiceId } from './types.ts'; + +/** A runtime command as seen by the transport layer: `(input) => Promise`. */ +type RuntimeCommand = (input: unknown) => Promise; + +/** The shared bits both helpers need: which service, who we are, and our sync state. */ +interface RuntimeTransportContext { + /** Id of the service these helpers act for; stamped on every emitted envelope. */ + serviceId: ServiceId; + /** This runtime's stable id, used to drop its own bootstrap request and its own echoes. */ + ownClientId: string; + /** The reconciler owning this runtime's LWW stamp and its adopt/advance transitions. */ + reconciler: SnapshotReconciler; + /** Reads the runtime's current live state at emit time. */ + getSnapshot: () => Record; +} + +/** + * Wraps each command so a successful local call broadcasts the full post-mutation snapshot. + * + * After the wrapped command resolves we advance the stamp (making this runtime the new author) and + * emit `services:patches`. Advancing BEFORE emitting is what makes the echo safe: the copy that + * bounces back carries our just-advanced stamp and fails `isNewer`, so it is dropped instead of + * re-applied. State adopted from peers flows through the reconciler's `setState`, never through these + * wrappers, so an adopted snapshot never triggers a re-broadcast. + */ +export function wrapCommandsForBroadcast( + commands: Record, + context: RuntimeTransportContext & { channel: ServiceChannel } +): Record { + const { serviceId, ownClientId, reconciler, getSnapshot, channel } = context; + + return Object.fromEntries( + Object.entries(commands).map(([name, cmd]) => [ + name, + async (input: unknown): Promise => { + const result = await cmd(input); + + // A local command makes this runtime the new author: advance the stamp BEFORE emitting so the + // broadcast bouncing back to us is recognized as not-newer (equal stamp) and dropped. + const stamp = reconciler.advanceLocal(ownClientId); + + channel.emit(SERVICE_PATCHES, { + serviceId, + state: getSnapshot(), + version: stamp.version, + clientId: stamp.clientId, + } satisfies PatchesPayload); + + return result; + }, + ]) + ); +} + +/** + * Attaches the channel listeners that keep one runtime in sync with its peers, and returns a teardown. + * + * Wires three handlers and emits a bootstrap `services:sync-start` so a freshly-registered runtime + * catches up to state authored before it joined: + * - sync-start → reply with our current snapshot+stamp (ignoring our own request); + * - sync-start-reply / patches → adopt iff strictly newer (and, on a relay hub, re-broadcast). + * + * A `relay` hub re-emits every snapshot it adopts under the SAME adopted stamp, so peers reachable on + * its *other* transports converge while the copy bouncing back fails `isNewer` and stops the loop. + * Leaves (a preview iframe) keep `relay: false`: with a single transport there is nothing to forward. + */ +export function connectRuntimeToChannel( + context: RuntimeTransportContext & { channel: ServiceChannel; relay: boolean } +): () => void { + const { serviceId, ownClientId, reconciler, getSnapshot, channel, relay } = context; + + const emitSyncStart = (): void => { + channel.emit(SERVICE_SYNC_START, { + serviceId, + clientId: ownClientId, + } satisfies SyncStartPayload); + }; + + // Relay hub only: forward an adopted snapshot to peers on our OTHER transports. We re-emit the + // canonical post-merge snapshot under the SAME adopted stamp, so the copy that bounces back fails + // `isNewer` and is dropped instead of looping. + const relayAdopted = (): void => { + channel.emit(SERVICE_PATCHES, { + serviceId, + state: getSnapshot(), + version: reconciler.stamp.version, + clientId: reconciler.stamp.clientId, + } satisfies PatchesPayload); + }; + + const adoptPeerSnapshot = (snapshot: { + version: number; + clientId: string; + state: Record; + }): boolean => { + const adopted = reconciler.tryAdopt( + { version: snapshot.version, clientId: snapshot.clientId }, + snapshot.state + ); + + if (adopted && relay) { + relayAdopted(); + } + + return adopted; + }; + + // Reply to a peer's sync-start with our current snapshot+stamp (which may be one we adopted from yet + // another peer, not necessarily our own clientId). Skip our own bootstrap request. + const onSyncStart = (payload: unknown): void => { + const request = v.safeParse(syncStartSchema, payload); + if ( + !request.success || + request.output.serviceId !== serviceId || + request.output.clientId === ownClientId + ) { + return; + } + + channel.emit(SERVICE_SYNC_START_REPLY, { + serviceId, + state: getSnapshot(), + version: reconciler.stamp.version, + clientId: reconciler.stamp.clientId, + } satisfies SyncStartReplyPayload); + }; + + // Bootstrap from a peer's sync-start-reply. Version-gating (not a first-reply-only guard) is what + // makes this converge when several peers reply: each reply is adopted only if strictly newer. + const onSyncStartReply = (payload: unknown): void => { + const snapshot = v.safeParse(stampedSnapshotSchema, payload); + if (!snapshot.success || snapshot.output.serviceId !== serviceId) { + return; + } + adoptPeerSnapshot(snapshot.output); + }; + + // Apply patches from peers. The version gate drops echoes of our own broadcast and any + // already-applied snapshot, so no explicit self-clientId check is needed here. + const onPatches = (payload: unknown): void => { + const snapshot = v.safeParse(stampedSnapshotSchema, payload); + if (!snapshot.success || snapshot.output.serviceId !== serviceId) { + return; + } + adoptPeerSnapshot(snapshot.output); + }; + + channel.on(SERVICE_SYNC_START, onSyncStart); + channel.on(SERVICE_SYNC_START_REPLY, onSyncStartReply); + channel.on(SERVICE_PATCHES, onPatches); + + // Ask any existing peer for the current state so we catch up to changes authored before we joined. + emitSyncStart(); + + // A hub that already holds peer-adopted state (e.g. after a hot reload) pushes once so late + // joiners on other transports can converge without waiting for another mutation. + if (relay && reconciler.stamp.version > 0) { + relayAdopted(); + } + + return (): void => { + channel.off(SERVICE_SYNC_START, onSyncStart); + channel.off(SERVICE_SYNC_START_REPLY, onSyncStartReply); + channel.off(SERVICE_PATCHES, onPatches); + }; +} + +/** + * Wires the remote-command-execution protocol for one service runtime, returning the command map + * callers should use plus a teardown. + * + * ## Why this exists + * + * A command handler can be supplied at registration in only *some* runtimes — the canonical case is + * a server-only handler that needs Node APIs or server context. Runtimes without a local handler + * still expose the command (so `useServiceCommand`, tests, and other services can call it), but they + * cannot run it themselves; they must ask a peer that can. + * + * ## Protocol + * + * - **Requester** (no local handler): the returned command emits `services:command-invoke` with a + * unique `callId` and returns a promise. The promise resolves with the first + * `services:command-result` for that `callId`, or rejects with the (deserialized) error from the + * first `services:command-error`. + * - **Responder** (has a local handler): on a matching `services:command-invoke` it emits + * `services:command-ack` immediately, runs the command locally (which also broadcasts the + * post-mutation state via the broadcast wrappers, so peers converge as usual), then emits + * `services:command-result` or `services:command-error`. + * + * Both roles coexist on one runtime: it responds for the commands it implements and requests the + * ones it does not. A runtime never requests a command it implements (it runs that locally), so it + * never answers its own invoke echo. + * + * ## Multiple implementers + * + * If several peers implement the same command they will each run it and reply. The requester keeps + * only the first reply per `callId` and ignores the rest. Running a command in more than one runtime + * is therefore possible by construction; it is constrained by usage conventions and documentation + * rather than enforced here. + * + * ## Topology + * + * Replies travel back over the same channel the invoke went out on. A request reaches every peer on + * the requester's transports; the manager is connected to both the dev server and the preview, so it + * can invoke a command implemented in either. Command events are *not* relayed across a hub's other + * transports, so a preview cannot directly invoke a server-only command (and vice versa) — route such + * calls through the manager or implement the command on a directly-connected peer. + */ +export function connectCommandTransport(context: { + /** Id of the service these commands belong to; stamped on every emitted envelope. */ + serviceId: ServiceId; + /** This runtime's stable id, stamped on replies so peers know who answered. */ + ownClientId: string; + channel: ServiceChannel; + /** + * Broadcast-wrapped local commands keyed by name. Only entries in {@link implementedCommandNames} + * are runnable; the rest are present only so the map is complete. + */ + localCommands: Record; + /** Command names that have a local handler in this runtime. */ + implementedCommandNames: ReadonlySet; + /** Every command name declared by the service definition. */ + commandNames: readonly string[]; +}): { commands: Record; disconnect: () => void } { + const { serviceId, ownClientId, channel, localCommands, implementedCommandNames, commandNames } = + context; + + // Requester bookkeeping: in-flight remote calls keyed by callId, settled by the first reply. + const pending = new Map< + string, + { resolve: (value: unknown) => void; reject: (error: unknown) => void } + >(); + + const settle = ( + callId: string, + apply: (entry: { resolve: (v: unknown) => void; reject: (e: unknown) => void }) => void + ): void => { + const entry = pending.get(callId); + if (!entry) { + return; + } + pending.delete(callId); + apply(entry); + }; + + // Responder: run a locally-implemented command on a peer's request and reply with the outcome. + const onInvoke = (payload: unknown): void => { + const parsed = v.safeParse(commandInvokeSchema, payload); + if ( + !parsed.success || + parsed.output.serviceId !== serviceId || + !implementedCommandNames.has(parsed.output.commandName) + ) { + return; + } + const invoke = parsed.output; + + channel.emit(SERVICE_COMMAND_ACK, { + serviceId, + callId: invoke.callId, + clientId: ownClientId, + } satisfies CommandAckPayload); + + void Promise.resolve() + .then(() => localCommands[invoke.commandName](invoke.input)) + .then( + (result) => { + channel.emit(SERVICE_COMMAND_RESULT, { + serviceId, + callId: invoke.callId, + result, + clientId: ownClientId, + } satisfies CommandResultPayload); + }, + (error: unknown) => { + channel.emit(SERVICE_COMMAND_ERROR, { + serviceId, + callId: invoke.callId, + error: serializeError(error), + clientId: ownClientId, + } satisfies CommandErrorPayload); + } + ); + }; + + // Requester: resolve/reject the pending promise for a reply addressed to one of our calls. + const onResult = (payload: unknown): void => { + const result = v.safeParse(commandResultSchema, payload); + if (!result.success || result.output.serviceId !== serviceId) { + return; + } + settle(result.output.callId, (entry) => entry.resolve(result.output.result)); + }; + + const onError = (payload: unknown): void => { + const failure = v.safeParse(commandErrorSchema, payload); + if (!failure.success || failure.output.serviceId !== serviceId) { + return; + } + settle(failure.output.callId, (entry) => entry.reject(deserializeError(failure.output.error))); + }; + + channel.on(SERVICE_COMMAND_INVOKE, onInvoke); + channel.on(SERVICE_COMMAND_RESULT, onResult); + channel.on(SERVICE_COMMAND_ERROR, onError); + + const requestRemote = (commandName: string, input: unknown): Promise => { + const callId = generateClientId(); + + return new Promise((resolve, reject) => { + pending.set(callId, { resolve, reject }); + channel.emit(SERVICE_COMMAND_INVOKE, { + serviceId, + commandName, + input, + callId, + clientId: ownClientId, + } satisfies CommandInvokePayload); + }); + }; + + const commands: Record = {}; + for (const name of commandNames) { + commands[name] = implementedCommandNames.has(name) + ? localCommands[name] + : (input: unknown) => requestRemote(name, input); + } + + return { + commands, + disconnect: (): void => { + channel.off(SERVICE_COMMAND_INVOKE, onInvoke); + channel.off(SERVICE_COMMAND_RESULT, onResult); + channel.off(SERVICE_COMMAND_ERROR, onError); + + // Fail any still-pending remote calls so awaiters don't hang forever past teardown. + for (const [, entry] of pending) { + entry.reject(new OpenServiceRemoteCommandDisconnectedError({ serviceId })); + } + pending.clear(); + }, + }; +} + +/** + * Wires one service runtime to the channel end to end and returns the command map callers expose plus + * a single teardown. + * + * This is the one entry point `registerService` uses, so the three transport halves — command + * broadcasting, the remote-command protocol, and the sync-start + patch listeners — are always + * assembled together against the same `channel` and can never drift into using different channels. + */ +export function connectServiceToChannel( + context: RuntimeTransportContext & { + channel: ServiceChannel; + relay: boolean; + /** The runtime's full command map (all names; unimplemented ones throw if run locally). */ + commands: Record; + /** Command names that have a local handler in this runtime. */ + implementedCommandNames: ReadonlySet; + /** Every command name declared by the service definition. */ + commandNames: readonly string[]; + } +): { commands: Record; disconnect: () => void } { + const { + serviceId, + ownClientId, + reconciler, + getSnapshot, + channel, + relay, + commands, + implementedCommandNames, + commandNames, + } = context; + + // Wrap commands so a local mutation broadcasts its post-mutation snapshot. State adopted from peers + // flows through the reconciler's `setState`, never these wrappers, so it never re-broadcasts. + const broadcastCommands = wrapCommandsForBroadcast(commands, { + serviceId, + ownClientId, + reconciler, + getSnapshot, + channel, + }); + + // Where a local handler exists, callers run it (and broadcast) via `broadcastCommands`; where it + // does not, the returned command routes the call to a peer that implements it. + const commandTransport = connectCommandTransport({ + serviceId, + ownClientId, + channel, + localCommands: broadcastCommands, + implementedCommandNames, + commandNames, + }); + + const disconnectSync = connectRuntimeToChannel({ + serviceId, + ownClientId, + reconciler, + getSnapshot, + channel, + relay, + }); + + return { + commands: commandTransport.commands, + disconnect: (): void => { + disconnectSync(); + commandTransport.disconnect(); + }, + }; +} diff --git a/code/core/src/shared/open-service/services/docgen/definition.ts b/code/core/src/shared/open-service/services/docgen/definition.ts index b87bcfcc43ed..1d838ad6edef 100644 --- a/code/core/src/shared/open-service/services/docgen/definition.ts +++ b/code/core/src/shared/open-service/services/docgen/definition.ts @@ -1,6 +1,6 @@ import * as v from 'valibot'; -import { defineService } from '../../service-definition.ts'; +import { defineService } from 'storybook/open-service'; import type { DocgenPayload } from './types.ts'; import { docgenQueryStaticPath } from './paths.ts'; @@ -69,8 +69,9 @@ const docgenOutputSchema = v.optional(docgenPayloadSchema); * sync reads. The real work — story index lookup, provider invocation, error handling — lives in * the `extractDocgen` command, whose body is supplied at registration time because it needs to * close over the server-only story index and the composed `experimental_docgenProvider` chain. - * The query's `load` hook (also supplied at registration) just calls `extractDocgen`, so - * `getDocgen.loaded()` is the awaitable form and surfaces extraction errors. + * The query's `load` hook calls `extractDocgen`, so `getDocgen.loaded()` is the awaitable form and + * surfaces extraction errors. `getDocgenForAllComponents` delegates to the `extractAllDocgen` + * command, whose handler is supplied at registration because it needs the story index. */ export const docgenServiceDef = defineService({ id: 'core/docgen', @@ -84,6 +85,9 @@ export const docgenServiceDef = defineService({ output: docgenOutputSchema, handler: (input, ctx) => input.id in ctx.self.state.components ? ctx.self.state.components[input.id] : undefined, + load: async (input, ctx) => { + await ctx.self.commands.extractDocgen(input); + }, staticPath: (input) => docgenQueryStaticPath(input.id), }, getDocgenForAllComponents: { @@ -91,7 +95,9 @@ export const docgenServiceDef = defineService({ input: v.void(), output: v.record(v.string(), docgenPayloadSchema), handler: (_input, ctx) => ctx.self.state.components, - // load is supplied at registration time so it can close over the story index. + load: async (_input, ctx) => { + await ctx.self.commands.extractAllDocgen(undefined); + }, }, }, commands: { @@ -103,5 +109,12 @@ export const docgenServiceDef = defineService({ // Handler is supplied at registration time so it can close over the story index and the // composed experimental_docgenProvider chain. }, + extractAllDocgen: { + description: + 'Extracts docgen for every component id in the story index by invoking `extractDocgen` for each.', + input: v.undefined(), + output: v.void(), + // Handler is supplied at registration time so it can close over the story index. + }, }, }); diff --git a/code/core/src/shared/open-service/services/docgen/server.ts b/code/core/src/shared/open-service/services/docgen/server.ts index 11f6c6cd0571..59e0f6c9e4f3 100644 --- a/code/core/src/shared/open-service/services/docgen/server.ts +++ b/code/core/src/shared/open-service/services/docgen/server.ts @@ -1,7 +1,7 @@ import { selectComponentEntriesByComponentId } from '../../../../common/utils/select-component-entry.ts'; import { OpenServiceDocgenMissingComponentError } from '../../../../server-errors.ts'; import type { StoryIndex } from '../../../../types/modules/indexer.ts'; -import { registerService } from '../../service-registration.ts'; +import { registerService } from '../../server.ts'; import { docgenServiceDef } from './definition.ts'; import type { DocgenPayload, DocgenProvider } from './types.ts'; @@ -34,24 +34,12 @@ export function registerDocgenService(options: RegisterDocgenServiceOptions) { return registerService(docgenServiceDef, { queries: { getDocgen: { - load: async (input, ctx) => { - await ctx.self.commands.extractDocgen(input); - }, staticInputs: async () => { const index = await options.getIndex(); const eligible = selectComponentEntriesByComponentId(Object.values(index.entries)); return Array.from(eligible.keys(), (id) => ({ id })); }, }, - getDocgenForAllComponents: { - load: async (_input, ctx) => { - const index = await options.getIndex(); - const ids = Array.from( - selectComponentEntriesByComponentId(Object.values(index.entries)).keys() - ); - await Promise.all(ids.map((id) => ctx.self.commands.extractDocgen({ id }))); - }, - }, }, commands: { extractDocgen: { @@ -81,6 +69,15 @@ export function registerDocgenService(options: RegisterDocgenServiceOptions) { return payload; }, }, + extractAllDocgen: { + handler: async (_input, ctx) => { + const index = await options.getIndex(); + const ids = Array.from( + selectComponentEntriesByComponentId(Object.values(index.entries)).keys() + ); + await Promise.all(ids.map((id) => ctx.self.commands.extractDocgen({ id }))); + }, + }, }, }); } diff --git a/code/core/src/shared/open-service/sync-test/local-command/definition.ts b/code/core/src/shared/open-service/sync-test/local-command/definition.ts new file mode 100644 index 000000000000..554a81911e77 --- /dev/null +++ b/code/core/src/shared/open-service/sync-test/local-command/definition.ts @@ -0,0 +1,40 @@ +/** + * Shared definition for the local-command open-service sync demo. + * + * The `setValue` handler lives in this definition, so every runtime that registers the service can + * execute the command locally and broadcast the resulting state. + */ + +import * as v from 'valibot'; + +import { defineService } from 'storybook/open-service'; + +type LocalCommandState = { + value: string; +}; + +export const localCommandSyncServiceDef = defineService({ + id: 'storybook/internal/open-service-local-command-sync-demo', + description: 'Internal demo service for validating local command execution and state sync.', + initialState: { value: '' } satisfies LocalCommandState, + queries: { + getValue: { + description: 'Returns the current synchronized text value.', + input: v.void(), + output: v.string(), + handler: (_input, ctx) => ctx.self.state.value, + }, + }, + commands: { + setValue: { + description: 'Sets the synchronized text value locally in each registered runtime.', + input: v.object({ value: v.string() }), + output: v.void(), + handler: async (input, ctx) => { + ctx.self.setState((state) => { + state.value = input.value; + }); + }, + }, + }, +}); diff --git a/code/core/src/shared/open-service/sync-test/local-command/local-command.stories.tsx b/code/core/src/shared/open-service/sync-test/local-command/local-command.stories.tsx new file mode 100644 index 000000000000..0424e236c7c8 --- /dev/null +++ b/code/core/src/shared/open-service/sync-test/local-command/local-command.stories.tsx @@ -0,0 +1,128 @@ +import React, { useSyncExternalStore } from 'react'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { expect, waitFor } from 'storybook/test'; + +import { localCommandSyncService } from './preview.ts'; + +let currentValue = ''; +const listeners = new Set<() => void>(); + +function setCurrentValue(value: string) { + currentValue = value; + for (const listener of listeners) { + listener(); + } +} + +function subscribeToCurrentValue(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); +} + +function getCurrentValue() { + return currentValue; +} + +function LocalCommandDemo() { + const value = useSyncExternalStore(subscribeToCurrentValue, getCurrentValue, getCurrentValue); + + return ( +
+

Open service local command sync demo

+

+ This story shares one open-service value with the manager toolbar input. Typing here or in + the local-command toolbar input runs setValue locally in that runtime, then + syncs the updated state to the other peers. +

+

+ Unlike the remote-command sibling, this story can run in Storybook Vitest because the + command handler is part of the shared definition. +

+ + + +
+

Raw service value

+
+          {JSON.stringify(value)}
+        
+
+
+ ); +} + +/** + * Exercises open-service local command execution: manager and preview call a command implemented in + * the shared definition, then observe synchronized state. + */ +const meta = { + title: 'Open Service/Sync Test/Local Command', + component: LocalCommandDemo, + parameters: { + layout: 'centered', + }, + beforeEach: () => { + setCurrentValue(localCommandSyncService.queries.getValue()); + const unsubscribe = localCommandSyncService.queries.getValue.subscribe( + undefined, + setCurrentValue + ); + return unsubscribe; + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const LocalCommandSync: Story = {}; + +export const LocalCommandPlayFunction: Story = { + play: async ({ canvas, userEvent }) => { + const input = await canvas.findByLabelText('Local command story sync input'); + const raw = await canvas.findByLabelText('Local command raw service state value'); + const nextValue = 'local-command-sync-value'; + + try { + await userEvent.clear(input); + + await waitFor(() => { + expect(raw).toHaveTextContent(JSON.stringify('')); + }); + + await userEvent.type(input, nextValue); + + await waitFor(() => { + expect(raw).toHaveTextContent(JSON.stringify(nextValue)); + }); + } finally { + await userEvent.clear(input); + + await waitFor(() => { + expect(raw).toHaveTextContent(JSON.stringify('')); + }); + } + }, +}; diff --git a/code/core/src/shared/open-service/sync-test/local-command/manager.tsx b/code/core/src/shared/open-service/sync-test/local-command/manager.tsx new file mode 100644 index 000000000000..69cf0e305df7 --- /dev/null +++ b/code/core/src/shared/open-service/sync-test/local-command/manager.tsx @@ -0,0 +1,42 @@ +import React from 'react'; + +import { addons, types } from 'storybook/manager-api'; + +import { registerService, useServiceCommand, useServiceQuery } from 'storybook/manager-api'; +import type { ServiceInstanceOf } from 'storybook/open-service'; +import { localCommandSyncServiceDef } from './definition.ts'; + +const ADDON_ID = 'storybook/internal/open-service-local-command-sync-demo'; + +type LocalCommandSyncService = ServiceInstanceOf; + +function LocalCommandSyncTool({ service }: { service: LocalCommandSyncService }) { + const value = useServiceQuery(service, 'getValue'); + const setValue = useServiceCommand(service, 'setValue'); + + return ( + + ); +} + +addons.register(ADDON_ID, () => { + const service = registerService(localCommandSyncServiceDef); + + addons.add(ADDON_ID, { + title: 'Open service local command sync', + type: types.TOOL, + match: ({ viewMode, tabId }) => !!viewMode?.match(/^(story|docs)$/) && !tabId, + render: () => , + }); +}); diff --git a/code/core/src/shared/open-service/sync-test/local-command/preview.ts b/code/core/src/shared/open-service/sync-test/local-command/preview.ts new file mode 100644 index 000000000000..9d64abe9e53e --- /dev/null +++ b/code/core/src/shared/open-service/sync-test/local-command/preview.ts @@ -0,0 +1,5 @@ +import { registerService } from 'storybook/preview-api'; + +import { localCommandSyncServiceDef } from './definition.ts'; + +export const localCommandSyncService = registerService(localCommandSyncServiceDef); diff --git a/code/core/src/shared/open-service/sync-test/local-command/server.ts b/code/core/src/shared/open-service/sync-test/local-command/server.ts new file mode 100644 index 000000000000..16377eae1f6d --- /dev/null +++ b/code/core/src/shared/open-service/sync-test/local-command/server.ts @@ -0,0 +1,21 @@ +import { registerService } from 'storybook/internal/common'; +import { localCommandSyncServiceDef } from './definition.ts'; + +export function registerLocalCommandSyncService() { + const service = registerService(localCommandSyncServiceDef); + + let previousValue: string | undefined; + + service.queries.getValue.subscribe(undefined, (value) => { + if (previousValue === undefined) { + console.log(`[open-service-local-command-sync-demo] initial value: ${JSON.stringify(value)}`); + } else if (value !== previousValue) { + console.log( + `[open-service-local-command-sync-demo] value changed: ${JSON.stringify(previousValue)} -> ${JSON.stringify(value)}` + ); + } + previousValue = value; + }); + + return service; +} diff --git a/code/core/src/shared/open-service/sync-test/manager.tsx b/code/core/src/shared/open-service/sync-test/manager.tsx new file mode 100644 index 000000000000..d6656f6620fc --- /dev/null +++ b/code/core/src/shared/open-service/sync-test/manager.tsx @@ -0,0 +1,2 @@ +import './local-command/manager.tsx'; +import './remote-command/manager.tsx'; diff --git a/code/core/src/shared/open-service/sync-test/preview.ts b/code/core/src/shared/open-service/sync-test/preview.ts new file mode 100644 index 000000000000..7b7948821a41 --- /dev/null +++ b/code/core/src/shared/open-service/sync-test/preview.ts @@ -0,0 +1,2 @@ +import './local-command/preview.ts'; +import './remote-command/preview.ts'; diff --git a/code/core/src/shared/open-service/sync-test/remote-command/definition.ts b/code/core/src/shared/open-service/sync-test/remote-command/definition.ts new file mode 100644 index 000000000000..e0ba62b1fc55 --- /dev/null +++ b/code/core/src/shared/open-service/sync-test/remote-command/definition.ts @@ -0,0 +1,36 @@ +/** + * Shared definition for the remote-command open-service sync demo. + * + * Manager and preview share this contract but do not implement `setValue`; the server supplies the + * handler at registration time. Typing in either UI invokes the server remotely, then syncs the + * updated state back to every runtime. + */ + +import * as v from 'valibot'; + +import { defineService } from 'storybook/open-service'; + +type RemoteCommandState = { + value: string; +}; + +export const remoteCommandSyncServiceDef = defineService({ + id: 'storybook/internal/open-service-remote-command-sync-demo', + description: 'Internal demo service for validating remote command execution and state sync.', + initialState: { value: '' } satisfies RemoteCommandState, + queries: { + getValue: { + description: 'Returns the current synchronized text value.', + input: v.void(), + output: v.string(), + handler: (_input, ctx) => ctx.self.state.value, + }, + }, + commands: { + setValue: { + description: 'Sets the synchronized text value. Implemented at server registration.', + input: v.object({ value: v.string() }), + output: v.void(), + }, + }, +}); diff --git a/code/core/src/shared/open-service/sync-test/remote-command/manager.tsx b/code/core/src/shared/open-service/sync-test/remote-command/manager.tsx new file mode 100644 index 000000000000..f7773471bb71 --- /dev/null +++ b/code/core/src/shared/open-service/sync-test/remote-command/manager.tsx @@ -0,0 +1,42 @@ +import React from 'react'; + +import { addons, types } from 'storybook/manager-api'; + +import { registerService, useServiceCommand, useServiceQuery } from 'storybook/manager-api'; +import type { ServiceInstanceOf } from 'storybook/open-service'; +import { remoteCommandSyncServiceDef } from './definition.ts'; + +const ADDON_ID = 'storybook/internal/open-service-remote-command-sync-demo'; + +type RemoteCommandSyncService = ServiceInstanceOf; + +function RemoteCommandSyncTool({ service }: { service: RemoteCommandSyncService }) { + const value = useServiceQuery(service, 'getValue'); + const setValue = useServiceCommand(service, 'setValue'); + + return ( + + ); +} + +addons.register(ADDON_ID, () => { + const service = registerService(remoteCommandSyncServiceDef); + + addons.add(ADDON_ID, { + title: 'Open service remote command sync', + type: types.TOOL, + match: ({ viewMode, tabId }) => !!viewMode?.match(/^(story|docs)$/) && !tabId, + render: () => , + }); +}); diff --git a/code/core/src/shared/open-service/sync-test/remote-command/preview.ts b/code/core/src/shared/open-service/sync-test/remote-command/preview.ts new file mode 100644 index 000000000000..6fb6558fcfe5 --- /dev/null +++ b/code/core/src/shared/open-service/sync-test/remote-command/preview.ts @@ -0,0 +1,5 @@ +import { registerService } from 'storybook/preview-api'; + +import { remoteCommandSyncServiceDef } from './definition.ts'; + +export const remoteCommandSyncService = registerService(remoteCommandSyncServiceDef); diff --git a/code/core/src/shared/open-service/sync-test/remote-command/remote-command.stories.tsx b/code/core/src/shared/open-service/sync-test/remote-command/remote-command.stories.tsx new file mode 100644 index 000000000000..46a55e045ddb --- /dev/null +++ b/code/core/src/shared/open-service/sync-test/remote-command/remote-command.stories.tsx @@ -0,0 +1,143 @@ +import React, { useSyncExternalStore } from 'react'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { expect, waitFor } from 'storybook/test'; + +import { remoteCommandSyncService } from './preview.ts'; + +let currentValue = ''; +const listeners = new Set<() => void>(); + +function setCurrentValue(value: string) { + currentValue = value; + for (const listener of listeners) { + listener(); + } +} + +function subscribeToCurrentValue(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); +} + +function getCurrentValue() { + return currentValue; +} + +function RemoteCommandDemo() { + const value = useSyncExternalStore(subscribeToCurrentValue, getCurrentValue, getCurrentValue); + + return ( +
+

Open service remote command sync demo

+

+ This story shares one open-service value with the manager toolbar input. Typing here or in + the remote-command toolbar input invokes the server-only setValue command + remotely, then syncs the updated state back to manager and preview. +

+

+ In a built Storybook this variant intentionally does nothing: it depends on the dev server + to run setValue and mutate the service state. +

+

+ The play-function variant is skipped in Storybook Vitest because that runner does not + provide the full manager/preview/server channel path. Run it in the Storybook UI + interactions panel, or use the internal Playwright e2e, to exercise the remote command path. +

+ + + +
+

Raw service value

+
+          {JSON.stringify(value)}
+        
+
+
+ ); +} + +/** + * Exercises open-service remote command execution: manager and preview call a command implemented + * only by the dev server, then observe synchronized state. This variant does not update state in a + * built Storybook because there is no dev server peer to run the command. + */ +const meta = { + title: 'Open Service/Sync Test/Remote Command', + component: RemoteCommandDemo, + parameters: { + chromatic: { disableSnapshot: true }, + layout: 'centered', + }, + beforeEach: () => { + setCurrentValue(remoteCommandSyncService.queries.getValue()); + const unsubscribe = remoteCommandSyncService.queries.getValue.subscribe( + undefined, + setCurrentValue + ); + return unsubscribe; + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const RemoteCommandSync: Story = {}; + +/** + * Runs only in the real Storybook UI/e2e environment because it depends on the server handling the + * remote command. + */ +export const RemoteCommandPlayFunction: Story = { + tags: ['!vitest'], + play: async ({ canvas, userEvent }) => { + const input = await canvas.findByLabelText('Remote command story sync input'); + const raw = await canvas.findByLabelText('Remote command raw service state value'); + const nextValue = 'play-function-sync-value'; + + try { + await userEvent.clear(input); + + await waitFor(() => { + expect(raw).toHaveTextContent(JSON.stringify('')); + }); + + for (const character of nextValue) { + await userEvent.type(input, character); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + await waitFor(() => { + expect(raw).toHaveTextContent(JSON.stringify(nextValue)); + }); + } finally { + await userEvent.clear(input); + + await waitFor(() => { + expect(raw).toHaveTextContent(JSON.stringify('')); + }); + } + }, +}; diff --git a/code/core/src/shared/open-service/sync-test/remote-command/server.ts b/code/core/src/shared/open-service/sync-test/remote-command/server.ts new file mode 100644 index 000000000000..bdbedc9b7fe2 --- /dev/null +++ b/code/core/src/shared/open-service/sync-test/remote-command/server.ts @@ -0,0 +1,33 @@ +import { registerService } from 'storybook/internal/common'; +import { remoteCommandSyncServiceDef } from './definition.ts'; + +export function registerRemoteCommandSyncService() { + const service = registerService(remoteCommandSyncServiceDef, { + commands: { + setValue: { + handler: async (input, ctx) => { + ctx.self.setState((state) => { + state.value = input.value; + }); + }, + }, + }, + }); + + let previousValue: string | undefined; + + service.queries.getValue.subscribe(undefined, (value) => { + if (previousValue === undefined) { + console.log( + `[open-service-remote-command-sync-demo] initial value: ${JSON.stringify(value)}` + ); + } else if (value !== previousValue) { + console.log( + `[open-service-remote-command-sync-demo] value changed: ${JSON.stringify(previousValue)} -> ${JSON.stringify(value)}` + ); + } + previousValue = value; + }); + + return service; +} diff --git a/code/core/src/shared/open-service/sync-test/server.ts b/code/core/src/shared/open-service/sync-test/server.ts new file mode 100644 index 000000000000..64f883872261 --- /dev/null +++ b/code/core/src/shared/open-service/sync-test/server.ts @@ -0,0 +1,9 @@ +import { registerLocalCommandSyncService } from './local-command/server.ts'; +import { registerRemoteCommandSyncService } from './remote-command/server.ts'; + +export function registerOpenServiceSyncDemos() { + return { + localCommand: registerLocalCommandSyncService(), + remoteCommand: registerRemoteCommandSyncService(), + }; +} diff --git a/code/core/src/shared/open-service/types.ts b/code/core/src/shared/open-service/types.ts index 0edf844e94fc..42da04f74408 100644 --- a/code/core/src/shared/open-service/types.ts +++ b/code/core/src/shared/open-service/types.ts @@ -9,6 +9,30 @@ export type AnySchema = StandardSchemaV1; /** Stable alias for service identifiers across definition, runtime, and registration APIs. */ export type ServiceId = string; +/** + * Constrains a service's state to a plain object — the only shape the architecture supports. + * + * This is not an arbitrary restriction; two layers require it: + * + * 1. State is wrapped in a `deepSignal` proxy for fine-grained per-field reactivity, and `deepSignal` + * throws ("this object can't be observed") on primitives, `null`, and `undefined` — there are no + * fields to track on a scalar. + * 2. Cross-peer sync (`deepReconcile` in `service-sync.ts`) merges state by walking object keys; it + * has no notion of replacing a whole scalar, so the wire protocol only carries keyed objects. + * + * Arrays are technically observable by `deepSignal` but are still rejected here: `deepReconcile` + * replaces arrays wholesale rather than merging by key, so a *top-level* array state would silently + * fail to sync between peers. Wrap collections in a field instead (`{ items: [...] }`). + * + * Authoring helpers pair this with an `extends object` bound (which rejects primitives, `null`, and + * `undefined` while still accepting both `interface` and `type` declarations). The naked `TState` in + * the intersection keeps it transparent to inference; only an array collapses to the branded error. + */ +export type ServiceState = TState & + (TState extends readonly unknown[] + ? { __openServiceStateError: 'Service state must be a plain object, not an array.' } + : unknown); + /** Public schema shape exposed when describing a schema-backed service contract. */ export type SchemaDescriptor = AnySchema; @@ -302,6 +326,11 @@ export type ServiceDefinition< > = { id: ServiceId; description?: string; + /** + * Initial state for the service. Must be a plain object (not a primitive, `null`, or array) — see + * {@link ServiceState} for why. The authoring boundary (`defineService`) enforces this; the runtime + * type stays `TState` so already-constructed definitions flow through the registry unchanged. + */ initialState: TState; queries: TQueries; commands: TCommands; @@ -352,10 +381,7 @@ export interface ServiceRegistryApi { export type RuntimeService = ServiceInstance, Commands> & ServiceRegistryApi; -export type ServiceQueryRegistration> = Pick< - TQuery, - 'handler' | 'load' -> & { +export type ServiceQueryRegistration = { /** Static build inputs that may depend on registry or other server context. */ staticInputs?: RegisteredStaticInputs; }; @@ -371,7 +397,7 @@ export type ServiceRegistrationOptions< TCommands extends Commands, > = { queries?: { - [TKey in keyof TQueries]?: ServiceQueryRegistration; + [TKey in keyof TQueries]?: ServiceQueryRegistration; }; commands?: { [TKey in keyof TCommands]?: ServiceCommandRegistration; diff --git a/code/core/src/shared/open-service/use-service-command.test.tsx b/code/core/src/shared/open-service/use-service-command.test.tsx new file mode 100644 index 000000000000..807b7a7fc398 --- /dev/null +++ b/code/core/src/shared/open-service/use-service-command.test.tsx @@ -0,0 +1,41 @@ +// @vitest-environment happy-dom +import { renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { clearChannel, installNoopChannel } from '../../channels/channel-slot.ts'; + +import { mutableRecordLookupServiceDef } from './fixtures.ts'; +import { clearRegistry, registerService } from './service-registry.ts'; +import { useServiceCommand } from './use-service-command.ts'; + +beforeEach(() => { + installNoopChannel(); +}); + +afterEach(() => { + clearRegistry(); + clearChannel(); +}); + +describe('useServiceCommand', () => { + it('returns a callable async function', async () => { + const service = registerService(mutableRecordLookupServiceDef); + + const { result } = renderHook(() => useServiceCommand(service, 'assignRecordField')); + + await result.current({ entryId: 'a', fieldKey: 'k', fieldValue: 'v' }); + + expect(service.queries.getRecordFields({ entryId: 'a' })).toEqual({ k: 'v' }); + }); + + it('returns the same function reference across re-renders', () => { + const service = registerService(mutableRecordLookupServiceDef); + + const { result, rerender } = renderHook(() => useServiceCommand(service, 'assignRecordField')); + + const first = result.current; + rerender(); + + expect(result.current).toBe(first); + }); +}); diff --git a/code/core/src/shared/open-service/use-service-command.ts b/code/core/src/shared/open-service/use-service-command.ts new file mode 100644 index 000000000000..59bcfb62c372 --- /dev/null +++ b/code/core/src/shared/open-service/use-service-command.ts @@ -0,0 +1,48 @@ +/** + * React hook to get a stable reference to a service command. + * + * Fire-and-forget: the returned async function invokes the command and returns a Promise. + * Callers manage their own loading/error state. This keeps the hook minimal and composable + * with any state management approach (local `useState`, `useReducer`, TanStack Query, etc.). + * + * The reference is stable as long as `service` and `commandName` do not change, so it is + * safe to pass to child components or include in effect dependency arrays. + */ + +import * as React from 'react'; + +type CommandFn = TCommands[TKey] extends ( + ...args: any[] +) => any + ? TCommands[TKey] + : never; + +/** + * Returns a stable reference to the named service command. + * + * @param service - A service instance from `registerService`. + * @param commandName - The name of the command to invoke. + * + * @example + * ```tsx + * const assignField = useServiceCommand(service, 'assignRecordField'); + * + * return ( + * + * ); + * ``` + */ +export function useServiceCommand< + // Accept any concretely-typed service: a command typed `(input: { id }) => ...` is not assignable + // to `(input: unknown) => ...` under contravariance, so a `Pick` constraint would + // reject every service whose command takes an object input. + TInstance extends { commands: Record Promise> }, + TKey extends keyof TInstance['commands'] & string, +>(service: TInstance, commandName: TKey): CommandFn { + return React.useMemo( + () => service.commands[commandName] as CommandFn, + [service, commandName] + ); +} diff --git a/code/core/src/shared/open-service/use-service-query.test.tsx b/code/core/src/shared/open-service/use-service-query.test.tsx new file mode 100644 index 000000000000..1550b63037cd --- /dev/null +++ b/code/core/src/shared/open-service/use-service-query.test.tsx @@ -0,0 +1,160 @@ +// @vitest-environment happy-dom +import { renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { clearChannel, installNoopChannel } from '../../channels/channel-slot.ts'; + +import { mutableRecordLookupServiceDef, undefinedOutputQueryServiceDef } from './fixtures.ts'; +import { clearRegistry, registerService } from './service-registry.ts'; +import { useServiceQuery } from './use-service-query.ts'; + +beforeEach(() => { + installNoopChannel(); +}); + +afterEach(() => { + clearRegistry(); + clearChannel(); +}); + +describe('useServiceQuery', () => { + it('returns the initial synchronous query result', () => { + const service = registerService(mutableRecordLookupServiceDef); + + const { result } = renderHook(() => + useServiceQuery(service, 'getRecordFields', { entryId: 'a' }) + ); + + expect(result.current).toBeNull(); + }); + + it('re-renders when the query result changes after a command', async () => { + const service = registerService(mutableRecordLookupServiceDef); + + const { result } = renderHook(() => + useServiceQuery(service, 'getRecordFields', { entryId: 'a' }) + ); + + expect(result.current).toBeNull(); + + await service.commands.assignRecordField({ + entryId: 'a', + fieldKey: 'color', + fieldValue: 'red', + }); + + await waitFor(() => { + expect(result.current).toEqual({ color: 'red' }); + }); + }); + + it('does not re-render for an unrelated entry', async () => { + const service = registerService(mutableRecordLookupServiceDef); + let renderCount = 0; + + renderHook(() => { + renderCount++; + return useServiceQuery(service, 'getRecordFields', { entryId: 'a' }); + }); + + const countAfterMount = renderCount; + + await service.commands.assignRecordField({ + entryId: 'b', + fieldKey: 'other', + fieldValue: 'value', + }); + + // Wait a tick to let any spurious re-renders fire. + await new Promise((resolve) => setTimeout(resolve, 20)); + + // No re-render because the subscribed key ('a') was not affected. + expect(renderCount).toBe(countAfterMount); + }); + + it('updates when input changes', async () => { + const service = registerService(mutableRecordLookupServiceDef); + + await service.commands.assignRecordField({ + entryId: 'a', + fieldKey: 'k', + fieldValue: 'v', + }); + + let currentEntryId = 'b'; + const { result, rerender } = renderHook(() => + useServiceQuery(service, 'getRecordFields', { entryId: currentEntryId }) + ); + + expect(result.current).toBeNull(); + + currentEntryId = 'a'; + rerender(); + + expect(result.current).toEqual({ k: 'v' }); + }); + + it('accumulates incremental updates', async () => { + const service = registerService(mutableRecordLookupServiceDef); + + const { result } = renderHook(() => + useServiceQuery(service, 'getRecordFields', { entryId: 'a' }) + ); + + await service.commands.assignRecordField({ entryId: 'a', fieldKey: 'x', fieldValue: '1' }); + + await waitFor(() => { + expect(result.current).toEqual({ x: '1' }); + }); + + await service.commands.assignRecordField({ entryId: 'a', fieldKey: 'y', fieldValue: '2' }); + + await waitFor(() => { + expect(result.current).toEqual({ x: '1', y: '2' }); + }); + }); + + it('treats an undefined query output as initialised, not as a recompute sentinel', () => { + const service = registerService(undefinedOutputQueryServiceDef); + // Re-subscription happens only when the lazy-init subscription key changes. If `undefined` + // output were mistaken for "uninitialised", the key would be rebuilt on every render and the + // hook would resubscribe each time. + const subscribeSpy = vi.spyOn(service.queries.getNothing, 'subscribe'); + + const { result, rerender } = renderHook(() => useServiceQuery(service, 'getNothing')); + + expect(result.current).toBeUndefined(); + + const subscribeCallsAfterMount = subscribeSpy.mock.calls.length; + + rerender(); + rerender(); + + expect(result.current).toBeUndefined(); + expect(subscribeSpy.mock.calls.length).toBe(subscribeCallsAfterMount); + }); + + it('maintains referential stability when result is deeply equal', async () => { + const service = registerService(mutableRecordLookupServiceDef); + + await service.commands.assignRecordField({ entryId: 'a', fieldKey: 'k', fieldValue: 'v' }); + + let renderCount = 0; + const { result } = renderHook(() => { + renderCount++; + return useServiceQuery(service, 'getRecordFields', { entryId: 'a' }); + }); + + const firstRef = result.current; + const countAfterMount = renderCount; + + // Assign the same value again — deeply equal, so no re-render. + await service.commands.assignRecordField({ entryId: 'a', fieldKey: 'k', fieldValue: 'v' }); + + // Wait a tick to let any spurious re-renders fire. + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(result.current).toBe(firstRef); + expect(renderCount).toBe(countAfterMount); + }); +}); diff --git a/code/core/src/shared/open-service/use-service-query.ts b/code/core/src/shared/open-service/use-service-query.ts new file mode 100644 index 000000000000..71e1729c9cb7 --- /dev/null +++ b/code/core/src/shared/open-service/use-service-query.ts @@ -0,0 +1,108 @@ +/** + * React hook to subscribe to a service query with fine-grained reactivity. + * + * Backed by `useSyncExternalStore`, so it integrates correctly with React 18+ concurrent + * features and works in both manager and preview contexts without any adapter. + * + * Re-renders only when the specific query result changes by value. Signal-level dedup + * inside the service runtime ensures that a load which rewrites a deeply-equal payload does + * not re-fire the subscription; `isEqual` in `getSnapshot` provides an additional + * referential-stability layer at the React boundary so the component sees a stable object + * reference across snapshot reads that return the same logical value. + * + * Object inputs are compared with deep equality when deciding whether to re-subscribe, so inline + * literals at the call site are safe. + */ + +import * as React from 'react'; + +import { isEqual } from 'es-toolkit/predicate'; + +import type { Query } from './types.ts'; + +/** + * Subscribe to a service query and receive reactive updates in a React component. + * + * @param service - A service instance from `registerService`. + * @param queryName - The name of the query to subscribe to. + * @param args - The query input. Omit entirely for queries whose input type is `void`. + * + * @example + * ```tsx + * const fields = useServiceQuery(recordService, 'getRecordFields', { entryId: 'a' }); + * ``` + */ +// `TInput`/`TOutput` are inferred as direct type parameters from the query's call signature. This is +// deliberate: a conditional over an indexed access like `Query` is +// evaluated against the index's *constraint* (`Query`, which — being `VoidQuery & InputQuery` +// — is callable with zero args and so always looks void), collapsing every query to "no input". Typing +// the query value as a plain `(input: TInput) => TOutput` call signature recovers the concrete input +// and output, and keeps the void-vs-input branch (`[TInput] extends [void]`) over a real type. +export function useServiceQuery( + service: { queries: Record TOutput> }, + queryName: TKey, + // A rest parameter so the input can be conditionally present: void-input queries take no third + // argument, every other query requires exactly one. + ...args: [TInput] extends [void] ? [] : [input: TInput] +): TOutput { + const queryFn = service.queries[queryName] as unknown as Query; + const input = args[0] as TInput; + + // Initialise synchronously so `getSnapshot` always returns a value on the first render, + // before the service's async first-emission microtask fires. Lazy-init avoids calling + // `queryFn(input)` on every render — only when the ref is empty or the subscription changes. + // `subscriptionKeyRef` (null until the first run) is the initialisation sentinel, not the + // snapshot value: a query whose output is legitimately `undefined` must not look uninitialised. + // Compare inputs with `isEqual`, not reference identity, so inline object literals at the + // call site do not re-run the handler on every render. + const subscriptionKeyRef = React.useRef<{ + queryFn: Query; + input: TInput; + } | null>(null); + const snapshotRef = React.useRef(undefined); + + if ( + subscriptionKeyRef.current === null || + subscriptionKeyRef.current.queryFn !== queryFn || + !isEqual(subscriptionKeyRef.current.input, input) + ) { + subscriptionKeyRef.current = { queryFn, input }; + snapshotRef.current = queryFn(input); + } + + // Stable for deep-equal inputs — only replaced when `queryFn` or the input value changes. + const subscriptionKey = subscriptionKeyRef.current!; + + // Re-subscribe when `queryFn` or the input value changes. The service subscribe() fires the + // callback immediately (deferred to a microtask) with the current value, then again + // whenever tracked state changes. + const subscribe = React.useCallback( + (listener: () => void): (() => void) => + queryFn.subscribe(subscriptionKey.input, (value) => { + if (isEqual(value, snapshotRef.current)) { + return; + } + snapshotRef.current = value; + listener(); + }), + [queryFn, subscriptionKey] + ); + + // Read directly from the service to get the freshest synchronous value, but compare with + // the previously stored snapshot so React sees a stable reference when the value is + // deeply equal. This prevents unnecessary re-renders when `getSnapshot` is called outside + // of a subscriber notification (e.g. on React's concurrent-mode bailout checks). + const getSnapshot = React.useCallback((): TOutput => { + const value = queryFn(subscriptionKey.input); + const previous = snapshotRef.current as TOutput; + + if (isEqual(value, previous)) { + return previous; + } + + snapshotRef.current = value; + return value; + }, [queryFn, subscriptionKey]); + + return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} diff --git a/code/e2e-internal/open-service-sync.spec.ts b/code/e2e-internal/open-service-sync.spec.ts new file mode 100644 index 000000000000..a0a778f03604 --- /dev/null +++ b/code/e2e-internal/open-service-sync.spec.ts @@ -0,0 +1,283 @@ +import { expect, test } from '@playwright/test'; +import process from 'process'; + +/** + * E2E regression for the paired open-service sync demos + * (`code/core/src/shared/open-service/sync-test`). + * + * Validates local command execution, remote command execution, manager/preview sync, dev-server + * reload bootstrap, and cross-tab relay. + */ + +/** Internal Storybook UI (`code/.storybook`) — not a sandbox template. */ +const storybookUrl = process.env.STORYBOOK_URL || 'http://localhost:6006'; + +const runsAgainstDevServer = !['build', 'static'].includes(process.env.STORYBOOK_TYPE || 'dev'); +const STORY_READY_TIMEOUT = 15_000; + +test.describe('open-service sync example', () => { + test.describe.configure({ mode: 'serial' }); + test.setTimeout(60_000); + + test('local command syncs the toolbar and story inputs', async ({ page }) => { + await page.goto( + `${storybookUrl}/?path=/story/core-shared-open-service-sync-test-local-command--local-command-sync` + ); + + const toolbarInput = page + .getByRole('toolbar') + .getByRole('textbox', { name: 'Local command toolbar sync input' }); + const storyInput = page + .frameLocator('#storybook-preview-iframe') + .getByRole('textbox', { name: 'Local command story sync input' }); + const rawStoryValue = page + .frameLocator('#storybook-preview-iframe') + .getByLabel('Local command raw service state value'); + + await expect(toolbarInput).toBeVisible({ timeout: STORY_READY_TIMEOUT }); + await expect(storyInput).toBeVisible({ timeout: STORY_READY_TIMEOUT }); + + try { + await toolbarInput.fill('local command: from toolbar'); + await expect(storyInput).toHaveValue('local command: from toolbar'); + await expect(rawStoryValue).toHaveText(JSON.stringify('local command: from toolbar')); + + await storyInput.fill('local command: from story'); + await expect(toolbarInput).toHaveValue('local command: from story'); + await expect(rawStoryValue).toHaveText(JSON.stringify('local command: from story')); + } finally { + await toolbarInput.fill(''); + await expect(storyInput).toHaveValue(''); + await expect(rawStoryValue).toHaveText(JSON.stringify('')); + } + }); + + test('local command persists state across reloads in dev', async ({ page }) => { + test.skip(!runsAgainstDevServer, 'Reload persistence requires the dev-server relay channel.'); + + await page.goto( + `${storybookUrl}/?path=/story/core-shared-open-service-sync-test-local-command--local-command-sync` + ); + + const toolbarInput = page + .getByRole('toolbar') + .getByRole('textbox', { name: 'Local command toolbar sync input' }); + const storyInput = page + .frameLocator('#storybook-preview-iframe') + .getByRole('textbox', { name: 'Local command story sync input' }); + const rawStoryValue = page + .frameLocator('#storybook-preview-iframe') + .getByLabel('Local command raw service state value'); + + await expect(toolbarInput).toBeVisible({ timeout: STORY_READY_TIMEOUT }); + await expect(storyInput).toBeVisible({ timeout: STORY_READY_TIMEOUT }); + + try { + await storyInput.fill('local command: before reload'); + await expect(toolbarInput).toHaveValue('local command: before reload'); + + await page.reload(); + + await expect(toolbarInput).toHaveValue('local command: before reload'); + await expect(storyInput).toHaveValue('local command: before reload'); + await expect(rawStoryValue).toHaveText(JSON.stringify('local command: before reload')); + } finally { + await toolbarInput.fill(''); + await expect(storyInput).toHaveValue(''); + await expect(rawStoryValue).toHaveText(JSON.stringify('')); + } + }); + + test('local command syncs across multiple open tabs', async ({ page, context }) => { + test.skip(!runsAgainstDevServer, 'Cross-tab sync requires the dev-server relay channel.'); + + const otherPage = await context.newPage(); + + await page.goto( + `${storybookUrl}/?path=/story/core-shared-open-service-sync-test-local-command--local-command-sync` + ); + await otherPage.goto( + `${storybookUrl}/?path=/story/core-shared-open-service-sync-test-local-command--local-command-sync` + ); + + const firstToolbarInput = page + .getByRole('toolbar') + .getByRole('textbox', { name: 'Local command toolbar sync input' }); + const firstStoryInput = page + .frameLocator('#storybook-preview-iframe') + .getByRole('textbox', { name: 'Local command story sync input' }); + const firstRawStoryValue = page + .frameLocator('#storybook-preview-iframe') + .getByLabel('Local command raw service state value'); + const secondToolbarInput = otherPage + .getByRole('toolbar') + .getByRole('textbox', { name: 'Local command toolbar sync input' }); + const secondStoryInput = otherPage + .frameLocator('#storybook-preview-iframe') + .getByRole('textbox', { name: 'Local command story sync input' }); + const secondRawStoryValue = otherPage + .frameLocator('#storybook-preview-iframe') + .getByLabel('Local command raw service state value'); + + await expect(firstToolbarInput).toBeVisible({ timeout: STORY_READY_TIMEOUT }); + await expect(firstStoryInput).toBeVisible({ timeout: STORY_READY_TIMEOUT }); + await expect(secondToolbarInput).toBeVisible({ timeout: STORY_READY_TIMEOUT }); + await expect(secondStoryInput).toBeVisible({ timeout: STORY_READY_TIMEOUT }); + + try { + await firstToolbarInput.fill(''); + await expect(firstStoryInput).toHaveValue(''); + await expect(firstRawStoryValue).toHaveText(JSON.stringify('')); + await expect(secondStoryInput).toHaveValue(''); + await expect(secondRawStoryValue).toHaveText(JSON.stringify('')); + + await firstToolbarInput.fill('local command: from first tab'); + await expect(secondStoryInput).toHaveValue('local command: from first tab'); + await expect(secondRawStoryValue).toHaveText(JSON.stringify('local command: from first tab')); + await expect(secondToolbarInput).toHaveValue('local command: from first tab'); + + await secondStoryInput.fill('local command: from second tab'); + await expect(firstToolbarInput).toHaveValue('local command: from second tab'); + await expect(firstStoryInput).toHaveValue('local command: from second tab'); + await expect(firstRawStoryValue).toHaveText(JSON.stringify('local command: from second tab')); + } finally { + await firstToolbarInput.fill(''); + await expect(firstStoryInput).toHaveValue(''); + await expect(firstRawStoryValue).toHaveText(JSON.stringify('')); + await otherPage.close(); + } + }); + + test('remote command syncs the toolbar and story inputs', async ({ page }) => { + await page.goto( + `${storybookUrl}/?path=/story/core-shared-open-service-sync-test-remote-command--remote-command-sync` + ); + + const toolbarInput = page + .getByRole('toolbar') + .getByRole('textbox', { name: 'Remote command toolbar sync input' }); + const storyInput = page + .frameLocator('#storybook-preview-iframe') + .getByRole('textbox', { name: 'Remote command story sync input' }); + const rawStoryValue = page + .frameLocator('#storybook-preview-iframe') + .getByLabel('Remote command raw service state value'); + + await expect(toolbarInput).toBeVisible({ timeout: STORY_READY_TIMEOUT }); + await expect(storyInput).toBeVisible({ timeout: STORY_READY_TIMEOUT }); + + try { + await toolbarInput.fill('remote command: from toolbar'); + await expect(storyInput).toHaveValue('remote command: from toolbar'); + await expect(rawStoryValue).toHaveText(JSON.stringify('remote command: from toolbar')); + + await storyInput.fill('remote command: from story'); + await expect(toolbarInput).toHaveValue('remote command: from story'); + await expect(rawStoryValue).toHaveText(JSON.stringify('remote command: from story')); + } finally { + await toolbarInput.fill(''); + await expect(storyInput).toHaveValue(''); + await expect(rawStoryValue).toHaveText(JSON.stringify('')); + } + }); + + test('remote command persists state across reloads in dev', async ({ page }) => { + test.skip(!runsAgainstDevServer, 'Reload persistence requires the dev-server relay channel.'); + + await page.goto( + `${storybookUrl}/?path=/story/core-shared-open-service-sync-test-remote-command--remote-command-sync` + ); + + const toolbarInput = page + .getByRole('toolbar') + .getByRole('textbox', { name: 'Remote command toolbar sync input' }); + const storyInput = page + .frameLocator('#storybook-preview-iframe') + .getByRole('textbox', { name: 'Remote command story sync input' }); + const rawStoryValue = page + .frameLocator('#storybook-preview-iframe') + .getByLabel('Remote command raw service state value'); + + await expect(toolbarInput).toBeVisible({ timeout: STORY_READY_TIMEOUT }); + await expect(storyInput).toBeVisible({ timeout: STORY_READY_TIMEOUT }); + + try { + await storyInput.fill('remote command: before reload'); + await expect(toolbarInput).toHaveValue('remote command: before reload'); + + await page.reload(); + + await expect(toolbarInput).toHaveValue('remote command: before reload'); + await expect(storyInput).toHaveValue('remote command: before reload'); + await expect(rawStoryValue).toHaveText(JSON.stringify('remote command: before reload')); + } finally { + await toolbarInput.fill(''); + await expect(storyInput).toHaveValue(''); + await expect(rawStoryValue).toHaveText(JSON.stringify('')); + } + }); + + test('remote command syncs across multiple open tabs', async ({ page, context }) => { + test.skip(!runsAgainstDevServer, 'Cross-tab sync requires the dev-server relay channel.'); + + const otherPage = await context.newPage(); + + await page.goto( + `${storybookUrl}/?path=/story/core-shared-open-service-sync-test-remote-command--remote-command-sync` + ); + await otherPage.goto( + `${storybookUrl}/?path=/story/core-shared-open-service-sync-test-remote-command--remote-command-sync` + ); + + const firstToolbarInput = page + .getByRole('toolbar') + .getByRole('textbox', { name: 'Remote command toolbar sync input' }); + const firstStoryInput = page + .frameLocator('#storybook-preview-iframe') + .getByRole('textbox', { name: 'Remote command story sync input' }); + const firstRawStoryValue = page + .frameLocator('#storybook-preview-iframe') + .getByLabel('Remote command raw service state value'); + const secondToolbarInput = otherPage + .getByRole('toolbar') + .getByRole('textbox', { name: 'Remote command toolbar sync input' }); + const secondStoryInput = otherPage + .frameLocator('#storybook-preview-iframe') + .getByRole('textbox', { name: 'Remote command story sync input' }); + const secondRawStoryValue = otherPage + .frameLocator('#storybook-preview-iframe') + .getByLabel('Remote command raw service state value'); + + await expect(firstToolbarInput).toBeVisible({ timeout: STORY_READY_TIMEOUT }); + await expect(firstStoryInput).toBeVisible({ timeout: STORY_READY_TIMEOUT }); + await expect(secondToolbarInput).toBeVisible({ timeout: STORY_READY_TIMEOUT }); + await expect(secondStoryInput).toBeVisible({ timeout: STORY_READY_TIMEOUT }); + + try { + await firstToolbarInput.fill(''); + await expect(firstStoryInput).toHaveValue(''); + await expect(firstRawStoryValue).toHaveText(JSON.stringify('')); + await expect(secondStoryInput).toHaveValue(''); + await expect(secondRawStoryValue).toHaveText(JSON.stringify('')); + + await firstToolbarInput.fill('remote command: from first tab'); + await expect(secondStoryInput).toHaveValue('remote command: from first tab'); + await expect(secondRawStoryValue).toHaveText( + JSON.stringify('remote command: from first tab') + ); + await expect(secondToolbarInput).toHaveValue('remote command: from first tab'); + + await secondStoryInput.fill('remote command: from second tab'); + await expect(firstToolbarInput).toHaveValue('remote command: from second tab'); + await expect(firstStoryInput).toHaveValue('remote command: from second tab'); + await expect(firstRawStoryValue).toHaveText( + JSON.stringify('remote command: from second tab') + ); + } finally { + await firstToolbarInput.fill(''); + await expect(firstStoryInput).toHaveValue(''); + await expect(firstRawStoryValue).toHaveText(JSON.stringify('')); + await otherPage.close(); + } + }); +}); diff --git a/code/playwright.config.ts b/code/playwright.config.ts index 262dba03cf5e..3366aaa95b89 100644 --- a/code/playwright.config.ts +++ b/code/playwright.config.ts @@ -57,6 +57,7 @@ export default defineConfig({ // like fixtures, traces, and retries: // https://playwright.dev/docs/test-global-setup-teardown name: 'setup', + testDir: './e2e-sandbox', testMatch: /.*\.setup\.ts/, }, { diff --git a/code/vitest.config.storybook.ts b/code/vitest.config.storybook.ts index f37246bce275..bcd3af432d00 100644 --- a/code/vitest.config.storybook.ts +++ b/code/vitest.config.storybook.ts @@ -30,6 +30,8 @@ export default defineProject({ ], test: { name: 'storybook-ui', + // Playwright occasionally misses the vitest-iframe frame within 1s under full-suite load. + retry: process.env.CI ? 2 : 1, exclude: [ ...defaultExclude, 'node_modules/**', diff --git a/scripts/tasks/e2e-tests-build.ts b/scripts/tasks/e2e-tests-build.ts index 7c93a785a1c5..85147e0d588a 100644 --- a/scripts/tasks/e2e-tests-build.ts +++ b/scripts/tasks/e2e-tests-build.ts @@ -35,7 +35,7 @@ export const e2eTestsBuild: Task & { port: number; type: 'build' | 'dev' } = { const playwrightCommand = process.env.DEBUG ? `yarn playwright test --project=chromium --ui ${testFiles.join(' ')}` - : `yarn playwright test ${testFiles.join(' ')}`; + : `yarn playwright test --project=chromium ${testFiles.join(' ')}`; await waitOn({ resources: [`http://localhost:${port}`], interval: 16, timeout: 200000 }); await exec(