@akinon/theme-contract 2.0.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # @akinon/theme-contract
2
+
3
+ ## 2.0.35
4
+
5
+ ### Patch Changes
6
+
7
+ - 8dca03f2: ZERO-4663: introduce shared wire contract for theme editor and storefront
8
+
9
+ New `@akinon/theme-contract` package: shared wire types, versioned postMessage envelope (`protocolVersion`), runtime validation (`parseThemeMessage`) and origin helpers. `@akinon/pz-theme` now routes all designer postMessage traffic through an origin-guarded `editor-bridge` — inbound designer messages are validated against the editor-origin allowlist (`NEXT_PUBLIC_THEME_EDITOR_ORIGINS`, localhost auto-trusted outside production) and nothing is posted to `'*'` anymore.
package/README.md ADDED
@@ -0,0 +1,73 @@
1
+ # @akinon/theme-contract
2
+
3
+ Shared wire contract between the theme editor (`pz-dynamic-widget`) and the storefront renderer (`@akinon/pz-theme`). Single source of truth for:
4
+
5
+ - **Theme wire types** — `Theme`, `ThemePlaceholder`, `ThemeSection`, `ThemeBlock`, `ThemeDataSource`, `NativeWidgetConfig`, `PageContextDiscovery`. Structure is typed strictly; `properties`/`styles`/`value` payloads stay open so the two repos can evolve field content without a lockstep release.
6
+ - **postMessage protocol** — the `ThemeMessage` envelope with `protocolVersion`, plus the full directional type unions (`EDITOR_TO_PREVIEW_MESSAGE_TYPES`, `PREVIEW_TO_EDITOR_MESSAGE_TYPES`).
7
+ - **Runtime validation** — `parseThemeMessage(event.data)` separates contract traffic from window noise and surfaces cross-repo drift (unknown types, version mismatches) instead of silently ignoring it. Content-level validation stays in `@akinon/pz-theme`'s zod `theme-schema.ts`.
8
+ - **Origin helpers** — `safeOrigin`, `parseOriginList`, `createOriginGuard` so both sides derive and enforce the trust boundary the same way. Never post to `'*'`; never accept a designer message without checking `event.origin` first.
9
+
10
+ ## Installation
11
+
12
+ Inside the pz-next monorepo the package resolves through Yarn workspaces. External consumers (the theme editor):
13
+
14
+ ```bash
15
+ pnpm add @akinon/theme-contract
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ Sending (either side):
21
+
22
+ ```ts
23
+ import { stampThemeMessage } from '@akinon/theme-contract';
24
+
25
+ targetWindow.postMessage(
26
+ stampThemeMessage({ type: 'UPDATE_THEME', data: { theme } }),
27
+ targetOrigin
28
+ );
29
+ ```
30
+
31
+ Receiving:
32
+
33
+ ```ts
34
+ import { parseThemeMessage } from '@akinon/theme-contract';
35
+
36
+ window.addEventListener('message', (event) => {
37
+ if (!originGuard.isTrusted(event.origin)) return; // trust FIRST
38
+ const parsed = parseThemeMessage(event.data);
39
+ if (!parsed.ok) return; // parsed.reason / parsed.type for diagnostics
40
+ if (parsed.versionMismatch) {
41
+ console.warn(`theme-contract version mismatch (remote ${parsed.remoteVersion})`);
42
+ }
43
+ handle(parsed.message);
44
+ });
45
+ ```
46
+
47
+ Renderer-side origin guard (see `@akinon/pz-theme` `src/editor-bridge.ts` for the wired-up singleton):
48
+
49
+ ```ts
50
+ import { createOriginGuard, parseOriginList } from '@akinon/theme-contract';
51
+
52
+ const guard = createOriginGuard({
53
+ allowedOrigins: parseOriginList(process.env.NEXT_PUBLIC_THEME_EDITOR_ORIGINS),
54
+ allowLocalDev: process.env.NODE_ENV !== 'production'
55
+ });
56
+ ```
57
+
58
+ ## Versioning
59
+
60
+ `THEME_CONTRACT_PROTOCOL_VERSION` bumps ONLY on a breaking change to the envelope or an existing message's data shape. Adding a message type is not breaking — unknown types are reported and skipped. Messages from pre-contract builds (no `protocolVersion` field) are accepted as version 0.
61
+
62
+ ## API Reference
63
+
64
+ | Export | Kind | Description |
65
+ | --- | --- | --- |
66
+ | `THEME_CONTRACT_PROTOCOL_VERSION` | const | Current protocol version |
67
+ | `stampThemeMessage(message)` | fn | Stamp an outbound envelope, preserving legacy top-level fields |
68
+ | `createThemeMessage(type, data?)` | fn | Build and stamp a `{ type, data }` envelope |
69
+ | `parseThemeMessage(raw)` | fn | Validate inbound `event.data`; never throws |
70
+ | `isKnownThemeMessageType(type)` | fn | Type-union membership check |
71
+ | `safeOrigin(url)` | fn | `scheme://host:port` or `null` |
72
+ | `parseOriginList(raw)` | fn | Comma/whitespace-separated env list → origins |
73
+ | `createOriginGuard(options)` | fn | Allowlist (+ optional localhost dev) origin check |
package/package.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "@akinon/theme-contract",
3
+ "version": "2.0.35",
4
+ "description": "Shared wire contract between the theme editor (pz-dynamic-widget) and the storefront renderer (@akinon/pz-theme): theme types, postMessage envelope, runtime validation and origin helpers.",
5
+ "license": "MIT",
6
+ "main": "src/index.ts",
7
+ "devDependencies": {
8
+ "typescript": "^5.2.2"
9
+ }
10
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from './types';
2
+ export * from './messages';
3
+ export * from './validate';
4
+ export * from './origin';
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The postMessage protocol between the theme editor (parent window) and the
3
+ * preview iframe (storefront). Both sides stamp outbound messages
4
+ * (`stampThemeMessage`) and validate inbound ones (`parseThemeMessage`, see
5
+ * validate.ts), so a renamed/removed type or an incompatible protocol
6
+ * version surfaces as a warning instead of a silently dead feature.
7
+ */
8
+
9
+ /**
10
+ * Bump ONLY on a breaking change to the envelope or to an existing message's
11
+ * data shape. Adding a new message type is NOT breaking — unknown types are
12
+ * reported and skipped, never fatal.
13
+ */
14
+ export const THEME_CONTRACT_PROTOCOL_VERSION = 1;
15
+
16
+ /**
17
+ * Version attributed to messages sent by pre-contract builds (no
18
+ * `protocolVersion` field). They keep working — the field is additive.
19
+ */
20
+ export const LEGACY_PROTOCOL_VERSION = 0;
21
+
22
+ /** Messages the editor sends into the preview iframe. */
23
+ export const EDITOR_TO_PREVIEW_MESSAGE_TYPES = [
24
+ 'LOAD_THEME',
25
+ 'UPDATE_THEME',
26
+ 'SELECT_SECTION',
27
+ 'SELECT_BLOCK',
28
+ 'SELECT_BLOCK_FROM_PREVIEW',
29
+ 'CLEAR_SELECTION',
30
+ 'DESELECT',
31
+ 'CHANGE_BREAKPOINT',
32
+ 'SET_THEME_EDITOR_COOKIE',
33
+ 'SET_PREVIEW_INTERACTION_MODE',
34
+ 'SET_BASKET_VIEW_MODE',
35
+ 'OPEN_MINI_BASKET',
36
+ 'SCROLL_TO_SECTION',
37
+ 'SCROLL_TO_BLOCK',
38
+ 'LOCALE_UPDATE',
39
+ 'LOAD_FONT',
40
+ 'LOAD_CUSTOM_FONT',
41
+ 'PRODUCT_PICKER_SEARCH',
42
+ 'PRODUCT_PICKER_RESOLVE_URL',
43
+ 'PRODUCT_PICKER_GET_PRODUCT'
44
+ ] as const;
45
+
46
+ /** Messages the preview iframe sends up to the editor. */
47
+ export const PREVIEW_TO_EDITOR_MESSAGE_TYPES = [
48
+ 'IFRAME_READY',
49
+ 'PLACEHOLDERS_AVAILABLE',
50
+ 'REGISTER_NATIVE_WIDGETS',
51
+ 'UNREGISTER_NATIVE_SECTION',
52
+ 'UPDATE_SECTION_BLOCKS',
53
+ 'SELECT_SECTION',
54
+ 'SELECT_BLOCK',
55
+ 'MOVE_SECTION_UP',
56
+ 'MOVE_SECTION_DOWN',
57
+ 'DUPLICATE_SECTION',
58
+ 'TOGGLE_SECTION_VISIBILITY',
59
+ 'SET_SECTION_VISIBILITY',
60
+ 'DELETE_SECTION',
61
+ 'RENAME_SECTION',
62
+ 'MOVE_BLOCK_UP',
63
+ 'MOVE_BLOCK_DOWN',
64
+ 'DUPLICATE_BLOCK',
65
+ 'TOGGLE_BLOCK_VISIBILITY',
66
+ 'SET_BLOCK_VISIBILITY',
67
+ 'DELETE_BLOCK',
68
+ 'DELETE_BLOCK_FROM_PARENT',
69
+ 'RENAME_BLOCK',
70
+ 'BLOCK_ACTION',
71
+ 'UPDATE_BLOCK_VALUE',
72
+ 'PRODUCT_PICKER_RESPONSE',
73
+ 'PAGE_CONTEXT_DISCOVERY',
74
+ 'PREVIEW_AUTH_STATE',
75
+ 'ROUTE_CHANGE_START',
76
+ 'ROUTE_CHANGE_COMPLETE',
77
+ // Bidirectional: the preview reports click-selection and its available
78
+ // locales up to the editor with the same types the editor uses downward.
79
+ 'SELECT_BLOCK_FROM_PREVIEW',
80
+ 'LOCALE_UPDATE'
81
+ ] as const;
82
+
83
+ export type EditorToPreviewMessageType =
84
+ typeof EDITOR_TO_PREVIEW_MESSAGE_TYPES[number];
85
+ export type PreviewToEditorMessageType =
86
+ typeof PREVIEW_TO_EDITOR_MESSAGE_TYPES[number];
87
+ export type ThemeMessageType =
88
+ | EditorToPreviewMessageType
89
+ | PreviewToEditorMessageType;
90
+
91
+ const KNOWN_MESSAGE_TYPES: ReadonlySet<string> = new Set([
92
+ ...EDITOR_TO_PREVIEW_MESSAGE_TYPES,
93
+ ...PREVIEW_TO_EDITOR_MESSAGE_TYPES
94
+ ]);
95
+
96
+ export function isKnownThemeMessageType(
97
+ type: string
98
+ ): type is ThemeMessageType {
99
+ return KNOWN_MESSAGE_TYPES.has(type);
100
+ }
101
+
102
+ /**
103
+ * The envelope. Kept intentionally backward compatible with the
104
+ * pre-contract shape `{ type, data }` — `protocolVersion` is additive, so
105
+ * an old peer simply ignores it.
106
+ */
107
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
108
+ export interface ThemeMessage<TData = any> {
109
+ type: ThemeMessageType;
110
+ data?: TData;
111
+ protocolVersion?: number;
112
+ }
113
+
114
+ /**
115
+ * Outbound message shape accepted by the send helpers. The index signature
116
+ * exists because several pre-contract messages carry their payload as extra
117
+ * TOP-LEVEL fields instead of `data` (ROUTE_CHANGE_*, BLOCK_ACTION, the
118
+ * preview-click SELECT_SECTION) — senders pass them through unchanged so
119
+ * older peers keep working. New messages must use `data`.
120
+ */
121
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
122
+ export interface OutboundThemeMessage<TData = any> {
123
+ type: ThemeMessageType;
124
+ data?: TData;
125
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
126
+ [legacyField: string]: any;
127
+ }
128
+
129
+ /** Stamp an outbound message, preserving legacy top-level fields. */
130
+ export function stampThemeMessage<TData>(
131
+ message: OutboundThemeMessage<TData>
132
+ ): OutboundThemeMessage<TData> {
133
+ return { ...message, protocolVersion: THEME_CONTRACT_PROTOCOL_VERSION };
134
+ }
135
+
136
+ /** Build and stamp a `{ type, data }` message. */
137
+ export function createThemeMessage<TData>(
138
+ type: ThemeMessageType,
139
+ data?: TData
140
+ ): ThemeMessage<TData> {
141
+ return data === undefined
142
+ ? { type, protocolVersion: THEME_CONTRACT_PROTOCOL_VERSION }
143
+ : { type, data, protocolVersion: THEME_CONTRACT_PROTOCOL_VERSION };
144
+ }
package/src/origin.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Origin helpers for both sides of the editor ⇄ preview trust boundary.
3
+ *
4
+ * The editor derives the preview's origin from the SAME `previewUrl` it sets
5
+ * as the iframe `src`. The preview derives the editor's origin from an
6
+ * explicit allowlist (plus a localhost fallback for development) — NEVER
7
+ * from whoever happens to be framing it: `document.referrer` alone would
8
+ * trust any page that embeds the storefront in an iframe.
9
+ */
10
+
11
+ /**
12
+ * The origin (`scheme://host:port`) of a URL, or `null` when the URL is
13
+ * empty/unparseable. Both sides use this so they can never disagree on how
14
+ * an origin is derived.
15
+ */
16
+ export function safeOrigin(url: string | null | undefined): string | null {
17
+ if (!url) return null;
18
+ try {
19
+ return new URL(url).origin;
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Parse a comma/whitespace-separated origin list (e.g. the
27
+ * `NEXT_PUBLIC_THEME_EDITOR_ORIGINS` env var) into normalized origins.
28
+ * Unparseable entries are dropped.
29
+ */
30
+ export function parseOriginList(raw: string | null | undefined): string[] {
31
+ if (!raw) return [];
32
+ const origins = raw
33
+ .split(/[\s,]+/)
34
+ .map((entry) => safeOrigin(entry.trim()))
35
+ .filter((origin): origin is string => origin !== null);
36
+ return Array.from(new Set(origins));
37
+ }
38
+
39
+ const LOCAL_DEV_ORIGIN_PATTERN =
40
+ /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/;
41
+
42
+ export function isLocalDevOrigin(origin: string): boolean {
43
+ return LOCAL_DEV_ORIGIN_PATTERN.test(origin);
44
+ }
45
+
46
+ export interface OriginGuard {
47
+ /** Explicitly allowed origins (normalized). */
48
+ readonly allowedOrigins: readonly string[];
49
+ isTrusted(origin: string): boolean;
50
+ }
51
+
52
+ export interface OriginGuardOptions {
53
+ allowedOrigins: string[];
54
+ /**
55
+ * Additionally trust localhost origins — for development, where the
56
+ * editor runs on an arbitrary local port and no allowlist is configured.
57
+ * Must be OFF in production.
58
+ */
59
+ allowLocalDev?: boolean;
60
+ }
61
+
62
+ export function createOriginGuard(options: OriginGuardOptions): OriginGuard {
63
+ const allowedOrigins = Array.from(
64
+ new Set(
65
+ options.allowedOrigins
66
+ .map((entry) => safeOrigin(entry))
67
+ .filter((origin): origin is string => origin !== null)
68
+ )
69
+ );
70
+ const allowLocalDev = Boolean(options.allowLocalDev);
71
+
72
+ return {
73
+ allowedOrigins,
74
+ isTrusted(origin: string): boolean {
75
+ if (!origin) return false;
76
+ if (allowedOrigins.includes(origin)) return true;
77
+ return allowLocalDev && isLocalDevOrigin(origin);
78
+ }
79
+ };
80
+ }
package/src/types.ts ADDED
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Wire-level theme types shared between the theme editor (pz-dynamic-widget)
3
+ * and the preview/storefront renderer (@akinon/pz-theme).
4
+ *
5
+ * These describe what actually travels over postMessage and what the
6
+ * renderer consumes — the STRUCTURE is typed strictly (ids, nesting,
7
+ * placeholders, data sources), while per-section/per-block payloads
8
+ * (`properties`, `styles`, `value`) stay open so the two sides can evolve
9
+ * field content without a lockstep release. The editor's stricter internal
10
+ * types are assignable to these.
11
+ */
12
+
13
+ export type ThemeDataSourceType =
14
+ | 'collection'
15
+ | 'api'
16
+ | 'static'
17
+ | 'page-context';
18
+
19
+ export interface ThemeDataSource {
20
+ id: string;
21
+ name: string;
22
+ type: ThemeDataSourceType;
23
+ details: {
24
+ collection?: {
25
+ pk: number;
26
+ slug?: string;
27
+ productLimit: number;
28
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
29
+ products?: Record<string, any>[];
30
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
31
+ data?: Record<string, any> | Record<string, any>[];
32
+ };
33
+ api?: {
34
+ url: string;
35
+ method: string;
36
+ headers: Record<string, string>;
37
+ body: Record<string, string>;
38
+ };
39
+ static?: {
40
+ name: string;
41
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
42
+ data: Record<string, any>[];
43
+ };
44
+ pageContext?: {
45
+ pageType: string;
46
+ rootPath: string;
47
+ };
48
+ };
49
+ }
50
+
51
+ export interface ThemeBlock {
52
+ id: string;
53
+ type: string;
54
+ label: string;
55
+ styleSourceId?: string;
56
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
57
+ value?: any;
58
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
59
+ properties?: Record<string, any>;
60
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
61
+ styles?: Record<string, any>;
62
+ order?: number;
63
+ hidden?: boolean;
64
+ locked?: boolean;
65
+ /** Nested blocks (group/iterator containers). */
66
+ blocks?: ThemeBlock[];
67
+ isIterator?: boolean;
68
+ iteratorDataPath?: string;
69
+ }
70
+
71
+ export interface ThemeSectionEditorConfig {
72
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
73
+ properties?: any[];
74
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
75
+ styles?: any[];
76
+ blocks?: Record<
77
+ string,
78
+ {
79
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
80
+ properties?: any[];
81
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
82
+ styles?: any[];
83
+ }
84
+ >;
85
+ hasContent?: boolean;
86
+ }
87
+
88
+ export interface ThemeSection {
89
+ id: string;
90
+ type: string;
91
+ label: string;
92
+ name?: string;
93
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
94
+ properties?: Record<string, any>;
95
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
96
+ styles?: Record<string, any>;
97
+ blocks: ThemeBlock[];
98
+ order: number;
99
+ hidden?: boolean;
100
+ locked?: boolean;
101
+ /** Reference to an entry in `Theme.dataSources`. */
102
+ dataSourceId?: string;
103
+ /**
104
+ * Renderer-side convenience: the resolved data source for `dataSourceId`.
105
+ * Never authored by the editor — the preview wrapper attaches it before
106
+ * handing sections to section renderers.
107
+ */
108
+ dataSource?: ThemeDataSource | null;
109
+ isNew?: boolean;
110
+ isModified?: boolean;
111
+ editorConfig?: ThemeSectionEditorConfig;
112
+ }
113
+
114
+ export interface ThemePlaceholder {
115
+ id: string;
116
+ slug: string;
117
+ name?: string;
118
+ sections: ThemeSection[];
119
+ isModified?: boolean;
120
+ }
121
+
122
+ export interface ThemeCustomFontSource {
123
+ dataUrl: string;
124
+ format: string;
125
+ }
126
+
127
+ export interface ThemeCustomFont {
128
+ name: string;
129
+ sources: ThemeCustomFontSource[];
130
+ }
131
+
132
+ export interface ThemeTypographyVariant {
133
+ key: string;
134
+ label?: string;
135
+ labelKey?: string;
136
+ fontFamily: string;
137
+ fontWeight: string;
138
+ fontSize: number;
139
+ color: string;
140
+ styles: string[];
141
+ }
142
+
143
+ export interface ThemeSettings {
144
+ colors?: {
145
+ primary?: string;
146
+ secondary?: string;
147
+ };
148
+ typography?: ThemeTypographyVariant[];
149
+ customFonts?: ThemeCustomFont[];
150
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
151
+ [key: string]: any;
152
+ }
153
+
154
+ /** Breakpoints are editor-defined; the renderer only forwards the key. */
155
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
156
+ export type ThemeBreakpoint = Record<string, any>;
157
+
158
+ export interface Theme {
159
+ name?: string;
160
+ slug?: string;
161
+ settings?: ThemeSettings;
162
+ breakpoints?: ThemeBreakpoint[];
163
+ placeholders: ThemePlaceholder[];
164
+ dataSources?: ThemeDataSource[];
165
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
166
+ customPages?: Record<string, any>[];
167
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
168
+ stylePresets?: Record<string, any>[];
169
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
170
+ reusableSectionTemplates?: Record<string, any>[];
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // Native widget registration (preview → editor)
175
+ // ---------------------------------------------------------------------------
176
+
177
+ export interface NativeWidgetBlock {
178
+ id: string;
179
+ type: string;
180
+ label: string;
181
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
182
+ value?: any;
183
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
184
+ properties?: Record<string, any>;
185
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
186
+ styles?: Record<string, any>;
187
+ locked?: boolean;
188
+ blocks?: NativeWidgetBlock[];
189
+ }
190
+
191
+ export interface NativeWidgetSection {
192
+ id: string;
193
+ type: string;
194
+ label: string;
195
+ blocks: NativeWidgetBlock[];
196
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
197
+ properties?: Record<string, any>;
198
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
199
+ styles?: Record<string, any>;
200
+ locked?: boolean;
201
+ editorConfig?: ThemeSectionEditorConfig;
202
+ }
203
+
204
+ export interface NativeWidgetConfig {
205
+ placeholderId: string;
206
+ name: string;
207
+ /** If false, section won't be auto-inserted when registered. Must be added via "Add Section". */
208
+ autoAdd?: boolean;
209
+ widgetSlug?: string;
210
+ section: NativeWidgetSection;
211
+ }
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // Page context discovery (preview → editor)
215
+ // ---------------------------------------------------------------------------
216
+
217
+ export interface PageContextDiscoveryEntry {
218
+ path: string;
219
+ kind: 'scalar' | 'array';
220
+ valueType: 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array';
221
+ sample?: string;
222
+ }
223
+
224
+ export interface PageContextDiscovery {
225
+ roots: string[];
226
+ entries: PageContextDiscoveryEntry[];
227
+ }
@@ -0,0 +1,90 @@
1
+ import {
2
+ LEGACY_PROTOCOL_VERSION,
3
+ THEME_CONTRACT_PROTOCOL_VERSION,
4
+ ThemeMessage,
5
+ isKnownThemeMessageType
6
+ } from './messages';
7
+
8
+ export type ThemeMessageRejection =
9
+ | 'not-an-object'
10
+ | 'missing-type'
11
+ | 'unknown-type'
12
+ | 'invalid-data';
13
+
14
+ export interface ParsedThemeMessageOk {
15
+ ok: true;
16
+ message: ThemeMessage;
17
+ /** Version the sender stamped; LEGACY_PROTOCOL_VERSION when absent. */
18
+ remoteVersion: number;
19
+ versionMismatch: boolean;
20
+ }
21
+
22
+ export interface ParsedThemeMessageRejected {
23
+ ok: false;
24
+ reason: ThemeMessageRejection;
25
+ /** Present for 'unknown-type', to make drift diagnosable. */
26
+ type?: string;
27
+ }
28
+
29
+ export type ParsedThemeMessage =
30
+ | ParsedThemeMessageOk
31
+ | ParsedThemeMessageRejected;
32
+
33
+ /**
34
+ * Validate a raw `MessageEvent.data` against the contract. Never throws.
35
+ *
36
+ * This is deliberately envelope-level (shape + known type + version), not a
37
+ * deep per-message schema: `window` receives traffic from devtools,
38
+ * extensions and unrelated code, so the job here is to cheaply separate
39
+ * contract messages from noise and to make cross-repo drift (an unknown
40
+ * type, a version bump) visible to the caller instead of silently ignored.
41
+ * Content-level validation stays where it is today (`@akinon/pz-theme`'s
42
+ * zod `theme-schema.ts` on the render side).
43
+ *
44
+ * NOTE: origin trust is a separate concern — check `event.origin` FIRST
45
+ * (see origin.ts), then parse.
46
+ */
47
+ export function parseThemeMessage(raw: unknown): ParsedThemeMessage {
48
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
49
+ return { ok: false, reason: 'not-an-object' };
50
+ }
51
+
52
+ const candidate = raw as {
53
+ type?: unknown;
54
+ data?: unknown;
55
+ protocolVersion?: unknown;
56
+ };
57
+
58
+ if (typeof candidate.type !== 'string' || candidate.type.length === 0) {
59
+ return { ok: false, reason: 'missing-type' };
60
+ }
61
+
62
+ if (!isKnownThemeMessageType(candidate.type)) {
63
+ return { ok: false, reason: 'unknown-type', type: candidate.type };
64
+ }
65
+
66
+ // `data` is either absent, a payload object, or the legacy boolean used by
67
+ // SET_THEME_EDITOR_COOKIE. Anything else is a malformed frame.
68
+ if (
69
+ candidate.data !== undefined &&
70
+ candidate.data !== null &&
71
+ typeof candidate.data !== 'object' &&
72
+ typeof candidate.data !== 'boolean'
73
+ ) {
74
+ return { ok: false, reason: 'invalid-data' };
75
+ }
76
+
77
+ const remoteVersion =
78
+ typeof candidate.protocolVersion === 'number'
79
+ ? candidate.protocolVersion
80
+ : LEGACY_PROTOCOL_VERSION;
81
+
82
+ return {
83
+ ok: true,
84
+ message: candidate as ThemeMessage,
85
+ remoteVersion,
86
+ versionMismatch:
87
+ remoteVersion !== LEGACY_PROTOCOL_VERSION &&
88
+ remoteVersion !== THEME_CONTRACT_PROTOCOL_VERSION
89
+ };
90
+ }