diff --git a/packages/adapter-angular/src/inject-live-preview-experience.ts b/packages/adapter-angular/src/inject-live-preview-experience.ts index 48a6dea5..dcd7a7ef 100644 --- a/packages/adapter-angular/src/inject-live-preview-experience.ts +++ b/packages/adapter-angular/src/inject-live-preview-experience.ts @@ -2,6 +2,7 @@ import { type Signal, afterNextRender, computed, effect, signal } from '@angular import { createLivePreviewClient, + sendPreviewStatus, type PreviewSessionOptions, } from '@contentful/experiences-live-preview'; import type { ExperiencePayload } from '@contentful/experiences-sdk-core'; @@ -48,13 +49,20 @@ export function injectLivePreviewExperience( if (!browserReady()) return; const options = connectionOptions(); - if (options === undefined) return; + if (options === undefined) { + sendPreviewStatus('static'); + return; + } const client = createLivePreviewClient(options); + const unsubscribeStatus = client.subscribeStatus(sendPreviewStatus); const unsubscribe = client.subscribe(() => { currentData.set(client.getSnapshot()); }); - onCleanup(unsubscribe); + onCleanup(() => { + unsubscribeStatus(); + unsubscribe(); + }); }); return { data }; diff --git a/packages/adapter-angular/src/live-preview-experience.test.ts b/packages/adapter-angular/src/live-preview-experience.test.ts index 57752001..afe0937d 100644 --- a/packages/adapter-angular/src/live-preview-experience.test.ts +++ b/packages/adapter-angular/src/live-preview-experience.test.ts @@ -17,6 +17,8 @@ import { type FakeSocket = { readonly url: string; readonly close: ReturnType; + emitOpen(): void; + emitClose(event: { code: number; reason: string }): void; emitMessage(data: unknown): void; }; @@ -25,6 +27,7 @@ const sockets: FakeSocket[] = []; class FakeWebSocket { readonly url: string; readonly close = vi.fn(); + onopen: ((event: { type: string }) => void) | null = null; onclose: ((event: { code: number; reason: string }) => void) | null = null; onmessage: ((event: { data: unknown }) => void) | null = null; @@ -33,6 +36,14 @@ class FakeWebSocket { sockets.push(this); } + emitOpen(): void { + this.onopen?.({ type: 'open' }); + } + + emitClose(event: { code: number; reason: string }): void { + this.onclose?.(event); + } + emitMessage(data: unknown): void { this.onmessage?.({ data }); } @@ -146,6 +157,8 @@ beforeEach(() => { }); afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); vi.unstubAllGlobals(); }); @@ -177,6 +190,7 @@ describe('injectLivePreviewExperience', () => { }); it('does not open a socket when preview credentials are incomplete', async () => { + const postMessage = vi.spyOn(window.parent, 'postMessage').mockImplementation(() => undefined); const fixture = createFixture(LivePreviewExperienceProbe, (probe) => { probe.options.set({ ...livePreviewOptions(payload('initial')), @@ -192,6 +206,75 @@ describe('injectLivePreviewExperience', () => { expect(fixture.componentInstance.livePreview.data()?.nodes[0]?.contentProperties?.title).toBe( 'initial' ); + expect(postMessage).toHaveBeenCalledWith( + { + source: 'experiences/live-preview', + type: 'status', + status: 'static', + }, + '*' + ); + fixture.destroy(); + }); + + it('sends live status after the session socket opens', async () => { + const postMessage = vi.spyOn(window.parent, 'postMessage').mockImplementation(() => undefined); + const fixture = createFixture(LivePreviewExperienceProbe); + await vi.waitFor(() => expect(sockets).toHaveLength(1)); + + expect(postMessage).not.toHaveBeenCalled(); + sockets[0]?.emitOpen(); + + expect(postMessage).toHaveBeenCalledWith( + { + source: 'experiences/live-preview', + type: 'status', + status: 'live', + }, + '*' + ); + fixture.destroy(); + }); + + it('does not send static status while the session socket reconnects', async () => { + const postMessage = vi.spyOn(window.parent, 'postMessage').mockImplementation(() => undefined); + const fixture = createFixture(LivePreviewExperienceProbe); + await vi.waitFor(() => expect(sockets).toHaveLength(1)); + + vi.useFakeTimers(); + sockets[0]?.emitOpen(); + sockets[0]?.emitClose({ code: 1006, reason: 'network' }); + vi.advanceTimersByTime(100); + sockets[1]?.emitOpen(); + + expect(postMessage).toHaveBeenCalledTimes(1); + expect(postMessage).toHaveBeenLastCalledWith( + { + source: 'experiences/live-preview', + type: 'status', + status: 'live', + }, + '*' + ); + fixture.destroy(); + }); + + it('sends static status without Preview Session options', async () => { + const postMessage = vi.spyOn(window.parent, 'postMessage').mockImplementation(() => undefined); + const fixture = createFixture(LivePreviewExperienceProbe, (probe) => { + probe.options.set({ initialPayload: payload('initial') }); + }); + await fixture.whenStable(); + + expect(sockets).toHaveLength(0); + expect(postMessage).toHaveBeenCalledWith( + { + source: 'experiences/live-preview', + type: 'status', + status: 'static', + }, + '*' + ); fixture.destroy(); }); diff --git a/packages/adapter-react/src/use-live-preview-experience.ts b/packages/adapter-react/src/use-live-preview-experience.ts index 358f5d07..8e5f7cdf 100644 --- a/packages/adapter-react/src/use-live-preview-experience.ts +++ b/packages/adapter-react/src/use-live-preview-experience.ts @@ -1,9 +1,10 @@ 'use client'; -import { useMemo, useSyncExternalStore } from 'react'; +import { useEffect, useMemo, useSyncExternalStore } from 'react'; import { createLivePreviewClient, + sendPreviewStatus, type PreviewSessionOptions, } from '@contentful/experiences-live-preview'; import type { ExperiencePayload } from '@contentful/experiences-sdk-core'; @@ -43,5 +44,15 @@ export function useLivePreviewExperience( ); const source = client ?? emptySource; const data = useSyncExternalStore(source.subscribe, source.getSnapshot, source.getSnapshot); + + useEffect(() => { + if (client === undefined) { + sendPreviewStatus('static'); + return; + } + + return client.subscribeStatus(sendPreviewStatus); + }, [client]); + return { data }; } diff --git a/packages/adapter-react/src/use-live-preview.test.tsx b/packages/adapter-react/src/use-live-preview.test.tsx index 176415fe..45e08dc7 100644 --- a/packages/adapter-react/src/use-live-preview.test.tsx +++ b/packages/adapter-react/src/use-live-preview.test.tsx @@ -6,8 +6,35 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ExperiencePayload, PortableRenderPlan } from '@contentful/experiences-sdk-core'; +import { + useLivePreviewExperience, + type UseLivePreviewExperienceOptions, +} from './use-live-preview-experience'; import { useLivePreview, type UseLivePreviewOptions } from './use-live-preview'; +class FakeWebSocket { + static instances: FakeWebSocket[] = []; + + readonly url: string; + readonly close = vi.fn(); + onopen: ((event: { type: string }) => void) | null = null; + onclose: ((event: { code: number; reason: string }) => void) | null = null; + onmessage: ((event: { data: unknown }) => void) | null = null; + + constructor(url: string) { + this.url = url; + FakeWebSocket.instances.push(this); + } + + emitOpen(): void { + this.onopen?.({ type: 'open' }); + } + + emitMessage(data: unknown): void { + this.onmessage?.({ data }); + } +} + const payload = (title: string): ExperiencePayload => ({ nodes: [ { @@ -50,6 +77,30 @@ const initialPlan: PortableRenderPlan = { diagnostics: [], }; +const previewSessionOptions = { + environmentId: 'environment-id', + previewToken: 'preview-token', + sessionHost: 'wss://preview-session.example.test', + sessionId: 'session-id', + spaceId: 'space-id', +}; + +const rawOptions = ( + overrides: Partial = {} +): UseLivePreviewExperienceOptions => ({ + previewSessionOptions, + ...overrides, +}); + +function RawLivePreviewProbe({ + options, +}: { + options: UseLivePreviewExperienceOptions; +}): ReactElement { + const { data } = useLivePreviewExperience(options); + return {data?.nodes[0]?.contentProperties?.title ?? ''}; +} + function LivePreviewProbe({ options }: { options: UseLivePreviewOptions }): ReactElement { const { data } = useLivePreview(options); return {data?.nodes[0]?.props.content.title ?? ''}; @@ -67,12 +118,16 @@ describe('useLivePreview', () => { let container: HTMLElement | undefined; beforeEach(() => { + FakeWebSocket.instances = []; vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.stubGlobal('WebSocket', FakeWebSocket); }); afterEach(() => { if (root) act(() => root?.unmount()); container?.remove(); + vi.useRealTimers(); + vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -92,4 +147,121 @@ describe('useLivePreview', () => { expect(container.textContent).toBe('initial'); }); + + it('sends static status when a session credential is missing', async () => { + const postMessage = vi.spyOn(window.parent, 'postMessage').mockImplementation(() => undefined); + const initialPayload = payload('initial'); + ({ container, root } = renderRoot()); + + await act(async () => { + root.render( + + ); + }); + + expect(container.textContent).toBe('initial'); + expect(FakeWebSocket.instances).toHaveLength(0); + expect(postMessage).toHaveBeenCalledWith( + { + source: 'experiences/live-preview', + type: 'status', + status: 'static', + }, + '*' + ); + }); + + it('sends live status after the session socket opens', async () => { + const postMessage = vi.spyOn(window.parent, 'postMessage').mockImplementation(() => undefined); + ({ root } = renderRoot()); + + await act(async () => { + root.render(); + }); + + expect(postMessage).not.toHaveBeenCalled(); + + await act(async () => { + FakeWebSocket.instances[0]?.emitOpen(); + }); + + expect(postMessage).toHaveBeenCalledWith( + { + source: 'experiences/live-preview', + type: 'status', + status: 'live', + }, + '*' + ); + }); + + it('does not send static status while the session socket reconnects', async () => { + vi.useFakeTimers(); + const postMessage = vi.spyOn(window.parent, 'postMessage').mockImplementation(() => undefined); + ({ root } = renderRoot()); + + await act(async () => { + root.render(); + FakeWebSocket.instances[0]?.emitOpen(); + }); + + await act(async () => { + FakeWebSocket.instances[0]?.onclose?.({ code: 1006, reason: 'network' }); + vi.advanceTimersByTime(100); + FakeWebSocket.instances[1]?.emitOpen(); + }); + + expect(postMessage).toHaveBeenCalledTimes(1); + expect(postMessage).toHaveBeenLastCalledWith( + { + source: 'experiences/live-preview', + type: 'status', + status: 'live', + }, + '*' + ); + }); + + it('sends a new status when the live-preview configuration changes', async () => { + const postMessage = vi.spyOn(window.parent, 'postMessage').mockImplementation(() => undefined); + ({ root } = renderRoot()); + + await act(async () => { + root.render( + + ); + }); + + await act(async () => { + root.render(); + }); + await act(async () => { + FakeWebSocket.instances[0]?.emitOpen(); + }); + + await act(async () => { + root.render( + + ); + }); + + expect(postMessage.mock.calls.map(([message]) => message)).toEqual([ + { source: 'experiences/live-preview', type: 'status', status: 'static' }, + { source: 'experiences/live-preview', type: 'status', status: 'live' }, + { source: 'experiences/live-preview', type: 'status', status: 'static' }, + ]); + }); }); diff --git a/packages/adapter-svelte/src/use-live-preview-experience.svelte.ts b/packages/adapter-svelte/src/use-live-preview-experience.svelte.ts index a8240f47..84fd14f7 100644 --- a/packages/adapter-svelte/src/use-live-preview-experience.svelte.ts +++ b/packages/adapter-svelte/src/use-live-preview-experience.svelte.ts @@ -1,5 +1,6 @@ import { createLivePreviewClient, + sendPreviewStatus, type PreviewSessionOptions, } from '@contentful/experiences-live-preview'; import type { ExperiencePayload } from '@contentful/experiences-sdk-core'; @@ -22,16 +23,23 @@ export function useLivePreviewExperience( $effect(() => { const { previewSessionOptions } = getOptions(); data = initialPayload; - if (previewSessionOptions === undefined) return; + if (previewSessionOptions === undefined) { + sendPreviewStatus('static'); + return; + } const client = createLivePreviewClient(previewSessionOptions, initialPayload); + const unsubscribeStatus = client.subscribeStatus(sendPreviewStatus); data = client.getSnapshot(); const unsubscribe = client.subscribe(() => { data = client.getSnapshot(); }); - return unsubscribe; + return () => { + unsubscribeStatus(); + unsubscribe(); + }; }); return { diff --git a/packages/adapter-svelte/src/use-live-preview-experience.test.ts b/packages/adapter-svelte/src/use-live-preview-experience.test.ts index 343dc7b8..ca6c55e7 100644 --- a/packages/adapter-svelte/src/use-live-preview-experience.test.ts +++ b/packages/adapter-svelte/src/use-live-preview-experience.test.ts @@ -11,6 +11,7 @@ class FakeWebSocket { readonly url: string; readonly close = vi.fn(); + onopen: ((event: { type: string }) => void) | null = null; onclose: ((event: { code: number; reason: string }) => void) | null = null; onmessage: ((event: { data: unknown }) => void) | null = null; @@ -19,6 +20,14 @@ class FakeWebSocket { FakeWebSocket.instances.push(this); } + emitOpen(): void { + this.onopen?.({ type: 'open' }); + } + + emitClose(event: { code: number; reason: string }): void { + this.onclose?.(event); + } + emitMessage(data: unknown): void { this.onmessage?.({ data }); } @@ -72,6 +81,8 @@ describe('useLivePreviewExperience', () => { }); afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); vi.unstubAllGlobals(); }); @@ -90,6 +101,7 @@ describe('useLivePreviewExperience', () => { }); it('does not connect when a session credential is missing', () => { + const postMessage = vi.spyOn(window.parent, 'postMessage').mockImplementation(() => undefined); const view = render(LivePreviewExperienceProbe, { props: { options: options({ @@ -101,5 +113,70 @@ describe('useLivePreviewExperience', () => { expect(view.container.textContent).toBe('initial'); expect(FakeWebSocket.instances).toHaveLength(0); + expect(postMessage).toHaveBeenCalledWith( + { + source: 'experiences/live-preview', + type: 'status', + status: 'static', + }, + '*' + ); + }); + + it('sends live status after the session socket opens', async () => { + const postMessage = vi.spyOn(window.parent, 'postMessage').mockImplementation(() => undefined); + render(LivePreviewExperienceProbe, { props: { options: options() } }); + + expect(postMessage).not.toHaveBeenCalled(); + + FakeWebSocket.instances[0]?.emitOpen(); + await vi.waitFor(() => + expect(postMessage).toHaveBeenCalledWith( + { + source: 'experiences/live-preview', + type: 'status', + status: 'live', + }, + '*' + ) + ); + }); + + it('does not send static status while the session socket reconnects', async () => { + vi.useFakeTimers(); + const postMessage = vi.spyOn(window.parent, 'postMessage').mockImplementation(() => undefined); + render(LivePreviewExperienceProbe, { props: { options: options() } }); + + FakeWebSocket.instances[0]?.emitOpen(); + FakeWebSocket.instances[0]?.emitClose({ code: 1006, reason: 'network' }); + vi.advanceTimersByTime(100); + FakeWebSocket.instances[1]?.emitOpen(); + + expect(postMessage).toHaveBeenCalledTimes(1); + expect(postMessage).toHaveBeenLastCalledWith( + { + source: 'experiences/live-preview', + type: 'status', + status: 'live', + }, + '*' + ); + }); + + it('sends static status without Preview Session options', () => { + const postMessage = vi.spyOn(window.parent, 'postMessage').mockImplementation(() => undefined); + render(LivePreviewExperienceProbe, { + props: { options: { initialPayload: payload('initial') } }, + }); + + expect(FakeWebSocket.instances).toHaveLength(0); + expect(postMessage).toHaveBeenCalledWith( + { + source: 'experiences/live-preview', + type: 'status', + status: 'static', + }, + '*' + ); }); }); diff --git a/packages/live-preview/README.md b/packages/live-preview/README.md index b9103df8..2ebbca99 100644 --- a/packages/live-preview/README.md +++ b/packages/live-preview/README.md @@ -17,46 +17,56 @@ type PreviewSessionOptions = { type LivePreviewClient = { getSnapshot(): ExperiencePayload | undefined; subscribe(listener: () => void): () => void; + subscribeStatus(listener: (status: LivePreviewStatus) => void): () => void; }; createLivePreviewClient( previewSessionOptions: PreviewSessionOptions, initialPayload?: ExperiencePayload, ): LivePreviewClient; + +sendPreviewStatus(status: LivePreviewStatus): void; ``` +## Usage + `createLivePreviewClient` returns a data source for a Preview Session. It opens the socket when the first listener subscribes and publishes each valid `next` -payload as received. Callers can pass the payload to `resolveExperience` or -build a render plan when they need one. If you pass `initialPayload`, -`getSnapshot()` returns it until a valid update arrives. Omitting -`initialPayload` starts the snapshot at `undefined`. Each `subscribe` call returns -an unsubscriber. When the last subscriber leaves, the client closes its socket -and cancels pending retries. - -`sessionId` and `previewToken` are optional. The package opens a socket when +payload as received. If you pass `initialPayload`, `getSnapshot()` returns it +until a valid update arrives. Without `initialPayload`, the snapshot starts as +`undefined`. + +`sessionId` and `previewToken` are optional. The package opens a socket only when both values are provided. The caller supplies the session ID through -`PreviewSessionOptions`. `getSnapshot()` exposes the latest Preview Session data to -the application. +`PreviewSessionOptions`. `sessionHost` is an optional WebSocket URL for the Preview Session service. It defaults to the production Contentful Session service. The SDK uses the URL as supplied, appends the subscription route, and sends `previewToken` as the encoded `access_token` query parameter. -Transform the snapshot before rendering if needed: +When you use the package directly in a Contentful preview, connect the client's +status subscription to `sendPreviewStatus`: ```ts +import { createLivePreviewClient, sendPreviewStatus } from '@contentful/experiences-live-preview'; + const client = createLivePreviewClient(previewSessionOptions); +const unsubscribeStatus = client.subscribeStatus(sendPreviewStatus); const unsubscribe = client.subscribe(() => { const experience = client.getSnapshot(); if (experience) updatePreview(experience); }); ``` +This tells the Contentful app how to coordinate updates with the SDK. The +subscription is required for this integration. Framework adapters set it up for +you. Keep both unsubscribe functions and call them when the preview no longer +uses the client. + A valid `next` message replaces the current data atomically. The client keeps the last valid data when it receives malformed messages, server errors, or transport interruptions. Unknown message types are ignored. The `next` payload -may contain an `Experience` or `ExperienceFragment`. Adapters can build on this -source and reuse its socket and message handling. Other frameworks can use the -same framework-neutral client. +contains an `Experience`. Adapters can build on this source and reuse its socket +and message handling. Other frameworks can use the same framework-neutral +client. diff --git a/packages/live-preview/src/index.ts b/packages/live-preview/src/index.ts index 0971a522..8ce968a6 100644 --- a/packages/live-preview/src/index.ts +++ b/packages/live-preview/src/index.ts @@ -1,3 +1,4 @@ export { createLivePreviewClient } from './live-preview-client.js'; -export type { LivePreviewClient } from './live-preview-client.js'; +export { sendPreviewStatus } from './preview-status.js'; +export type { LivePreviewClient, LivePreviewStatus } from './live-preview-client.js'; export type { PreviewSessionOptions } from './preview-session.js'; diff --git a/packages/live-preview/src/live-preview-client.test.ts b/packages/live-preview/src/live-preview-client.test.ts index 4cbabe9a..fabc7ace 100644 --- a/packages/live-preview/src/live-preview-client.test.ts +++ b/packages/live-preview/src/live-preview-client.test.ts @@ -105,6 +105,50 @@ describe('createLivePreviewClient', () => { unsubscribe(); }); + it('reports static status when live-preview credentials are missing', () => { + const listener = vi.fn(); + const source = createLivePreviewClient(sourceOptions()); + + const unsubscribe = source.subscribeStatus(listener); + + expect(listener).toHaveBeenCalledWith('static'); + unsubscribe(); + }); + + it('reports live status when the session socket opens', () => { + setBrowser(); + const listener = vi.fn(); + const source = createLivePreviewClient(sourceOptions('session-id')); + const unsubscribeStatus = source.subscribeStatus(listener); + const unsubscribe = source.subscribe(vi.fn()); + + expect(listener).not.toHaveBeenCalled(); + sockets[0]?.emitOpen(); + + expect(listener).toHaveBeenCalledWith('live'); + unsubscribeStatus(); + unsubscribe(); + }); + + it('does not report static status when the session socket reconnects', () => { + vi.useFakeTimers(); + setBrowser(); + const listener = vi.fn(); + const source = createLivePreviewClient(sourceOptions('session-id')); + const unsubscribeStatus = source.subscribeStatus(listener); + const unsubscribe = source.subscribe(vi.fn()); + + sockets[0]?.emitOpen(); + sockets[0]?.emitClose(1006, 'network'); + vi.advanceTimersByTime(100); + sockets[1]?.emitOpen(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith('live'); + unsubscribeStatus(); + unsubscribe(); + }); + it('uses the production Session origin when sessionHost is omitted', () => { setBrowser(); const source = createLivePreviewClient({ diff --git a/packages/live-preview/src/live-preview-client.ts b/packages/live-preview/src/live-preview-client.ts index fd488ea0..cdfcb246 100644 --- a/packages/live-preview/src/live-preview-client.ts +++ b/packages/live-preview/src/live-preview-client.ts @@ -1,9 +1,12 @@ import type { ExperiencePayload } from '@contentful/experiences-sdk-core'; import { subscribeToPreviewSession, type PreviewSessionOptions } from './preview-session.js'; +export type LivePreviewStatus = 'live' | 'static'; + export type LivePreviewClient = { getSnapshot(): ExperiencePayload | undefined; subscribe(listener: () => void): () => void; + subscribeStatus(listener: (status: LivePreviewStatus) => void): () => void; }; export function createLivePreviewClient( @@ -11,10 +14,15 @@ export function createLivePreviewClient( initialPayload?: ExperiencePayload ): LivePreviewClient { const listeners = new Set<{ handler: () => void }>(); + const statusListeners = new Set<{ handler: (status: LivePreviewStatus) => void }>(); const notifyListeners = (): void => { for (const { handler } of [...listeners]) handler(); }; + const hasLivePreviewOptions = + previewSessionOptions.sessionId !== undefined && + previewSessionOptions.previewToken !== undefined; + let currentStatus: LivePreviewStatus | undefined = hasLivePreviewOptions ? undefined : 'static'; let currentData = initialPayload; let unsubscribeFromSession: (() => void) | undefined; @@ -23,9 +31,16 @@ export function createLivePreviewClient( notifyListeners(); }; + const updateStatus = (status: LivePreviewStatus): void => { + if (currentStatus === status) return; + currentStatus = status; + for (const { handler } of [...statusListeners]) handler(status); + }; + const closeSession = (): void => { unsubscribeFromSession?.(); unsubscribeFromSession = undefined; + if (hasLivePreviewOptions) currentStatus = undefined; }; return { @@ -36,7 +51,10 @@ export function createLivePreviewClient( listeners.add(subscription); if (isFirstSubscriber) { try { - unsubscribeFromSession = subscribeToPreviewSession(previewSessionOptions, updateData); + unsubscribeFromSession = subscribeToPreviewSession(previewSessionOptions, { + onOpen: () => updateStatus('live'), + onUpdate: updateData, + }); } catch (error: unknown) { listeners.delete(subscription); closeSession(); @@ -49,5 +67,14 @@ export function createLivePreviewClient( if (listeners.size === 0) closeSession(); }; }, + subscribeStatus(listener) { + const subscription = { handler: listener }; + statusListeners.add(subscription); + if (currentStatus !== undefined) listener(currentStatus); + + return () => { + statusListeners.delete(subscription); + }; + }, }; } diff --git a/packages/live-preview/src/preview-session.ts b/packages/live-preview/src/preview-session.ts index f2023863..c16e8f8e 100644 --- a/packages/live-preview/src/preview-session.ts +++ b/packages/live-preview/src/preview-session.ts @@ -19,6 +19,11 @@ type SessionMessage = | { kind: 'unknown' } | { kind: 'invalid' }; +type PreviewSessionHandlers = { + onUpdate: (experience: ExperiencePayload) => void; + onOpen?: () => void; +}; + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } @@ -97,10 +102,11 @@ function isSessionEnded(event: WebSocketCloseEvent): boolean { export function subscribeToPreviewSession( options: PreviewSessionOptions, - onUpdate: (experience: ExperiencePayload) => void + handlers: PreviewSessionHandlers ): () => void { const log = createDebugLogger(options.debug, 'live-preview'); const { sessionId, previewToken } = options; + const { onOpen, onUpdate } = handlers; if (sessionId === undefined || previewToken === undefined) return () => undefined; const connection = createWebSocketConnection({ @@ -110,6 +116,7 @@ export function subscribeToPreviewSession( RETRY_DELAYS_MS[retryAttempt] ?? RETRY_DELAYS_MS[RETRY_DELAYS_MS.length - 1] ?? 0, }); + const unsubscribeFromOpen = onOpen ? connection.onopen(() => onOpen()) : undefined; const unsubscribe = connection.onmessage((event) => { const message = parseMessage(event.data); @@ -127,6 +134,7 @@ export function subscribeToPreviewSession( }); return () => { + unsubscribeFromOpen?.(); unsubscribe(); connection.close(); }; diff --git a/packages/live-preview/src/preview-status.test.ts b/packages/live-preview/src/preview-status.test.ts new file mode 100644 index 00000000..31e57bc5 --- /dev/null +++ b/packages/live-preview/src/preview-status.test.ts @@ -0,0 +1,25 @@ +/** @vitest-environment jsdom */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { sendPreviewStatus } from './preview-status'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('sendPreviewStatus', () => { + it('sends the preview status to the parent window', () => { + const postMessage = vi.spyOn(window.parent, 'postMessage').mockImplementation(() => undefined); + + sendPreviewStatus('static'); + + expect(postMessage).toHaveBeenCalledWith( + { + source: 'experiences/live-preview', + type: 'status', + status: 'static', + }, + '*' + ); + }); +}); diff --git a/packages/live-preview/src/preview-status.ts b/packages/live-preview/src/preview-status.ts new file mode 100644 index 00000000..898fd693 --- /dev/null +++ b/packages/live-preview/src/preview-status.ts @@ -0,0 +1,18 @@ +import type { LivePreviewStatus } from './live-preview-client.js'; + +const PREVIEW_STATUS_MESSAGE = { + source: 'experiences/live-preview', + type: 'status', +} as const; + +export function sendPreviewStatus(status: LivePreviewStatus): void { + if (typeof window === 'undefined') return; + + window.parent?.postMessage( + { + ...PREVIEW_STATUS_MESSAGE, + status, + }, + '*' + ); +} diff --git a/packages/live-preview/src/test-fixtures/fake-websocket.ts b/packages/live-preview/src/test-fixtures/fake-websocket.ts index a50c669b..06b1c4fd 100644 --- a/packages/live-preview/src/test-fixtures/fake-websocket.ts +++ b/packages/live-preview/src/test-fixtures/fake-websocket.ts @@ -1,8 +1,13 @@ import { vi } from 'vitest'; -import type { WebSocketCloseEvent, WebSocketMessageEvent } from '../websocket.js'; +import type { + WebSocketCloseEvent, + WebSocketMessageEvent, + WebSocketOpenEvent, +} from '../websocket.js'; export type FakeSocket = { readonly url: string; + onopen: ((event: WebSocketOpenEvent) => void) | null; onclose: ((event: WebSocketCloseEvent) => void) | null; onmessage: ((event: WebSocketMessageEvent) => void) | null; close: ReturnType; @@ -14,6 +19,7 @@ export const sockets: FakeSocket[] = []; export class FakeWebSocket { readonly url: string; + onopen: ((event: WebSocketOpenEvent) => void) | null = null; onclose: ((event: WebSocketCloseEvent) => void) | null = null; onmessage: ((event: WebSocketMessageEvent) => void) | null = null; readonly close = vi.fn(); @@ -23,6 +29,10 @@ export class FakeWebSocket { sockets.push(this); } + emitOpen(): void { + this.onopen?.({ type: 'open' }); + } + emitClose(code = 1000, reason = ''): void { this.onclose?.({ code, reason }); } diff --git a/packages/live-preview/src/websocket.test.ts b/packages/live-preview/src/websocket.test.ts index 82b517d7..027adac0 100644 --- a/packages/live-preview/src/websocket.test.ts +++ b/packages/live-preview/src/websocket.test.ts @@ -158,6 +158,7 @@ describe('createWebSocketConnection', () => { it('propagates synchronous constructor failures', () => { const failure = new Error('invalid WebSocket configuration'); class ThrowingWebSocket { + onopen: ((event: { type: string }) => void) | null = null; onclose: ((event: WebSocketCloseEvent) => void) | null = null; onmessage: ((event: WebSocketMessageEvent) => void) | null = null; @@ -183,6 +184,7 @@ describe('createWebSocketConnection', () => { class RetryThenThrowWebSocket { static firstInstance: RetryThenThrowWebSocket | undefined; + onopen: ((event: { type: string }) => void) | null = null; onclose: ((event: WebSocketCloseEvent) => void) | null = null; onmessage: ((event: WebSocketMessageEvent) => void) | null = null; diff --git a/packages/live-preview/src/websocket.ts b/packages/live-preview/src/websocket.ts index f9508ea2..656afebb 100644 --- a/packages/live-preview/src/websocket.ts +++ b/packages/live-preview/src/websocket.ts @@ -3,11 +3,16 @@ export type WebSocketCloseEvent = { readonly reason: string; }; +export type WebSocketOpenEvent = { + readonly type: string; +}; + export type WebSocketMessageEvent = { readonly data: unknown; }; type WebSocket = { + onopen: ((event: WebSocketOpenEvent) => void) | null; onclose: ((event: WebSocketCloseEvent) => void) | null; onmessage: ((event: WebSocketMessageEvent) => void) | null; close(): void; @@ -17,6 +22,7 @@ type WebSocketConstructor = new (url: string) => WebSocket; type WebSocketConnection = { close(): void; + onopen(handler: (event: WebSocketOpenEvent) => void): () => void; onmessage(handler: (event: WebSocketMessageEvent) => void): () => void; }; @@ -34,6 +40,7 @@ export function createWebSocketConnection(options: { let retryCount = 0; let closed = false; + const openHandlers = new Set<(event: WebSocketOpenEvent) => void>(); const messageHandlers = new Set<(event: WebSocketMessageEvent) => void>(); const clearRetryTimer = () => { @@ -43,10 +50,12 @@ export function createWebSocketConnection(options: { }; const clearHandlers = () => { + openHandlers.clear(); messageHandlers.clear(); }; const detachSocketHandlers = (currentSocket: WebSocket) => { + currentSocket.onopen = null; currentSocket.onmessage = null; currentSocket.onclose = null; }; @@ -97,6 +106,23 @@ export function createWebSocketConnection(options: { const ws = new WS(options.url); websocket = ws; ws.onclose = (event) => handleClose(ws, event); + ws.onopen = (event) => { + if (websocket !== ws || closed) return; + + let handlerError: unknown; + let hasHandlerError = false; + + for (const handler of [...openHandlers]) { + try { + handler(event); + } catch (error: unknown) { + if (!hasHandlerError) handlerError = error; + hasHandlerError = true; + } + } + + if (hasHandlerError) throw handlerError; + }; ws.onmessage = (event) => { if (websocket !== ws || closed) return; @@ -128,9 +154,18 @@ export function createWebSocketConnection(options: { return; } - currentSocket.onmessage = null; + detachSocketHandlers(currentSocket); + clearHandlers(); currentSocket.close(); }, + onopen(handler) { + if (closed) return () => undefined; + + openHandlers.add(handler); + return () => { + openHandlers.delete(handler); + }; + }, onmessage(handler) { if (closed) return () => undefined;