@u-devtools/core 0.1.6 → 0.2.1

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.
@@ -0,0 +1,36 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * Zod schema for RPC message validation.
5
+ * Validates the structure of RPC messages used for communication between Server and Client contexts.
6
+ */
7
+ export const RpcMessageSchema = z.object({
8
+ /** Unique message identifier */
9
+ id: z.string().min(1),
10
+ /** Message type: 'request' for RPC calls, 'response' for replies, 'event' for broadcasts */
11
+ type: z.enum(['request', 'response', 'event']),
12
+ /** RPC method name (for requests and events) */
13
+ method: z.string().optional(),
14
+ /** Message payload data */
15
+ payload: z.unknown().optional(),
16
+ /** Error information (for error responses) */
17
+ error: z.unknown().optional(),
18
+ });
19
+
20
+ /**
21
+ * Type inferred from RpcMessageSchema
22
+ */
23
+ export type RpcMessageType = z.infer<typeof RpcMessageSchema>;
24
+
25
+ /**
26
+ * Validates an unknown value as an RPC message.
27
+ * @param data - Data to validate
28
+ * @returns Validated RPC message or null if validation fails
29
+ */
30
+ export function validateRpcMessage(data: unknown): RpcMessageType | null {
31
+ const result = RpcMessageSchema.safeParse(data);
32
+ if (result.success) {
33
+ return result.data;
34
+ }
35
+ return null;
36
+ }
@@ -0,0 +1,125 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * Zod schema for setting type validation.
5
+ */
6
+ export const SettingTypeSchema = z.enum(['string', 'number', 'boolean', 'select', 'array']);
7
+
8
+ /**
9
+ * Zod schema for setting option (used in select type).
10
+ */
11
+ export const SettingOptionSchema = z.object({
12
+ label: z.string(),
13
+ value: z.unknown(),
14
+ });
15
+
16
+ /**
17
+ * Zod schema for setting schema definition.
18
+ * Recursive schema using z.lazy for items field.
19
+ */
20
+ export const SettingSchemaDefSchema: z.ZodType<any> = z.lazy(() =>
21
+ z.object({
22
+ /** Display label for the setting */
23
+ label: z.string().min(1),
24
+ /** Optional description/tooltip text */
25
+ description: z.string().optional(),
26
+ /** Setting type (determines input component) */
27
+ type: SettingTypeSchema,
28
+ /** Default value */
29
+ default: z.unknown().optional(),
30
+ /** Options for 'select' type settings */
31
+ options: z.array(SettingOptionSchema).optional(),
32
+ /** Schema for array items (for 'array' type with object items) */
33
+ items: z.record(z.string(), SettingSchemaDefSchema).optional(),
34
+ /** Item type for 'array' type with primitive items ('string' or 'number') */
35
+ itemType: z.enum(['string', 'number']).optional(),
36
+ })
37
+ );
38
+
39
+ /**
40
+ * Zod schema for plugin settings schema (record of setting definitions).
41
+ */
42
+ export const PluginSettingsSchemaSchema = z.record(z.string(), SettingSchemaDefSchema);
43
+
44
+ /**
45
+ * Type inferred from SettingSchemaDefSchema
46
+ */
47
+ export type SettingSchemaDefType = z.infer<typeof SettingSchemaDefSchema>;
48
+
49
+ /**
50
+ * Type inferred from PluginSettingsSchemaSchema
51
+ */
52
+ export type PluginSettingsSchemaType = z.infer<typeof PluginSettingsSchemaSchema>;
53
+
54
+ /**
55
+ * Validates a setting schema definition.
56
+ * @param data - Data to validate
57
+ * @returns Validated setting schema definition or null if validation fails
58
+ */
59
+ export function validateSettingSchemaDef(data: unknown): SettingSchemaDefType | null {
60
+ const result = SettingSchemaDefSchema.safeParse(data);
61
+ if (result.success) {
62
+ return result.data;
63
+ }
64
+ return null;
65
+ }
66
+
67
+ /**
68
+ * Validates a plugin settings schema.
69
+ * @param data - Data to validate
70
+ * @returns Validated plugin settings schema or null if validation fails
71
+ */
72
+ export function validatePluginSettingsSchema(data: unknown): PluginSettingsSchemaType | null {
73
+ const result = PluginSettingsSchemaSchema.safeParse(data);
74
+ if (result.success) {
75
+ return result.data;
76
+ }
77
+ return null;
78
+ }
79
+
80
+ /**
81
+ * Validates a setting value against its schema definition.
82
+ * @param value - Value to validate
83
+ * @param schemaDef - Setting schema definition
84
+ * @returns True if value is valid, false otherwise
85
+ */
86
+ export function validateSettingValue(value: unknown, schemaDef: SettingSchemaDefType): boolean {
87
+ switch (schemaDef.type) {
88
+ case 'string':
89
+ return typeof value === 'string';
90
+ case 'number':
91
+ return typeof value === 'number' && !Number.isNaN(value);
92
+ case 'boolean':
93
+ return typeof value === 'boolean';
94
+ case 'select':
95
+ if (!schemaDef.options) return false;
96
+ return schemaDef.options.some((opt: { label: string; value: unknown }) => opt.value === value);
97
+ case 'array':
98
+ if (!Array.isArray(value)) return false;
99
+ if (schemaDef.itemType) {
100
+ // Primitive array items
101
+ return value.every((item) => {
102
+ if (schemaDef.itemType === 'string') return typeof item === 'string';
103
+ if (schemaDef.itemType === 'number') return typeof item === 'number';
104
+ return false;
105
+ });
106
+ }
107
+ if (schemaDef.items) {
108
+ // Object array items - validate each item
109
+ const itemsSchema = schemaDef.items;
110
+ return value.every((item) => {
111
+ if (typeof item !== 'object' || item === null) return false;
112
+ for (const [key, itemSchema] of Object.entries(itemsSchema)) {
113
+ const itemValue = (item as Record<string, unknown>)[key];
114
+ if (!validateSettingValue(itemValue, itemSchema)) {
115
+ return false;
116
+ }
117
+ }
118
+ return true;
119
+ });
120
+ }
121
+ return true; // Array without constraints
122
+ default:
123
+ return false;
124
+ }
125
+ }
@@ -0,0 +1,172 @@
1
+ import type { RpcMessage } from './index';
2
+ import { validateRpcMessage } from './schemas/rpc';
3
+ import { extractErrorMessage } from '@u-devtools/utils';
4
+
5
+ /**
6
+ * Unique ID generator for RPC requests
7
+ */
8
+ const uuid = () => Math.random().toString(36).substring(2, 15);
9
+
10
+ /**
11
+ * Abstract class for message transport.
12
+ * Provides common logic for RPC calls and event subscriptions.
13
+ */
14
+ export abstract class Transport {
15
+ protected handlers = new Map<
16
+ string,
17
+ { resolve: (value: unknown) => void; reject: (error: Error) => void }
18
+ >();
19
+ protected eventListeners = new Map<string, Set<(data: unknown) => void>>();
20
+ protected disposed = false;
21
+ protected timeout = 5000; // Default timeout
22
+
23
+ /**
24
+ * Send a message through the transport
25
+ */
26
+ protected abstract sendMessage(type: string, data: unknown): void;
27
+
28
+ /**
29
+ * Subscribe to messages from the transport
30
+ */
31
+ protected abstract subscribe(type: string, handler: (data: unknown) => void): () => void;
32
+
33
+ /**
34
+ * Unsubscribe from transport messages
35
+ */
36
+ protected abstract unsubscribe?(type: string, handler: (data: unknown) => void): void;
37
+
38
+ /**
39
+ * RPC method call
40
+ */
41
+ call<T = unknown>(method: string, payload?: unknown): Promise<T> {
42
+ if (this.disposed) {
43
+ return Promise.reject(new Error('Transport has been disposed'));
44
+ }
45
+
46
+ const id = uuid();
47
+ return new Promise<T>((resolve, reject) => {
48
+ this.handlers.set(id, {
49
+ resolve: resolve as (value: unknown) => void,
50
+ reject,
51
+ });
52
+
53
+ // Send request
54
+ this.sendMessage('request', { id, type: 'request', method, payload });
55
+
56
+ // Timeout
57
+ setTimeout(() => {
58
+ const handler = this.handlers.get(id);
59
+ if (handler) {
60
+ this.handlers.delete(id);
61
+ const error = new Error(`RPC Timeout: ${method}`);
62
+ console.error('[RPC Timeout]', {
63
+ method,
64
+ payload,
65
+ stack: error.stack,
66
+ timestamp: new Date().toISOString(),
67
+ });
68
+ handler.reject(error);
69
+ }
70
+ }, this.timeout);
71
+ });
72
+ }
73
+
74
+ /**
75
+ * Subscribe to events
76
+ */
77
+ on(event: string, fn: (data: unknown) => void): () => void {
78
+ if (!this.eventListeners.has(event)) {
79
+ this.eventListeners.set(event, new Set());
80
+ }
81
+
82
+ this.eventListeners.get(event)?.add(fn);
83
+
84
+ // Subscribe to transport events
85
+ const unsubscribe = this.subscribe('event', (data: unknown) => {
86
+ const msg = data as RpcMessage;
87
+ if (msg.method === event) {
88
+ fn(msg.payload);
89
+ }
90
+ });
91
+
92
+ // Return unsubscribe function
93
+ return () => {
94
+ const listeners = this.eventListeners.get(event);
95
+ if (listeners) {
96
+ listeners.delete(fn);
97
+ }
98
+ unsubscribe();
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Unsubscribe from events
104
+ */
105
+ off(event: string, fn: (data: unknown) => void) {
106
+ const listeners = this.eventListeners.get(event);
107
+ if (listeners) {
108
+ listeners.delete(fn);
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Handle incoming messages (called by transport)
114
+ */
115
+ protected handleMessage(data: unknown) {
116
+ if (this.disposed) return;
117
+
118
+ // Validate message structure using Zod
119
+ const msg = validateRpcMessage(data);
120
+ if (!msg) {
121
+ console.warn('[Transport] Invalid RPC message structure:', data);
122
+ return;
123
+ }
124
+
125
+ // Handle RPC responses
126
+ if (msg.type === 'response') {
127
+ const handler = this.handlers.get(msg.id);
128
+ if (handler) {
129
+ if (msg.error) {
130
+ // Extract error message using utility function
131
+ const errorMessage = extractErrorMessage(msg.error);
132
+ handler.reject(new Error(errorMessage));
133
+ } else {
134
+ handler.resolve(msg.payload);
135
+ }
136
+ this.handlers.delete(msg.id);
137
+ }
138
+ return;
139
+ }
140
+
141
+ // Handle events
142
+ if (msg.type === 'event') {
143
+ const method = msg.method || '';
144
+ const listeners = this.eventListeners.get(method);
145
+ if (listeners && listeners.size > 0) {
146
+ for (const fn of listeners) {
147
+ try {
148
+ fn(msg.payload);
149
+ } catch (err) {
150
+ console.error('[Transport] Error in listener:', err);
151
+ }
152
+ }
153
+ }
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Clean up all handlers and subscriptions
159
+ */
160
+ dispose() {
161
+ this.disposed = true;
162
+
163
+ // Reject all pending requests
164
+ for (const [id, handler] of this.handlers.entries()) {
165
+ handler.reject(new Error(`Transport disposed: request ${id} was cancelled`));
166
+ }
167
+ this.handlers.clear();
168
+
169
+ // Clear all event subscriptions
170
+ this.eventListeners.clear();
171
+ }
172
+ }
@@ -0,0 +1,174 @@
1
+ import { Transport } from '../transport';
2
+
3
+ /**
4
+ * Transport based on BroadcastChannel
5
+ * Used for communication between App (window) and Client (iframe)
6
+ * Does not support RPC (call), only events (send/on)
7
+ */
8
+ export class BroadcastTransport extends Transport {
9
+ private channel: BroadcastChannel;
10
+ private messageHandler?: (e: MessageEvent) => void;
11
+ private namespace: string;
12
+
13
+ constructor(namespace: string) {
14
+ super();
15
+ this.namespace = namespace;
16
+ this.channel = new BroadcastChannel(`u-devtools:${namespace}`);
17
+
18
+ this.messageHandler = (e: MessageEvent) => {
19
+ const { event, data } = e.data as { event: string; data: unknown };
20
+ // BroadcastChannel is only used for events, not for RPC
21
+ const listeners = this.eventListeners.get(event);
22
+ if (listeners) {
23
+ listeners.forEach((fn) => {
24
+ fn(data);
25
+ });
26
+ }
27
+ };
28
+
29
+ this.channel.onmessage = this.messageHandler;
30
+ }
31
+
32
+ /**
33
+ * Extract values from Vue reactive objects (ref, computed, reactive)
34
+ */
35
+ private unwrapVueReactive(value: unknown): unknown {
36
+ // Check if this is a Vue ref/computed
37
+ if (value && typeof value === 'object') {
38
+ // Vue 3 ref/computed have __v_isRef or _value property
39
+ if ('__v_isRef' in value || '_value' in value) {
40
+ const refValue = (value as { _value?: unknown; value?: unknown })._value ?? (value as { value?: unknown }).value;
41
+ return this.unwrapVueReactive(refValue);
42
+ }
43
+ // Vue reactive objects may have circular references
44
+ if (Array.isArray(value)) {
45
+ return value.map(item => this.unwrapVueReactive(item));
46
+ }
47
+ // Regular objects - process recursively
48
+ const result: Record<string, unknown> = {};
49
+ const seen = new WeakSet();
50
+ for (const key in value) {
51
+ if (Object.prototype.hasOwnProperty.call(value, key)) {
52
+ const val = (value as Record<string, unknown>)[key];
53
+ // Skip circular references and Vue internal properties
54
+ if (key.startsWith('__') || key.startsWith('_') && key !== '_value') {
55
+ continue;
56
+ }
57
+ if (val && typeof val === 'object') {
58
+ if (seen.has(val as object)) {
59
+ result[key] = '[Circular]';
60
+ continue;
61
+ }
62
+ seen.add(val as object);
63
+ }
64
+ result[key] = this.unwrapVueReactive(val);
65
+ }
66
+ }
67
+ return result;
68
+ }
69
+ return value;
70
+ }
71
+
72
+ /**
73
+ * Send event (not RPC)
74
+ */
75
+ send(event: string, data?: unknown): void {
76
+ try {
77
+ // Extract values from Vue reactive objects before sending
78
+ const serializableData = data !== undefined ? this.unwrapVueReactive(data) : undefined;
79
+ this.channel.postMessage({ event, data: serializableData });
80
+ } catch (e) {
81
+ // Ignore errors if channel is closed
82
+ if (
83
+ e instanceof DOMException &&
84
+ (e.name === 'InvalidStateError' || e.message?.includes('closed'))
85
+ ) {
86
+ console.warn(`[BroadcastTransport:${this.namespace}] Cannot send event "${event}": channel is closed`);
87
+ return;
88
+ }
89
+
90
+ // DataCloneError - add detailed information
91
+ if (e instanceof Error && (e.name === 'DataCloneError' || e.message?.includes('circular'))) {
92
+ const stack = new Error().stack;
93
+ const dataType = data === null ? 'null' : data === undefined ? 'undefined' : typeof data;
94
+ let dataPreview = '';
95
+ try {
96
+ dataPreview = typeof data === 'object' && data !== null
97
+ ? JSON.stringify(data, (key, value) => {
98
+ if (typeof value === 'function') return '[Function]';
99
+ if (value instanceof Node) return `[${value.nodeName}]`;
100
+ if (value instanceof Error) return `[Error: ${value.message}]`;
101
+ // Skip Vue internal properties
102
+ if (key.startsWith('__') || (key.startsWith('_') && key !== '_value')) return undefined;
103
+ return value;
104
+ }, 2).slice(0, 200)
105
+ : String(data).slice(0, 100);
106
+ } catch {
107
+ dataPreview = '[Unable to serialize]';
108
+ }
109
+
110
+ console.error(
111
+ `[BroadcastTransport:${this.namespace}] DataCloneError: Failed to send event "${event}".\n` +
112
+ `Data type: ${dataType}\n` +
113
+ `Data preview: ${dataPreview}\n` +
114
+ `This usually means the data contains non-serializable objects (functions, DOM nodes, Vue refs, etc.).\n` +
115
+ `Stack trace:\n${stack?.split('\n').slice(0, 5).join('\n') || 'N/A'}`
116
+ );
117
+ return; // Don't throw error, just log
118
+ }
119
+
120
+ throw e;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * RPC calls are not supported in BroadcastChannel
126
+ */
127
+ override call<T = unknown>(_method: string, _payload?: unknown): Promise<T> {
128
+ return Promise.reject(
129
+ new Error('RPC calls are not supported in BroadcastTransport. Use send() for events.')
130
+ );
131
+ }
132
+
133
+ protected sendMessage(_type: string, _data: unknown): void {
134
+ // BroadcastChannel does not support RPC
135
+ throw new Error('sendMessage is not supported in BroadcastTransport. Use send() instead.');
136
+ }
137
+
138
+ protected subscribe(event: string, handler: (data: unknown) => void): () => void {
139
+ // Subscription is already handled via messageHandler
140
+ if (!this.eventListeners.has(event)) {
141
+ this.eventListeners.set(event, new Set());
142
+ }
143
+ const listeners = this.eventListeners.get(event);
144
+ if (listeners) {
145
+ listeners.add(handler);
146
+ }
147
+ // Return unsubscribe function
148
+ return () => {
149
+ const listeners = this.eventListeners.get(event);
150
+ if (listeners) {
151
+ listeners.delete(handler);
152
+ }
153
+ };
154
+ }
155
+
156
+ protected unsubscribe(event: string, handler: (data: unknown) => void): void {
157
+ const listeners = this.eventListeners.get(event);
158
+ if (listeners) {
159
+ listeners.delete(handler);
160
+ }
161
+ }
162
+
163
+ override dispose() {
164
+ super.dispose();
165
+ this.channel.close();
166
+ }
167
+
168
+ /**
169
+ * Close channel (alias for dispose)
170
+ */
171
+ close() {
172
+ this.dispose();
173
+ }
174
+ }
@@ -0,0 +1,82 @@
1
+ import { Transport } from '../transport';
2
+
3
+ /**
4
+ * Transport based on Vite HMR (Hot Module Replacement)
5
+ * Used for communication between client (iframe) and server (Node.js)
6
+ */
7
+ export class HmrTransport extends Transport {
8
+ private responseHandler?: (data: unknown) => void;
9
+ private eventHandler?: (data: unknown) => void;
10
+
11
+ constructor(
12
+ private hot: {
13
+ send: (event: string, data: unknown) => void;
14
+ on: (event: string, handler: (data: unknown) => void) => void;
15
+ off?: (event: string, handler: (data: unknown) => void) => void;
16
+ }
17
+ ) {
18
+ super();
19
+ if (!hot) throw new Error('Hot Module Replacement is required for HmrTransport');
20
+
21
+ // Listen for responses from server
22
+ this.responseHandler = (data: unknown) => {
23
+ this.handleMessage(data);
24
+ };
25
+ hot.on('u-devtools:response', this.responseHandler);
26
+
27
+ // Listen for events from server
28
+ this.eventHandler = (data: unknown) => {
29
+ this.handleMessage(data);
30
+ };
31
+ hot.on('u-devtools:event', this.eventHandler);
32
+ }
33
+
34
+ /**
35
+ * Override on() for HMR, as subscription is already set up in constructor
36
+ */
37
+ override on(event: string, fn: (data: unknown) => void): () => void {
38
+ if (!this.eventListeners.has(event)) {
39
+ this.eventListeners.set(event, new Set());
40
+ }
41
+ this.eventListeners.get(event)?.add(fn);
42
+
43
+ // Return unsubscribe function
44
+ return () => {
45
+ const listeners = this.eventListeners.get(event);
46
+ if (listeners) {
47
+ listeners.delete(fn);
48
+ }
49
+ };
50
+ }
51
+
52
+ protected sendMessage(type: string, data: unknown): void {
53
+ if (type === 'request') {
54
+ this.hot.send('u-devtools:request', data);
55
+ }
56
+ }
57
+
58
+ protected subscribe(_type: string, _handler: (data: unknown) => void): () => void {
59
+ // HMR is already subscribed in constructor, just return empty function
60
+ // as handling happens through responseHandler and eventHandler
61
+ return () => {
62
+ // Unsubscription is handled in dispose
63
+ };
64
+ }
65
+
66
+ protected unsubscribe(_type: string, _handler: (data: unknown) => void): void {
67
+ // HMR doesn't support unsubscribing individual handlers directly
68
+ // Unsubscription happens through dispose
69
+ }
70
+
71
+ override dispose() {
72
+ super.dispose();
73
+
74
+ // Unsubscribe from HMR events if API supports off
75
+ if (this.hot.off && this.responseHandler) {
76
+ this.hot.off('u-devtools:response', this.responseHandler);
77
+ }
78
+ if (this.hot.off && this.eventHandler) {
79
+ this.hot.off('u-devtools:event', this.eventHandler);
80
+ }
81
+ }
82
+ }