@rozenite/vite-plugin 1.12.0 → 2.0.0
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/README.md +12 -0
- package/dist/bundle-dts.d.ts.map +1 -1
- package/dist/client-plugin.d.ts.map +1 -1
- package/dist/dev-config-module.d.ts.map +1 -1
- package/dist/dev-host/assets/index-DU3zXL2c.css +1 -0
- package/dist/dev-host/assets/index-Xsb6uYhI.js +13 -0
- package/dist/dev-host/index.html +3 -3
- package/dist/dev-host/manifest.json +2 -2
- package/dist/index.cjs +24 -8
- package/dist/index.d.ts +9 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -8
- package/dist/react-native-plugin.d.ts.map +1 -1
- package/dist/sdk-plugin.d.ts.map +1 -1
- package/dist/server-plugin.d.ts.map +1 -1
- package/dist/tsconfig.lib.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/bundle-dts.ts +126 -0
- package/src/client-plugin.ts +394 -0
- package/src/dev-config-module.ts +32 -0
- package/src/dev-host/App.tsx +373 -0
- package/src/dev-host/components/DispatchForm.tsx +155 -0
- package/src/dev-host/components/FlowList.tsx +122 -0
- package/src/dev-host/components/MessageDetailsPane.tsx +82 -0
- package/src/dev-host/components/MessageLogPane.tsx +102 -0
- package/src/dev-host/components/PanelTabs.tsx +41 -0
- package/src/dev-host/config.ts +111 -0
- package/src/dev-host/constants.ts +12 -0
- package/src/dev-host/flow-runtime.ts +318 -0
- package/src/dev-host/index.html +12 -0
- package/src/dev-host/main.tsx +22 -0
- package/src/dev-host/server.ts +61 -0
- package/src/dev-host/styles.css +301 -0
- package/src/dev-host/types.ts +56 -0
- package/src/dev-host/utils.ts +99 -0
- package/src/dev-host/vite.config.mts +22 -0
- package/src/index.ts +124 -0
- package/src/load-config.ts +117 -0
- package/src/package-json.ts +16 -0
- package/src/react-native-plugin.ts +52 -0
- package/src/require-plugin.ts +221 -0
- package/src/sdk-plugin.ts +47 -0
- package/src/server-plugin.ts +39 -0
- package/src/utils.ts +31 -0
- package/src/virtual-modules.d.ts +7 -0
- package/dist/dev-host/assets/index-CkxvBMpp.css +0 -1
- package/dist/dev-host/assets/index-cNKHRwej.js +0 -11
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Button, JsonInspector, ScrollArea } from '@rozenite/ui';
|
|
2
|
+
import { Send, X } from 'lucide-react';
|
|
3
|
+
import type { MessageEntry } from '../types.js';
|
|
4
|
+
import { formatMessageDate, formatPayloadForCommandInput } from '../utils.js';
|
|
5
|
+
|
|
6
|
+
type MessageDetailsPaneProps = {
|
|
7
|
+
selectedMessage: MessageEntry;
|
|
8
|
+
onClose: () => void;
|
|
9
|
+
onUseMessage: (message: MessageEntry) => void;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const MessageDetailsPane = ({
|
|
13
|
+
selectedMessage,
|
|
14
|
+
onClose,
|
|
15
|
+
onUseMessage,
|
|
16
|
+
}: MessageDetailsPaneProps) => {
|
|
17
|
+
return (
|
|
18
|
+
<section className="dev-host-pane">
|
|
19
|
+
<header className="dev-host-pane-header">
|
|
20
|
+
<h2 className="dev-host-pane-title">Message Details</h2>
|
|
21
|
+
<div className="dev-host-pane-actions">
|
|
22
|
+
<Button
|
|
23
|
+
variant="ghost"
|
|
24
|
+
size="icon"
|
|
25
|
+
onClick={() => onUseMessage(selectedMessage)}
|
|
26
|
+
aria-label="Use message in dispatcher"
|
|
27
|
+
title="Use message in dispatcher"
|
|
28
|
+
>
|
|
29
|
+
<Send />
|
|
30
|
+
</Button>
|
|
31
|
+
<Button
|
|
32
|
+
variant="ghost"
|
|
33
|
+
size="icon"
|
|
34
|
+
onClick={onClose}
|
|
35
|
+
aria-label="Close message details"
|
|
36
|
+
title="Close message details"
|
|
37
|
+
>
|
|
38
|
+
<X />
|
|
39
|
+
</Button>
|
|
40
|
+
</div>
|
|
41
|
+
</header>
|
|
42
|
+
|
|
43
|
+
<ScrollArea className="dev-host-scroll">
|
|
44
|
+
<div className="dev-host-details">
|
|
45
|
+
<div className="dev-host-detail-section">
|
|
46
|
+
<span className="dev-host-label">Date</span>
|
|
47
|
+
<div className="dev-host-detail-value">
|
|
48
|
+
{formatMessageDate(selectedMessage.date)}
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
<div className="dev-host-detail-section">
|
|
52
|
+
<span className="dev-host-label">Type</span>
|
|
53
|
+
<div className="dev-host-detail-value">{selectedMessage.type}</div>
|
|
54
|
+
</div>
|
|
55
|
+
<div className="dev-host-detail-section">
|
|
56
|
+
<span className="dev-host-label">Payload</span>
|
|
57
|
+
<div className="dev-host-detail-payload">
|
|
58
|
+
{typeof selectedMessage.payload === 'object' &&
|
|
59
|
+
selectedMessage.payload !== null ? (
|
|
60
|
+
<JsonInspector
|
|
61
|
+
data={selectedMessage.payload}
|
|
62
|
+
defaultExpandedDepth={2}
|
|
63
|
+
/>
|
|
64
|
+
) : (
|
|
65
|
+
<pre className="m-0 whitespace-pre-wrap break-words">
|
|
66
|
+
{formatPayloadForCommandInput(selectedMessage.payload)}
|
|
67
|
+
</pre>
|
|
68
|
+
)}
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
</ScrollArea>
|
|
73
|
+
</section>
|
|
74
|
+
);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const getDispatcherValuesFromMessage = (message: MessageEntry) => {
|
|
78
|
+
return {
|
|
79
|
+
commandType: message.type,
|
|
80
|
+
commandPayload: formatPayloadForCommandInput(message.payload),
|
|
81
|
+
};
|
|
82
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Button, DataTable, ScrollArea } from '@rozenite/ui';
|
|
2
|
+
import { Trash2 } from 'lucide-react';
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
import type { DataTableColumn } from '@rozenite/ui';
|
|
5
|
+
import type { MessageEntry } from '../types.js';
|
|
6
|
+
import { formatMessageTableDate, formatPayloadPreview } from '../utils.js';
|
|
7
|
+
|
|
8
|
+
type MessageLogPaneProps = {
|
|
9
|
+
messages: MessageEntry[];
|
|
10
|
+
onSelectMessage: (messageId: string) => void;
|
|
11
|
+
onClearMessages: () => void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const MessageLogPane = ({
|
|
15
|
+
messages,
|
|
16
|
+
onSelectMessage,
|
|
17
|
+
onClearMessages,
|
|
18
|
+
}: MessageLogPaneProps) => {
|
|
19
|
+
const columns = useMemo<DataTableColumn<MessageEntry>[]>(
|
|
20
|
+
() => [
|
|
21
|
+
{
|
|
22
|
+
accessorKey: 'direction',
|
|
23
|
+
header: 'Dir',
|
|
24
|
+
cell: ({ getValue }) => {
|
|
25
|
+
const direction = getValue<MessageEntry['direction']>();
|
|
26
|
+
const isSent = direction === 'in';
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<span
|
|
30
|
+
className={
|
|
31
|
+
isSent ? 'dev-host-direction-in' : 'dev-host-direction-out'
|
|
32
|
+
}
|
|
33
|
+
aria-label={isSent ? 'Sent message' : 'Received message'}
|
|
34
|
+
title={isSent ? 'Sent message' : 'Received message'}
|
|
35
|
+
>
|
|
36
|
+
{isSent ? '↑' : '↓'}
|
|
37
|
+
</span>
|
|
38
|
+
);
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
accessorKey: 'date',
|
|
43
|
+
header: 'Date',
|
|
44
|
+
cell: ({ row }) => (
|
|
45
|
+
<span className="text-muted-foreground">
|
|
46
|
+
{formatMessageTableDate(row.original.date)}
|
|
47
|
+
</span>
|
|
48
|
+
),
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
accessorKey: 'type',
|
|
52
|
+
header: 'Type',
|
|
53
|
+
cell: ({ getValue }) => (
|
|
54
|
+
<span className="font-medium">{getValue<string>()}</span>
|
|
55
|
+
),
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: 'payload',
|
|
59
|
+
accessorFn: (message) => formatPayloadPreview(message.payload),
|
|
60
|
+
header: 'Payload',
|
|
61
|
+
cell: ({ getValue }) => (
|
|
62
|
+
<span className="block max-w-160 truncate font-mono text-xs text-muted-foreground">
|
|
63
|
+
{getValue<string>()}
|
|
64
|
+
</span>
|
|
65
|
+
),
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
[],
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<section className="dev-host-pane">
|
|
73
|
+
<header className="dev-host-pane-header">
|
|
74
|
+
<h2 className="dev-host-pane-title">Message Log</h2>
|
|
75
|
+
<Button
|
|
76
|
+
variant="ghost"
|
|
77
|
+
size="icon"
|
|
78
|
+
onClick={onClearMessages}
|
|
79
|
+
disabled={messages.length === 0}
|
|
80
|
+
aria-label="Clear message log"
|
|
81
|
+
title="Clear message log"
|
|
82
|
+
>
|
|
83
|
+
<Trash2 />
|
|
84
|
+
</Button>
|
|
85
|
+
</header>
|
|
86
|
+
|
|
87
|
+
<ScrollArea
|
|
88
|
+
className="dev-host-scroll"
|
|
89
|
+
viewportClassName="dev-host-scroll-viewport"
|
|
90
|
+
>
|
|
91
|
+
<DataTable
|
|
92
|
+
className="dev-host-message-table"
|
|
93
|
+
columns={columns}
|
|
94
|
+
data={messages}
|
|
95
|
+
emptyMessage="No messages yet."
|
|
96
|
+
getRowId={(message) => message.id}
|
|
97
|
+
onRowClick={(message) => onSelectMessage(message.id)}
|
|
98
|
+
/>
|
|
99
|
+
</ScrollArea>
|
|
100
|
+
</section>
|
|
101
|
+
);
|
|
102
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Select } from '@rozenite/ui';
|
|
2
|
+
import type { DevHostPanelEntry } from '../types.js';
|
|
3
|
+
|
|
4
|
+
type PanelTabsProps = {
|
|
5
|
+
panels: DevHostPanelEntry[];
|
|
6
|
+
activeSource: string;
|
|
7
|
+
onValueChange: (value: string) => void;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const PanelTabs = ({
|
|
11
|
+
panels,
|
|
12
|
+
activeSource,
|
|
13
|
+
onValueChange,
|
|
14
|
+
}: PanelTabsProps) => {
|
|
15
|
+
if (panels.length === 0) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const activePanel = panels.find((panel) => panel.source === activeSource);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<Select
|
|
23
|
+
value={activeSource}
|
|
24
|
+
onValueChange={(value) => onValueChange(String(value))}
|
|
25
|
+
>
|
|
26
|
+
<Select.Trigger
|
|
27
|
+
className="dev-host-panel-select"
|
|
28
|
+
aria-label="Plugin panel"
|
|
29
|
+
>
|
|
30
|
+
<Select.Value>{activePanel?.label}</Select.Value>
|
|
31
|
+
</Select.Trigger>
|
|
32
|
+
<Select.Content align="end">
|
|
33
|
+
{panels.map((panel) => (
|
|
34
|
+
<Select.Item key={panel.source} value={panel.source}>
|
|
35
|
+
{panel.label}
|
|
36
|
+
</Select.Item>
|
|
37
|
+
))}
|
|
38
|
+
</Select.Content>
|
|
39
|
+
</Select>
|
|
40
|
+
);
|
|
41
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { DevFlowEntry, DevPresetEntry } from '../load-config.js';
|
|
2
|
+
import { DEV_HOST_CONFIG_GLOBAL_KEY } from './constants.js';
|
|
3
|
+
import type { DevHostFlowEntry, DevHostPresetEntry } from './types.js';
|
|
4
|
+
|
|
5
|
+
type DevHostPresetSource = Omit<DevPresetEntry, 'name'> & {
|
|
6
|
+
name?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
type DevHostFlowSource = Omit<DevFlowEntry, 'name'> & {
|
|
10
|
+
name?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const getEntryDisplayName = (value: unknown, fallback: string) => {
|
|
14
|
+
if (typeof value === 'string' && value.trim()) {
|
|
15
|
+
return value.trim();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return fallback;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const getDevHostConfig = () => {
|
|
22
|
+
const value = (window as unknown as Record<string, unknown>)[
|
|
23
|
+
DEV_HOST_CONFIG_GLOBAL_KEY
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
if (typeof value !== 'object' || value === null) {
|
|
27
|
+
return {} as { presets?: unknown; flows?: unknown };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return value as { presets?: unknown; flows?: unknown };
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const isDevHostPresetSource = (
|
|
34
|
+
value: unknown,
|
|
35
|
+
): value is DevHostPresetSource => {
|
|
36
|
+
return (
|
|
37
|
+
typeof value === 'object' &&
|
|
38
|
+
value !== null &&
|
|
39
|
+
(!('name' in value) || typeof value.name === 'string') &&
|
|
40
|
+
'type' in value &&
|
|
41
|
+
typeof value.type === 'string' &&
|
|
42
|
+
'payload' in value
|
|
43
|
+
);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const isDevHostFlowSource = (value: unknown): value is DevHostFlowSource => {
|
|
47
|
+
return (
|
|
48
|
+
typeof value === 'object' &&
|
|
49
|
+
value !== null &&
|
|
50
|
+
(!('name' in value) || typeof value.name === 'string') &&
|
|
51
|
+
(!('autoRun' in value) || typeof value.autoRun === 'boolean') &&
|
|
52
|
+
'run' in value &&
|
|
53
|
+
typeof value.run === 'function'
|
|
54
|
+
);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const toDevHostPresetEntry = (value: unknown): DevHostPresetEntry | null => {
|
|
58
|
+
if (!isDevHostPresetSource(value)) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const displayName = getEntryDisplayName(value.name, 'Untitled preset');
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
name: displayName,
|
|
66
|
+
displayName,
|
|
67
|
+
type: value.type,
|
|
68
|
+
payload: value.payload,
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const toDevHostFlowEntry = (value: unknown): DevHostFlowEntry | null => {
|
|
73
|
+
if (!isDevHostFlowSource(value)) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const displayName = getEntryDisplayName(value.name, 'Untitled flow');
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
name: displayName,
|
|
81
|
+
displayName,
|
|
82
|
+
autoRun: value.autoRun ?? false,
|
|
83
|
+
run: value.run,
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const getDevHostPresets = (): DevHostPresetEntry[] => {
|
|
88
|
+
const presets = getDevHostConfig().presets;
|
|
89
|
+
|
|
90
|
+
if (!Array.isArray(presets)) {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return presets.flatMap((preset) => {
|
|
95
|
+
const entry = toDevHostPresetEntry(preset);
|
|
96
|
+
return entry ? [entry] : [];
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const getDevHostFlows = (): DevHostFlowEntry[] => {
|
|
101
|
+
const flows = getDevHostConfig().flows;
|
|
102
|
+
|
|
103
|
+
if (!Array.isArray(flows)) {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return flows.flatMap((flow) => {
|
|
108
|
+
const entry = toDevHostFlowEntry(flow);
|
|
109
|
+
return entry ? [entry] : [];
|
|
110
|
+
});
|
|
111
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const DEV_HOST_STATE_ELEMENT_ID = '__rozenite-dev-host__';
|
|
2
|
+
export const DEV_HOST_CONFIG_GLOBAL_KEY = '__rozenite-dev-config__';
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_DEVTOOLS_HEIGHT = 288;
|
|
5
|
+
export const MIN_DEVTOOLS_HEIGHT = 180;
|
|
6
|
+
export const MIN_IFRAME_HEIGHT = 220;
|
|
7
|
+
export const MIN_NARROW_IFRAME_HEIGHT = 140;
|
|
8
|
+
export const DETAILS_PANEL_WIDTH = 360;
|
|
9
|
+
export const MIN_DETAILS_WIDTH = 280;
|
|
10
|
+
export const DEFAULT_COMMAND_WIDTH = 320;
|
|
11
|
+
export const MIN_COMMAND_WIDTH = 260;
|
|
12
|
+
export const SPLITTER_SIZE = 6;
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import type {
|
|
3
|
+
DevFlowContext,
|
|
4
|
+
DevFlowMessage,
|
|
5
|
+
DevFlowMessageMatcher,
|
|
6
|
+
} from '../load-config.js';
|
|
7
|
+
import type {
|
|
8
|
+
DevHostFlowEntry,
|
|
9
|
+
DevHostFlowRunState,
|
|
10
|
+
MessageEntry,
|
|
11
|
+
} from './types.js';
|
|
12
|
+
|
|
13
|
+
type FlowRunnerOptions = {
|
|
14
|
+
sendMessage: (type: string, payload: unknown) => void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type ActiveFlowRun = {
|
|
18
|
+
id: string;
|
|
19
|
+
flowName: string;
|
|
20
|
+
flowDisplayName: string;
|
|
21
|
+
autoRun: boolean;
|
|
22
|
+
controller: AbortController;
|
|
23
|
+
listeners: Map<number, (message: DevFlowMessage) => void>;
|
|
24
|
+
cleanups: Set<() => void>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const createAbortError = () => {
|
|
28
|
+
return new DOMException('Flow execution was stopped.', 'AbortError');
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const isAbortError = (error: unknown) => {
|
|
32
|
+
return error instanceof DOMException
|
|
33
|
+
? error.name === 'AbortError'
|
|
34
|
+
: error instanceof Error && error.name === 'AbortError';
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const toFlowMessage = (message: MessageEntry): DevFlowMessage => {
|
|
38
|
+
return {
|
|
39
|
+
id: message.id,
|
|
40
|
+
direction: message.direction,
|
|
41
|
+
date: message.date,
|
|
42
|
+
type: message.type,
|
|
43
|
+
payload: message.payload,
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const matchesMessage = (
|
|
48
|
+
message: DevFlowMessage,
|
|
49
|
+
matcher?: DevFlowMessageMatcher,
|
|
50
|
+
) => {
|
|
51
|
+
if (!matcher) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (typeof matcher === 'string') {
|
|
56
|
+
return message.type === matcher;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (typeof matcher === 'function') {
|
|
60
|
+
return matcher(message);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
(matcher.type == null || message.type === matcher.type) &&
|
|
65
|
+
(matcher.direction == null || message.direction === matcher.direction) &&
|
|
66
|
+
(matcher.predicate == null || matcher.predicate(message))
|
|
67
|
+
);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const formatFlowError = (error: unknown) => {
|
|
71
|
+
if (isAbortError(error)) {
|
|
72
|
+
return 'Flow execution was stopped.';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (error instanceof Error) {
|
|
76
|
+
return error.message;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return String(error);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const useFlowRunner = ({ sendMessage }: FlowRunnerOptions) => {
|
|
83
|
+
const [flowRuns, setFlowRuns] = useState<DevHostFlowRunState[]>([]);
|
|
84
|
+
const messagesRef = useRef<MessageEntry[]>([]);
|
|
85
|
+
const activeRunsRef = useRef<Map<string, ActiveFlowRun>>(new Map());
|
|
86
|
+
const listenerIdRef = useRef(0);
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
return () => {
|
|
90
|
+
activeRunsRef.current.forEach((run) => {
|
|
91
|
+
run.controller.abort();
|
|
92
|
+
run.cleanups.forEach((cleanup) => cleanup());
|
|
93
|
+
});
|
|
94
|
+
activeRunsRef.current.clear();
|
|
95
|
+
};
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
const stopFlow = (runId: string) => {
|
|
99
|
+
const activeRun = activeRunsRef.current.get(runId);
|
|
100
|
+
if (!activeRun) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
activeRun.controller.abort();
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const registerMessage = (message: MessageEntry) => {
|
|
108
|
+
messagesRef.current = [message, ...messagesRef.current];
|
|
109
|
+
|
|
110
|
+
const flowMessage = toFlowMessage(message);
|
|
111
|
+
activeRunsRef.current.forEach((activeRun) => {
|
|
112
|
+
activeRun.listeners.forEach((listener) => {
|
|
113
|
+
listener(flowMessage);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const resetMessages = () => {
|
|
119
|
+
messagesRef.current = [];
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const runFlow = (flow: DevHostFlowEntry, options?: { autoRun?: boolean }) => {
|
|
123
|
+
const duplicateRun = [...activeRunsRef.current.values()].find(
|
|
124
|
+
(run) => run.flowName === flow.name,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
if (duplicateRun) {
|
|
128
|
+
return duplicateRun.id;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const runId = crypto.randomUUID();
|
|
132
|
+
const controller = new AbortController();
|
|
133
|
+
const activeRun: ActiveFlowRun = {
|
|
134
|
+
id: runId,
|
|
135
|
+
flowName: flow.name,
|
|
136
|
+
flowDisplayName: flow.displayName,
|
|
137
|
+
autoRun: options?.autoRun ?? flow.autoRun,
|
|
138
|
+
controller,
|
|
139
|
+
listeners: new Map(),
|
|
140
|
+
cleanups: new Set(),
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const throwIfAborted = () => {
|
|
144
|
+
if (controller.signal.aborted) {
|
|
145
|
+
throw createAbortError();
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const cleanupRun = () => {
|
|
150
|
+
activeRun.cleanups.forEach((cleanup) => cleanup());
|
|
151
|
+
activeRun.cleanups.clear();
|
|
152
|
+
activeRun.listeners.clear();
|
|
153
|
+
activeRunsRef.current.delete(runId);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const onMessage: DevFlowContext['onMessage'] = (matcher, listener) => {
|
|
157
|
+
throwIfAborted();
|
|
158
|
+
|
|
159
|
+
const listenerId = listenerIdRef.current + 1;
|
|
160
|
+
listenerIdRef.current = listenerId;
|
|
161
|
+
|
|
162
|
+
const wrappedListener = (message: DevFlowMessage) => {
|
|
163
|
+
if (matchesMessage(message, matcher)) {
|
|
164
|
+
listener(message);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
activeRun.listeners.set(listenerId, wrappedListener);
|
|
169
|
+
|
|
170
|
+
const remove = () => {
|
|
171
|
+
activeRun.listeners.delete(listenerId);
|
|
172
|
+
controller.signal.removeEventListener('abort', remove);
|
|
173
|
+
activeRun.cleanups.delete(remove);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
controller.signal.addEventListener('abort', remove, { once: true });
|
|
177
|
+
activeRun.cleanups.add(remove);
|
|
178
|
+
|
|
179
|
+
return { remove };
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const flowContext: DevFlowContext = {
|
|
183
|
+
signal: controller.signal,
|
|
184
|
+
send: (type, payload) => {
|
|
185
|
+
throwIfAborted();
|
|
186
|
+
sendMessage(type, payload);
|
|
187
|
+
},
|
|
188
|
+
onMessage,
|
|
189
|
+
waitForMessage: (matcher, options) => {
|
|
190
|
+
throwIfAborted();
|
|
191
|
+
|
|
192
|
+
return new Promise((resolve, reject) => {
|
|
193
|
+
let timeoutId: number | null = null;
|
|
194
|
+
|
|
195
|
+
const cleanup = () => {
|
|
196
|
+
subscription.remove();
|
|
197
|
+
controller.signal.removeEventListener('abort', handleAbort);
|
|
198
|
+
|
|
199
|
+
if (timeoutId !== null) {
|
|
200
|
+
window.clearTimeout(timeoutId);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const handleAbort = () => {
|
|
205
|
+
cleanup();
|
|
206
|
+
reject(createAbortError());
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const subscription = onMessage(matcher, (message) => {
|
|
210
|
+
cleanup();
|
|
211
|
+
resolve(message);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
controller.signal.addEventListener('abort', handleAbort, {
|
|
215
|
+
once: true,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
if (options?.timeoutMs != null) {
|
|
219
|
+
timeoutId = window.setTimeout(() => {
|
|
220
|
+
cleanup();
|
|
221
|
+
reject(
|
|
222
|
+
new Error(
|
|
223
|
+
`Timed out waiting for a matching message after ${options.timeoutMs}ms.`,
|
|
224
|
+
),
|
|
225
|
+
);
|
|
226
|
+
}, options.timeoutMs);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
},
|
|
230
|
+
getMessages: (matcher) => {
|
|
231
|
+
throwIfAborted();
|
|
232
|
+
|
|
233
|
+
return messagesRef.current
|
|
234
|
+
.map(toFlowMessage)
|
|
235
|
+
.filter((message) => matchesMessage(message, matcher));
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
activeRunsRef.current.set(runId, activeRun);
|
|
240
|
+
setFlowRuns((current) => [
|
|
241
|
+
{
|
|
242
|
+
id: runId,
|
|
243
|
+
flowName: flow.name,
|
|
244
|
+
flowDisplayName: flow.displayName,
|
|
245
|
+
status: 'running',
|
|
246
|
+
result: null,
|
|
247
|
+
error: null,
|
|
248
|
+
autoRun: activeRun.autoRun,
|
|
249
|
+
},
|
|
250
|
+
...current,
|
|
251
|
+
]);
|
|
252
|
+
|
|
253
|
+
const updateRunState = (
|
|
254
|
+
nextState: Partial<
|
|
255
|
+
Omit<
|
|
256
|
+
DevHostFlowRunState,
|
|
257
|
+
'id' | 'flowName' | 'flowDisplayName' | 'autoRun'
|
|
258
|
+
>
|
|
259
|
+
>,
|
|
260
|
+
) => {
|
|
261
|
+
setFlowRuns((current) =>
|
|
262
|
+
current.map((run) =>
|
|
263
|
+
run.id === runId
|
|
264
|
+
? {
|
|
265
|
+
...run,
|
|
266
|
+
...nextState,
|
|
267
|
+
}
|
|
268
|
+
: run,
|
|
269
|
+
),
|
|
270
|
+
);
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
Promise.resolve(flow.run(flowContext))
|
|
274
|
+
.then((result) => {
|
|
275
|
+
if (controller.signal.aborted) {
|
|
276
|
+
updateRunState({
|
|
277
|
+
status: 'aborted',
|
|
278
|
+
result: null,
|
|
279
|
+
error: 'Flow execution was stopped.',
|
|
280
|
+
});
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
updateRunState({
|
|
285
|
+
status: 'succeeded',
|
|
286
|
+
result,
|
|
287
|
+
error: null,
|
|
288
|
+
});
|
|
289
|
+
})
|
|
290
|
+
.catch((error) => {
|
|
291
|
+
updateRunState({
|
|
292
|
+
status: isAbortError(error) ? 'aborted' : 'failed',
|
|
293
|
+
result: null,
|
|
294
|
+
error: formatFlowError(error),
|
|
295
|
+
});
|
|
296
|
+
})
|
|
297
|
+
.finally(() => {
|
|
298
|
+
cleanupRun();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
return runId;
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const hasRunningFlow = (flowName: string) => {
|
|
305
|
+
return flowRuns.some(
|
|
306
|
+
(run) => run.flowName === flowName && run.status === 'running',
|
|
307
|
+
);
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
flowRuns,
|
|
312
|
+
runFlow,
|
|
313
|
+
stopFlow,
|
|
314
|
+
hasRunningFlow,
|
|
315
|
+
registerMessage,
|
|
316
|
+
resetMessages,
|
|
317
|
+
};
|
|
318
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Rozenite Dev Host</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="./main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { StrictMode } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import { App } from './App.js';
|
|
4
|
+
import { getDevHostFlows, getDevHostPresets } from './config.js';
|
|
5
|
+
import './styles.css';
|
|
6
|
+
import { readDevHostState } from './utils.js';
|
|
7
|
+
|
|
8
|
+
const rootElement = document.getElementById('root');
|
|
9
|
+
|
|
10
|
+
if (!rootElement) {
|
|
11
|
+
throw new Error('Rozenite dev host failed to initialize.');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const state = readDevHostState();
|
|
15
|
+
const flows = getDevHostFlows();
|
|
16
|
+
const presets = getDevHostPresets();
|
|
17
|
+
|
|
18
|
+
createRoot(rootElement).render(
|
|
19
|
+
<StrictMode>
|
|
20
|
+
<App {...state} flows={flows} presets={presets} />
|
|
21
|
+
</StrictMode>,
|
|
22
|
+
);
|