@walkrstudio/studio 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.
- package/README.md +33 -0
- package/dist/assets/index-DQ40kFnx.js +40 -0
- package/dist/index.html +17 -0
- package/index.html +17 -0
- package/package.json +35 -0
- package/src/App.tsx +499 -0
- package/src/components/PlaybackControls.tsx +104 -0
- package/src/components/StepPanel.tsx +443 -0
- package/src/components/Timeline.tsx +376 -0
- package/src/index.ts +1 -0
- package/src/main.tsx +10 -0
- package/src/types.ts +11 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.json +12 -0
- package/vite.config.ts +161 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import type { Step } from "@walkrstudio/core";
|
|
2
|
+
import type React from "react";
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
|
|
5
|
+
interface TimelineProps {
|
|
6
|
+
steps: Step[];
|
|
7
|
+
selectedStepIndex: number | null;
|
|
8
|
+
currentStepIndex: number;
|
|
9
|
+
playheadTime: number;
|
|
10
|
+
totalDuration: number;
|
|
11
|
+
onSelectStep: (index: number) => void;
|
|
12
|
+
onSeek: (timeMs: number) => void;
|
|
13
|
+
onUpdateDuration: (stepIndex: number, newDuration: number) => void;
|
|
14
|
+
onReorderStep: (fromIndex: number, toIndex: number) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const STEP_COLORS: Record<string, string> = {
|
|
18
|
+
moveTo: "#1d3a5c",
|
|
19
|
+
click: "#3b1d5c",
|
|
20
|
+
type: "#1d5c2a",
|
|
21
|
+
scroll: "#5c3b1d",
|
|
22
|
+
wait: "#333",
|
|
23
|
+
zoom: "#5c1d3b",
|
|
24
|
+
pan: "#1d5c5c",
|
|
25
|
+
highlight: "#5c5c1d",
|
|
26
|
+
clearCache: "#5c2a1d",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const STEP_BORDER_COLORS: Record<string, string> = {
|
|
30
|
+
moveTo: "#2a4f75",
|
|
31
|
+
click: "#4f2a75",
|
|
32
|
+
type: "#2a753f",
|
|
33
|
+
scroll: "#754f2a",
|
|
34
|
+
wait: "#444",
|
|
35
|
+
zoom: "#752a4f",
|
|
36
|
+
pan: "#2a7575",
|
|
37
|
+
highlight: "#75752a",
|
|
38
|
+
clearCache: "#753f2a",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const SCALE = 0.3; // 1ms = 0.3px
|
|
42
|
+
const MIN_BLOCK_WIDTH = 40;
|
|
43
|
+
|
|
44
|
+
function stepWidth(duration: number): number {
|
|
45
|
+
return Math.max(duration * SCALE, MIN_BLOCK_WIDTH);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function Timeline({
|
|
49
|
+
steps,
|
|
50
|
+
selectedStepIndex,
|
|
51
|
+
currentStepIndex,
|
|
52
|
+
playheadTime,
|
|
53
|
+
totalDuration,
|
|
54
|
+
onSelectStep,
|
|
55
|
+
onSeek,
|
|
56
|
+
onUpdateDuration,
|
|
57
|
+
onReorderStep,
|
|
58
|
+
}: TimelineProps) {
|
|
59
|
+
const trackRef = useRef<HTMLDivElement>(null);
|
|
60
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
61
|
+
const [resizing, setResizing] = useState<{
|
|
62
|
+
index: number;
|
|
63
|
+
startX: number;
|
|
64
|
+
startDuration: number;
|
|
65
|
+
} | null>(null);
|
|
66
|
+
const [resizePreview, setResizePreview] = useState<number | null>(null);
|
|
67
|
+
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
|
68
|
+
const [dropTarget, setDropTarget] = useState<number | null>(null);
|
|
69
|
+
const [_draggingPlayhead, setDraggingPlayhead] = useState(false);
|
|
70
|
+
|
|
71
|
+
const totalWidth = steps.reduce((sum, s) => sum + stepWidth(s.duration), 0);
|
|
72
|
+
|
|
73
|
+
const handlePlayheadMouseDown = useCallback(
|
|
74
|
+
(e: React.MouseEvent) => {
|
|
75
|
+
e.stopPropagation();
|
|
76
|
+
setDraggingPlayhead(true);
|
|
77
|
+
|
|
78
|
+
const onMove = (ev: MouseEvent) => {
|
|
79
|
+
if (!trackRef.current) return;
|
|
80
|
+
const rect = trackRef.current.getBoundingClientRect();
|
|
81
|
+
const x = Math.max(0, Math.min(ev.clientX - rect.left, rect.width));
|
|
82
|
+
const ratio = x / totalWidth;
|
|
83
|
+
onSeek(Math.round(ratio * totalDuration));
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const onUp = () => {
|
|
87
|
+
setDraggingPlayhead(false);
|
|
88
|
+
window.removeEventListener("mousemove", onMove);
|
|
89
|
+
window.removeEventListener("mouseup", onUp);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
window.addEventListener("mousemove", onMove);
|
|
93
|
+
window.addEventListener("mouseup", onUp);
|
|
94
|
+
},
|
|
95
|
+
[totalDuration, totalWidth, onSeek],
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const handleResizeStart = useCallback(
|
|
99
|
+
(e: React.MouseEvent, index: number, duration: number) => {
|
|
100
|
+
e.stopPropagation();
|
|
101
|
+
e.preventDefault();
|
|
102
|
+
const startX = e.clientX;
|
|
103
|
+
setResizing({ index, startX, startDuration: duration });
|
|
104
|
+
|
|
105
|
+
const onMove = (ev: MouseEvent) => {
|
|
106
|
+
const dx = ev.clientX - startX;
|
|
107
|
+
const newDuration = Math.max(50, Math.round(duration + dx / SCALE));
|
|
108
|
+
setResizePreview(newDuration);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const onUp = (ev: MouseEvent) => {
|
|
112
|
+
const dx = ev.clientX - startX;
|
|
113
|
+
const newDuration = Math.max(50, Math.round(duration + dx / SCALE));
|
|
114
|
+
onUpdateDuration(index, newDuration);
|
|
115
|
+
setResizing(null);
|
|
116
|
+
setResizePreview(null);
|
|
117
|
+
window.removeEventListener("mousemove", onMove);
|
|
118
|
+
window.removeEventListener("mouseup", onUp);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
window.addEventListener("mousemove", onMove);
|
|
122
|
+
window.addEventListener("mouseup", onUp);
|
|
123
|
+
},
|
|
124
|
+
[onUpdateDuration],
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const handleDragStart = useCallback((e: React.DragEvent, index: number) => {
|
|
128
|
+
e.dataTransfer.effectAllowed = "move";
|
|
129
|
+
e.dataTransfer.setData("text/plain", String(index));
|
|
130
|
+
setDragIndex(index);
|
|
131
|
+
}, []);
|
|
132
|
+
|
|
133
|
+
const handleDragOver = useCallback((e: React.DragEvent, index: number) => {
|
|
134
|
+
e.preventDefault();
|
|
135
|
+
e.dataTransfer.dropEffect = "move";
|
|
136
|
+
setDropTarget(index);
|
|
137
|
+
}, []);
|
|
138
|
+
|
|
139
|
+
const handleDrop = useCallback(
|
|
140
|
+
(e: React.DragEvent, toIndex: number) => {
|
|
141
|
+
e.preventDefault();
|
|
142
|
+
const fromIndex = parseInt(e.dataTransfer.getData("text/plain"), 10);
|
|
143
|
+
if (!Number.isNaN(fromIndex) && fromIndex !== toIndex) {
|
|
144
|
+
onReorderStep(fromIndex, toIndex);
|
|
145
|
+
}
|
|
146
|
+
setDragIndex(null);
|
|
147
|
+
setDropTarget(null);
|
|
148
|
+
},
|
|
149
|
+
[onReorderStep],
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const handleDragEnd = useCallback(() => {
|
|
153
|
+
setDragIndex(null);
|
|
154
|
+
setDropTarget(null);
|
|
155
|
+
}, []);
|
|
156
|
+
|
|
157
|
+
const handleTrackClick = useCallback(
|
|
158
|
+
(e: React.MouseEvent) => {
|
|
159
|
+
if (!trackRef.current) return;
|
|
160
|
+
const rect = trackRef.current.getBoundingClientRect();
|
|
161
|
+
const x = e.clientX - rect.left;
|
|
162
|
+
const ratio = x / totalWidth;
|
|
163
|
+
onSeek(Math.round(ratio * totalDuration));
|
|
164
|
+
},
|
|
165
|
+
[totalDuration, totalWidth, onSeek],
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
// Compute playhead X by walking through steps, accounting for
|
|
169
|
+
// non-linear time-to-pixel mapping (MIN_BLOCK_WIDTH inflates 0ms steps)
|
|
170
|
+
let playheadX = 0;
|
|
171
|
+
if (totalDuration > 0) {
|
|
172
|
+
let timeRemaining = playheadTime;
|
|
173
|
+
for (let i = 0; i < steps.length; i++) {
|
|
174
|
+
const dur = steps[i].duration;
|
|
175
|
+
const w = stepWidth(dur);
|
|
176
|
+
if (timeRemaining <= dur) {
|
|
177
|
+
// Playhead is within this step
|
|
178
|
+
playheadX += dur > 0 ? (timeRemaining / dur) * w : 0;
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
playheadX += w + 4; // 4 = gap between blocks
|
|
182
|
+
timeRemaining -= dur;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Auto-scroll to keep playhead visible
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
const container = scrollRef.current;
|
|
189
|
+
if (!container) return;
|
|
190
|
+
const playheadLeft = 8 + playheadX;
|
|
191
|
+
const { scrollLeft, clientWidth } = container;
|
|
192
|
+
const margin = 60;
|
|
193
|
+
if (playheadLeft > scrollLeft + clientWidth - margin) {
|
|
194
|
+
container.scrollLeft = playheadLeft - clientWidth + margin;
|
|
195
|
+
} else if (playheadLeft < scrollLeft + margin) {
|
|
196
|
+
container.scrollLeft = playheadLeft - margin;
|
|
197
|
+
}
|
|
198
|
+
}, [playheadX]);
|
|
199
|
+
|
|
200
|
+
// Time markers
|
|
201
|
+
let cumulativeMs = 0;
|
|
202
|
+
const _stepOffsets = steps.map((s) => {
|
|
203
|
+
const offset = cumulativeMs;
|
|
204
|
+
cumulativeMs += s.duration;
|
|
205
|
+
return offset;
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
return (
|
|
209
|
+
<div
|
|
210
|
+
style={{
|
|
211
|
+
height: 120,
|
|
212
|
+
background: "#141414",
|
|
213
|
+
borderTop: "1px solid #2a2a2a",
|
|
214
|
+
display: "flex",
|
|
215
|
+
flexShrink: 0,
|
|
216
|
+
}}
|
|
217
|
+
>
|
|
218
|
+
{/* Left column: time markers */}
|
|
219
|
+
<div
|
|
220
|
+
style={{
|
|
221
|
+
width: 60,
|
|
222
|
+
borderRight: "1px solid #2a2a2a",
|
|
223
|
+
display: "flex",
|
|
224
|
+
flexDirection: "column",
|
|
225
|
+
justifyContent: "center",
|
|
226
|
+
alignItems: "center",
|
|
227
|
+
fontSize: 10,
|
|
228
|
+
color: "#555",
|
|
229
|
+
flexShrink: 0,
|
|
230
|
+
}}
|
|
231
|
+
>
|
|
232
|
+
<div>{formatTime(playheadTime)}</div>
|
|
233
|
+
<div style={{ marginTop: 4, color: "#444" }}>{formatTime(totalDuration)}</div>
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
{/* Main track area */}
|
|
237
|
+
<div
|
|
238
|
+
ref={scrollRef}
|
|
239
|
+
style={{
|
|
240
|
+
flex: 1,
|
|
241
|
+
overflowX: "auto",
|
|
242
|
+
overflowY: "hidden",
|
|
243
|
+
position: "relative",
|
|
244
|
+
}}
|
|
245
|
+
>
|
|
246
|
+
<div
|
|
247
|
+
ref={trackRef}
|
|
248
|
+
style={{
|
|
249
|
+
display: "flex",
|
|
250
|
+
alignItems: "center",
|
|
251
|
+
height: "100%",
|
|
252
|
+
padding: "24px 8px",
|
|
253
|
+
gap: 4,
|
|
254
|
+
minWidth: totalWidth + 16,
|
|
255
|
+
position: "relative",
|
|
256
|
+
cursor: "pointer",
|
|
257
|
+
}}
|
|
258
|
+
onClick={handleTrackClick}
|
|
259
|
+
>
|
|
260
|
+
{/* Step blocks */}
|
|
261
|
+
{steps.map((step, i) => {
|
|
262
|
+
const w =
|
|
263
|
+
resizing?.index === i && resizePreview !== null
|
|
264
|
+
? stepWidth(resizePreview)
|
|
265
|
+
: stepWidth(step.duration);
|
|
266
|
+
const isSelected = selectedStepIndex === i;
|
|
267
|
+
const isCurrent = currentStepIndex === i;
|
|
268
|
+
const isDragTarget = dropTarget === i;
|
|
269
|
+
|
|
270
|
+
return (
|
|
271
|
+
<div
|
|
272
|
+
key={step.id}
|
|
273
|
+
draggable
|
|
274
|
+
onDragStart={(e) => handleDragStart(e, i)}
|
|
275
|
+
onDragOver={(e) => handleDragOver(e, i)}
|
|
276
|
+
onDrop={(e) => handleDrop(e, i)}
|
|
277
|
+
onDragEnd={handleDragEnd}
|
|
278
|
+
onClick={(e) => {
|
|
279
|
+
e.stopPropagation();
|
|
280
|
+
onSelectStep(i);
|
|
281
|
+
}}
|
|
282
|
+
style={{
|
|
283
|
+
width: w,
|
|
284
|
+
height: 72,
|
|
285
|
+
background: STEP_COLORS[step.type] ?? "#333",
|
|
286
|
+
border: `1px solid ${STEP_BORDER_COLORS[step.type] ?? "#444"}`,
|
|
287
|
+
borderRadius: 6,
|
|
288
|
+
display: "flex",
|
|
289
|
+
flexDirection: "column",
|
|
290
|
+
justifyContent: "center",
|
|
291
|
+
alignItems: "center",
|
|
292
|
+
cursor: "grab",
|
|
293
|
+
position: "relative",
|
|
294
|
+
flexShrink: 0,
|
|
295
|
+
userSelect: "none",
|
|
296
|
+
boxShadow: isSelected
|
|
297
|
+
? "0 0 0 2px #3b82f6"
|
|
298
|
+
: isCurrent
|
|
299
|
+
? "0 0 0 2px #10b981"
|
|
300
|
+
: "none",
|
|
301
|
+
opacity: dragIndex === i ? 0.5 : 1,
|
|
302
|
+
borderLeft: isDragTarget ? "3px solid #3b82f6" : undefined,
|
|
303
|
+
}}
|
|
304
|
+
>
|
|
305
|
+
<span
|
|
306
|
+
style={{
|
|
307
|
+
fontSize: 11,
|
|
308
|
+
fontWeight: 600,
|
|
309
|
+
textTransform: "uppercase",
|
|
310
|
+
color: "#e8e8e8",
|
|
311
|
+
}}
|
|
312
|
+
>
|
|
313
|
+
{step.type}
|
|
314
|
+
</span>
|
|
315
|
+
<span style={{ fontSize: 10, color: "rgba(255,255,255,0.5)", marginTop: 2 }}>
|
|
316
|
+
{resizing?.index === i && resizePreview !== null
|
|
317
|
+
? `${resizePreview}ms`
|
|
318
|
+
: `${step.duration}ms`}
|
|
319
|
+
</span>
|
|
320
|
+
|
|
321
|
+
{/* Resize handle */}
|
|
322
|
+
<div
|
|
323
|
+
onMouseDown={(e) => handleResizeStart(e, i, step.duration)}
|
|
324
|
+
style={{
|
|
325
|
+
position: "absolute",
|
|
326
|
+
right: 0,
|
|
327
|
+
top: 0,
|
|
328
|
+
bottom: 0,
|
|
329
|
+
width: 4,
|
|
330
|
+
cursor: "col-resize",
|
|
331
|
+
borderRadius: "0 6px 6px 0",
|
|
332
|
+
}}
|
|
333
|
+
/>
|
|
334
|
+
</div>
|
|
335
|
+
);
|
|
336
|
+
})}
|
|
337
|
+
|
|
338
|
+
{/* Playhead */}
|
|
339
|
+
<div
|
|
340
|
+
onMouseDown={handlePlayheadMouseDown}
|
|
341
|
+
style={{
|
|
342
|
+
position: "absolute",
|
|
343
|
+
left: 8 + playheadX,
|
|
344
|
+
top: 0,
|
|
345
|
+
bottom: 0,
|
|
346
|
+
width: 2,
|
|
347
|
+
background: "#ef4444",
|
|
348
|
+
cursor: "ew-resize",
|
|
349
|
+
zIndex: 10,
|
|
350
|
+
pointerEvents: "auto",
|
|
351
|
+
}}
|
|
352
|
+
>
|
|
353
|
+
<div
|
|
354
|
+
style={{
|
|
355
|
+
position: "absolute",
|
|
356
|
+
top: 0,
|
|
357
|
+
left: -4,
|
|
358
|
+
width: 10,
|
|
359
|
+
height: 10,
|
|
360
|
+
background: "#ef4444",
|
|
361
|
+
borderRadius: "50%",
|
|
362
|
+
}}
|
|
363
|
+
/>
|
|
364
|
+
</div>
|
|
365
|
+
</div>
|
|
366
|
+
</div>
|
|
367
|
+
</div>
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function formatTime(ms: number): string {
|
|
372
|
+
const s = ms / 1000;
|
|
373
|
+
const mins = Math.floor(s / 60);
|
|
374
|
+
const secs = (s % 60).toFixed(1);
|
|
375
|
+
return mins > 0 ? `${mins}:${secs.padStart(4, "0")}` : `${secs}s`;
|
|
376
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { App } from "./App";
|
package/src/main.tsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import ReactDOM from "react-dom/client";
|
|
3
|
+
import { App } from "./App";
|
|
4
|
+
|
|
5
|
+
// biome-ignore lint/style/noNonNullAssertion: root element is guaranteed in index.html
|
|
6
|
+
ReactDOM.createRoot(document.getElementById("root")!).render(
|
|
7
|
+
<React.StrictMode>
|
|
8
|
+
<App />
|
|
9
|
+
</React.StrictMode>,
|
|
10
|
+
);
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { CursorConfig, Step } from "@walkrstudio/core";
|
|
2
|
+
|
|
3
|
+
export interface WalkthroughDef {
|
|
4
|
+
url: string;
|
|
5
|
+
originalUrl?: string;
|
|
6
|
+
title?: string;
|
|
7
|
+
steps: Step[];
|
|
8
|
+
cursor?: CursorConfig;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type PlaybackStatus = "idle" | "playing" | "paused" | "done";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
package/tsconfig.json
ADDED
package/vite.config.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { watch as fsWatch, readFileSync } from "node:fs";
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import react from "@vitejs/plugin-react";
|
|
5
|
+
import type { Plugin } from "vite";
|
|
6
|
+
import { defineConfig } from "vite";
|
|
7
|
+
|
|
8
|
+
function walkthroughHmr(): Plugin {
|
|
9
|
+
const jsonPath = resolve(__dirname, "public", "walkthrough.json");
|
|
10
|
+
return {
|
|
11
|
+
name: "walkr-walkthrough-hmr",
|
|
12
|
+
configureServer(server) {
|
|
13
|
+
let last = "";
|
|
14
|
+
const watcher = fsWatch(jsonPath, () => {
|
|
15
|
+
try {
|
|
16
|
+
const content = readFileSync(jsonPath, "utf-8");
|
|
17
|
+
if (content === last) return;
|
|
18
|
+
last = content;
|
|
19
|
+
server.ws.send({
|
|
20
|
+
type: "custom",
|
|
21
|
+
event: "walkthrough:update",
|
|
22
|
+
data: JSON.parse(content),
|
|
23
|
+
});
|
|
24
|
+
} catch {
|
|
25
|
+
// file may be mid-write
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
server.httpServer?.on("close", () => watcher.close());
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let proxyTarget = process.env.WALKR_PROXY_TARGET ?? "";
|
|
34
|
+
if (!proxyTarget) {
|
|
35
|
+
try {
|
|
36
|
+
proxyTarget = readFileSync(resolve(__dirname, ".walkr-proxy-target"), "utf-8").trim();
|
|
37
|
+
} catch {
|
|
38
|
+
// No proxy target file — proxy disabled
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const PREFIX = "/__target__";
|
|
42
|
+
const targetPort = proxyTarget ? new URL(proxyTarget).port || "80" : "";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Rewrite absolute paths in proxied text responses so that sub-resource
|
|
46
|
+
* requests (scripts, styles, fetches) route back through the proxy.
|
|
47
|
+
*
|
|
48
|
+
* Content-type-aware to avoid corrupting JS regex literals like `/"/g`
|
|
49
|
+
* which the old blanket regex would turn into `"/__target__/g`.
|
|
50
|
+
*/
|
|
51
|
+
function rewriteAbsolutePaths(content: string, contentType: string): string {
|
|
52
|
+
// HTML: targeted attribute rewriting + conservative general patterns
|
|
53
|
+
if (contentType.includes("html")) {
|
|
54
|
+
let html = content
|
|
55
|
+
// HTML attributes: src="/x", href="/x", action="/x", etc.
|
|
56
|
+
.replace(
|
|
57
|
+
/((?:src|href|action|poster|formaction|icon|manifest|ping|background)\s*=\s*)(["'])\/(?!\/|__target__)/gi,
|
|
58
|
+
`$1$2${PREFIX}/`,
|
|
59
|
+
)
|
|
60
|
+
// Inline style url()
|
|
61
|
+
.replace(/\burl\(\s*["']?\/(?!\/|__target__)/g, (m) => m.replace("/", `${PREFIX}/`))
|
|
62
|
+
// Quoted deep paths (contain /): "/assets/chunk.js" → "/__target__/assets/chunk.js"
|
|
63
|
+
.replace(/(["'`])\/(?!\/|__target__)([a-zA-Z@._][\w@._-]*\/)/g, `$1${PREFIX}/$2`)
|
|
64
|
+
// Quoted root files with extension: "/favicon.ico" → "/__target__/favicon.ico"
|
|
65
|
+
.replace(/(["'`])\/(?!\/|__target__)([a-zA-Z@._][\w@._-]+\.\w{2,})/g, `$1${PREFIX}/$2`);
|
|
66
|
+
|
|
67
|
+
// Inject <base> so client-side routers (Vue Router, React Router, etc.)
|
|
68
|
+
// resolve routes relative to the proxy prefix instead of "/".
|
|
69
|
+
// Existing <base> tags are already rewritten by the href regex above.
|
|
70
|
+
if (!/<base\b/i.test(html)) {
|
|
71
|
+
html = html.replace(/(<head[^>]*>)/i, `$1<base href="${PREFIX}/">`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return html;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// CSS: url() and @import
|
|
78
|
+
if (contentType.includes("css")) {
|
|
79
|
+
return content
|
|
80
|
+
.replace(/\burl\(\s*["']?\/(?!\/|__target__)/g, (m) => m.replace("/", `${PREFIX}/`))
|
|
81
|
+
.replace(/(@import\s+["'])\/(?!\/|__target__)/g, `$1${PREFIX}/`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// JS/JSON/other text: conservative patterns only.
|
|
85
|
+
// Only match paths with depth ("/segment/...") or file extensions ("/file.ext").
|
|
86
|
+
// This avoids corrupting regex flags like .replace(/"/g, ...) where "/g" would match.
|
|
87
|
+
return content
|
|
88
|
+
.replace(/(["'`])\/(?!\/|__target__)([a-zA-Z@._][\w@._-]*\/)/g, `$1${PREFIX}/$2`)
|
|
89
|
+
.replace(/(["'`])\/(?!\/|__target__)([a-zA-Z@._][\w@._-]+\.\w{2,})/g, `$1${PREFIX}/$2`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isTextResponse(contentType: string): boolean {
|
|
93
|
+
return (
|
|
94
|
+
contentType.includes("text/") ||
|
|
95
|
+
contentType.includes("javascript") ||
|
|
96
|
+
contentType.includes("json") ||
|
|
97
|
+
contentType.includes("css")
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export default defineConfig({
|
|
102
|
+
plugins: [react(), walkthroughHmr()],
|
|
103
|
+
server: {
|
|
104
|
+
port: 3000,
|
|
105
|
+
proxy: proxyTarget
|
|
106
|
+
? {
|
|
107
|
+
[PREFIX]: {
|
|
108
|
+
target: proxyTarget,
|
|
109
|
+
changeOrigin: true,
|
|
110
|
+
rewrite: (path) => path.replace(new RegExp(`^${PREFIX}`), ""),
|
|
111
|
+
// We handle the response ourselves so we can rewrite paths
|
|
112
|
+
selfHandleResponse: true,
|
|
113
|
+
configure: (proxy) => {
|
|
114
|
+
// Ask target not to compress so we can rewrite text
|
|
115
|
+
proxy.on("proxyReq", (proxyReq) => {
|
|
116
|
+
proxyReq.setHeader("accept-encoding", "identity");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
proxy.on("proxyRes", (proxyRes, _req: IncomingMessage, res: ServerResponse) => {
|
|
120
|
+
const contentType = proxyRes.headers["content-type"] ?? "";
|
|
121
|
+
|
|
122
|
+
if (isTextResponse(contentType)) {
|
|
123
|
+
// Buffer text response, rewrite paths, then send
|
|
124
|
+
const chunks: Buffer[] = [];
|
|
125
|
+
proxyRes.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
126
|
+
proxyRes.on("end", () => {
|
|
127
|
+
const raw = Buffer.concat(chunks).toString("utf-8");
|
|
128
|
+
let rewritten = rewriteAbsolutePaths(raw, contentType);
|
|
129
|
+
|
|
130
|
+
// Redirect the target's HMR WebSocket to its own Vite
|
|
131
|
+
// server instead of the Studio's (avoids token mismatch).
|
|
132
|
+
if (_req.url?.includes("/@vite/client")) {
|
|
133
|
+
rewritten = rewritten.replace(
|
|
134
|
+
/hmrPort\s*=\s*null/,
|
|
135
|
+
`hmrPort = ${targetPort}`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
const headers = { ...proxyRes.headers };
|
|
139
|
+
delete headers["content-length"];
|
|
140
|
+
delete headers["content-encoding"];
|
|
141
|
+
// Prevent browser from caching rewritten responses
|
|
142
|
+
headers["cache-control"] = "no-store";
|
|
143
|
+
delete headers.etag;
|
|
144
|
+
res.writeHead(proxyRes.statusCode ?? 200, headers);
|
|
145
|
+
res.end(rewritten);
|
|
146
|
+
});
|
|
147
|
+
} else {
|
|
148
|
+
// Binary responses (images, fonts) pass through unchanged
|
|
149
|
+
const headers = { ...proxyRes.headers };
|
|
150
|
+
headers["cache-control"] = "no-store";
|
|
151
|
+
delete headers.etag;
|
|
152
|
+
res.writeHead(proxyRes.statusCode ?? 200, headers);
|
|
153
|
+
proxyRes.pipe(res);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
: {},
|
|
160
|
+
},
|
|
161
|
+
});
|