@editframe/elements 0.42.5 → 0.42.8
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/LICENSE-FULL.md +148 -0
- package/dist/elements/EFCaptions.d.ts +4 -4
- package/dist/elements/EFImage.d.ts +4 -4
- package/dist/elements/EFMedia.d.ts +2 -2
- package/dist/elements/EFTemporal.js +3 -1
- package/dist/elements/EFTemporal.js.map +1 -1
- package/dist/elements/EFText.d.ts +4 -4
- package/dist/elements/EFText.js +1 -0
- package/dist/elements/EFText.js.map +1 -1
- package/dist/elements/EFTextSegment.d.ts +4 -4
- package/dist/elements/EFTimegroup.js +12 -1
- package/dist/elements/EFTimegroup.js.map +1 -1
- package/dist/elements/EFWaveform.d.ts +4 -4
- package/dist/gui/EFActiveRootTemporal.d.ts +4 -4
- package/dist/gui/EFConfiguration.d.ts +4 -4
- package/dist/gui/EFDial.d.ts +4 -4
- package/dist/gui/EFFilmstrip.d.ts +4 -4
- package/dist/gui/EFOverlayItem.d.ts +4 -4
- package/dist/gui/EFOverlayLayer.d.ts +4 -4
- package/dist/gui/EFPause.d.ts +4 -4
- package/dist/gui/EFPlay.d.ts +4 -4
- package/dist/gui/EFPreview.d.ts +4 -4
- package/dist/gui/EFScrubber.d.ts +4 -4
- package/dist/gui/EFTimeDisplay.d.ts +4 -4
- package/dist/gui/EFTimelineRuler.d.ts +4 -4
- package/dist/gui/EFToggleLoop.d.ts +4 -4
- package/dist/gui/EFTogglePlay.d.ts +4 -4
- package/dist/gui/EFWorkbench.d.ts +4 -4
- package/dist/gui/hierarchy/EFHierarchy.d.ts +4 -4
- package/dist/gui/hierarchy/EFHierarchyItem.d.ts +2 -2
- package/dist/gui/timeline/EFTimeline.d.ts +2 -2
- package/dist/gui/timeline/EFTimeline.js +18 -1
- package/dist/gui/timeline/EFTimeline.js.map +1 -1
- package/dist/gui/timeline/TrimHandles.d.ts +4 -4
- package/dist/gui/timeline/tracks/EFThumbnailStrip.d.ts +4 -4
- package/dist/gui/tree/EFTree.d.ts +4 -4
- package/dist/gui/tree/EFTreeItem.d.ts +4 -4
- package/dist/preview/renderTimegroupToCanvas.js +9 -0
- package/dist/preview/renderTimegroupToCanvas.js.map +1 -1
- package/dist/preview/renderTimegroupToVideo.js +1 -1
- package/dist/preview/renderTimegroupToVideo.js.map +1 -1
- package/dist/preview/rendering/serializeTimelineDirect.js +2 -1
- package/dist/preview/rendering/serializeTimelineDirect.js.map +1 -1
- package/package.json +8 -3
- package/scripts/build-css.js +0 -44
- package/test/__cache__/GET__api_v1_transcode_audio_1_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__32da3954ba60c96ad732020c65a08ebc/data.bin +0 -0
- package/test/__cache__/GET__api_v1_transcode_audio_1_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__32da3954ba60c96ad732020c65a08ebc/metadata.json +0 -16
- package/test/__cache__/GET__api_v1_transcode_audio_2_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__b0b2b07efcf607de8ee0f650328c32f7/data.bin +0 -0
- package/test/__cache__/GET__api_v1_transcode_audio_2_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__b0b2b07efcf607de8ee0f650328c32f7/metadata.json +0 -16
- package/test/__cache__/GET__api_v1_transcode_audio_3_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a75c2252b542e0c152c780e9a8d7b154/data.bin +0 -0
- package/test/__cache__/GET__api_v1_transcode_audio_3_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a75c2252b542e0c152c780e9a8d7b154/metadata.json +0 -16
- package/test/__cache__/GET__api_v1_transcode_audio_4_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a64ff1cfb1b52cae14df4b5dfa1e222b/data.bin +0 -0
- package/test/__cache__/GET__api_v1_transcode_audio_4_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a64ff1cfb1b52cae14df4b5dfa1e222b/metadata.json +0 -16
- package/test/__cache__/GET__api_v1_transcode_audio_5_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__91e8a522f950809b9f09f4173113b4b0/data.bin +0 -0
- package/test/__cache__/GET__api_v1_transcode_audio_5_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__91e8a522f950809b9f09f4173113b4b0/metadata.json +0 -16
- package/test/__cache__/GET__api_v1_transcode_audio_init_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__e66d2c831d951e74ad0aeaa6489795d0/data.bin +0 -0
- package/test/__cache__/GET__api_v1_transcode_audio_init_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__e66d2c831d951e74ad0aeaa6489795d0/metadata.json +0 -16
- package/test/__cache__/GET__api_v1_transcode_high_1_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__26197f6f7c46cacb0a71134131c3f775/data.bin +0 -0
- package/test/__cache__/GET__api_v1_transcode_high_1_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__26197f6f7c46cacb0a71134131c3f775/metadata.json +0 -16
- package/test/__cache__/GET__api_v1_transcode_high_2_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__4cb6774cd3650ccf59c8f8dc6678c0b9/data.bin +0 -0
- package/test/__cache__/GET__api_v1_transcode_high_2_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__4cb6774cd3650ccf59c8f8dc6678c0b9/metadata.json +0 -16
- package/test/__cache__/GET__api_v1_transcode_high_3_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__0b3b2b1c8933f7fcf8a9ecaa88d58b41/data.bin +0 -0
- package/test/__cache__/GET__api_v1_transcode_high_3_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__0b3b2b1c8933f7fcf8a9ecaa88d58b41/metadata.json +0 -16
- package/test/__cache__/GET__api_v1_transcode_high_4_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a6fb05a22b18d850f7f2950bbcdbdeed/data.bin +0 -0
- package/test/__cache__/GET__api_v1_transcode_high_4_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a6fb05a22b18d850f7f2950bbcdbdeed/metadata.json +0 -16
- package/test/__cache__/GET__api_v1_transcode_high_5_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a50058c7c3602e90879fe3428ed891f4/data.bin +0 -0
- package/test/__cache__/GET__api_v1_transcode_high_5_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a50058c7c3602e90879fe3428ed891f4/metadata.json +0 -16
- package/test/__cache__/GET__api_v1_transcode_high_init_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__0798c479b44aaeef850609a430f6e613/data.bin +0 -0
- package/test/__cache__/GET__api_v1_transcode_high_init_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__0798c479b44aaeef850609a430f6e613/metadata.json +0 -16
- package/test/__cache__/GET__api_v1_transcode_manifest_json_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__3be92a0437de726b431ed5af2369158a/data.bin +0 -1
- package/test/__cache__/GET__api_v1_transcode_manifest_json_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__3be92a0437de726b431ed5af2369158a/metadata.json +0 -17
- package/test/__cache__/GET__api_v1_transcode_scrub_1_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__6ff5127ebeda578a679474347fbd6137/data.bin +0 -0
- package/test/__cache__/GET__api_v1_transcode_scrub_1_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__6ff5127ebeda578a679474347fbd6137/metadata.json +0 -16
- package/test/__cache__/GET__api_v1_transcode_scrub_init_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__f6d4793fc9ff854ee9a738917fb64a53/data.bin +0 -0
- package/test/__cache__/GET__api_v1_transcode_scrub_init_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__f6d4793fc9ff854ee9a738917fb64a53/metadata.json +0 -16
- package/test/cache-integration-verification.browsertest.ts +0 -84
- package/test/constants.ts +0 -8
- package/test/createJitTestClips.ts +0 -425
- package/test/profilingPlugin.ts +0 -221
- package/test/recordReplayProxyPlugin.js +0 -428
- package/test/setup.ts +0 -71
- package/test/useAssetMSW.ts +0 -53
- package/test/useMSW.ts +0 -40
- package/test/useTranscodeMSW.ts +0 -191
- package/test/visualRegressionUtils.ts +0 -300
- package/tsdown.config.ts +0 -65
package/test/profilingPlugin.ts
DELETED
|
@@ -1,221 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Vitest Browser Profiling Plugin
|
|
3
|
-
*
|
|
4
|
-
* Captures Chrome DevTools CPU profiles during browser tests.
|
|
5
|
-
* Enable with VITEST_PROFILE=1 environment variable.
|
|
6
|
-
*
|
|
7
|
-
* Usage:
|
|
8
|
-
* VITEST_PROFILE=1 ./scripts/browsertest <test-file>
|
|
9
|
-
* ./scripts/browsertest --profile <test-file>
|
|
10
|
-
*
|
|
11
|
-
* Output:
|
|
12
|
-
* Creates ./browsertest-profile.cpuprofile in the elements directory
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import type { Plugin } from "vite";
|
|
16
|
-
import * as fs from "node:fs";
|
|
17
|
-
import * as path from "node:path";
|
|
18
|
-
|
|
19
|
-
interface ProfileNode {
|
|
20
|
-
id: number;
|
|
21
|
-
callFrame: {
|
|
22
|
-
functionName: string;
|
|
23
|
-
scriptId: string;
|
|
24
|
-
url: string;
|
|
25
|
-
lineNumber: number;
|
|
26
|
-
columnNumber: number;
|
|
27
|
-
};
|
|
28
|
-
hitCount?: number;
|
|
29
|
-
children?: number[];
|
|
30
|
-
positionTicks?: { line: number; ticks: number }[];
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
interface CPUProfile {
|
|
34
|
-
nodes: ProfileNode[];
|
|
35
|
-
startTime: number;
|
|
36
|
-
endTime: number;
|
|
37
|
-
samples: number[];
|
|
38
|
-
timeDeltas: number[];
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Global state for CDP session and profile
|
|
42
|
-
let cdpSession: any = null;
|
|
43
|
-
let profilingStartTime: number = 0;
|
|
44
|
-
|
|
45
|
-
export function profilingPlugin(): Plugin {
|
|
46
|
-
const isProfilingEnabled = process.env.VITEST_PROFILE === "1";
|
|
47
|
-
|
|
48
|
-
if (!isProfilingEnabled) {
|
|
49
|
-
return { name: "vitest-profiling-disabled" };
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
console.log("\n🔬 CPU Profiling enabled for browser tests\n");
|
|
53
|
-
|
|
54
|
-
return {
|
|
55
|
-
name: "vitest-browser-profiling",
|
|
56
|
-
|
|
57
|
-
// Hook into server configuration to get access to the browser
|
|
58
|
-
configureServer(server) {
|
|
59
|
-
// We need to hook into the Vitest browser lifecycle
|
|
60
|
-
// This is tricky because Vitest manages the browser connection
|
|
61
|
-
|
|
62
|
-
// Add an endpoint that tests can call to start/stop profiling
|
|
63
|
-
server.middlewares.use("/__vitest_profile__/start", async (_req, res) => {
|
|
64
|
-
try {
|
|
65
|
-
// Get the CDP session from the connected browser
|
|
66
|
-
// This requires access to the Playwright page, which Vitest manages
|
|
67
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
68
|
-
res.end(JSON.stringify({ status: "profiling_start_requested" }));
|
|
69
|
-
} catch (error) {
|
|
70
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
71
|
-
res.end(JSON.stringify({ error: String(error) }));
|
|
72
|
-
}
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
server.middlewares.use("/__vitest_profile__/stop", async (_req, res) => {
|
|
76
|
-
try {
|
|
77
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
78
|
-
res.end(JSON.stringify({ status: "profiling_stop_requested" }));
|
|
79
|
-
} catch (error) {
|
|
80
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
81
|
-
res.end(JSON.stringify({ error: String(error) }));
|
|
82
|
-
}
|
|
83
|
-
});
|
|
84
|
-
},
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Start CDP profiling on a Playwright page
|
|
90
|
-
* Call this from test setup or beforeAll
|
|
91
|
-
*/
|
|
92
|
-
export async function startProfiling(page: any): Promise<void> {
|
|
93
|
-
try {
|
|
94
|
-
// Get CDP session from Playwright page
|
|
95
|
-
// In Playwright, we access CDP via page.context().newCDPSession(page)
|
|
96
|
-
const context = page.context();
|
|
97
|
-
cdpSession = await context.newCDPSession(page);
|
|
98
|
-
|
|
99
|
-
await cdpSession.send("Profiler.enable");
|
|
100
|
-
await cdpSession.send("Profiler.setSamplingInterval", { interval: 100 }); // 100µs
|
|
101
|
-
await cdpSession.send("Profiler.start");
|
|
102
|
-
|
|
103
|
-
profilingStartTime = Date.now();
|
|
104
|
-
console.log("🎬 CPU profiling started");
|
|
105
|
-
} catch (error) {
|
|
106
|
-
console.error("Failed to start profiling:", error);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Stop CDP profiling and save the profile
|
|
112
|
-
* Call this from test teardown or afterAll
|
|
113
|
-
*/
|
|
114
|
-
export async function stopProfiling(
|
|
115
|
-
outputPath?: string,
|
|
116
|
-
): Promise<CPUProfile | null> {
|
|
117
|
-
if (!cdpSession) return null;
|
|
118
|
-
|
|
119
|
-
try {
|
|
120
|
-
const { profile } = (await cdpSession.send("Profiler.stop")) as {
|
|
121
|
-
profile: CPUProfile;
|
|
122
|
-
};
|
|
123
|
-
await cdpSession.send("Profiler.disable");
|
|
124
|
-
|
|
125
|
-
const duration = Date.now() - profilingStartTime;
|
|
126
|
-
console.log(`⏱️ CPU profiling stopped after ${duration}ms`);
|
|
127
|
-
|
|
128
|
-
// Save profile
|
|
129
|
-
const finalPath = outputPath || "./browsertest-profile.cpuprofile";
|
|
130
|
-
const profileJson = JSON.stringify(profile, null, 2);
|
|
131
|
-
fs.writeFileSync(finalPath, profileJson);
|
|
132
|
-
console.log(`💾 Profile saved to: ${finalPath}`);
|
|
133
|
-
console.log(` Load in Chrome DevTools → Performance → Load profile`);
|
|
134
|
-
|
|
135
|
-
// Print summary
|
|
136
|
-
printProfileSummary(profile, duration);
|
|
137
|
-
|
|
138
|
-
cdpSession = null;
|
|
139
|
-
return profile;
|
|
140
|
-
} catch (error) {
|
|
141
|
-
console.error("Failed to stop profiling:", error);
|
|
142
|
-
return null;
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function printProfileSummary(profile: CPUProfile, wallClockMs: number): void {
|
|
147
|
-
const hitCounts = new Map<number, number>();
|
|
148
|
-
for (const sample of profile.samples) {
|
|
149
|
-
hitCounts.set(sample, (hitCounts.get(sample) || 0) + 1);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const sampleIntervalUs =
|
|
153
|
-
profile.timeDeltas.length > 0
|
|
154
|
-
? profile.timeDeltas.reduce((a, b) => a + b, 0) /
|
|
155
|
-
profile.timeDeltas.length
|
|
156
|
-
: 1000;
|
|
157
|
-
|
|
158
|
-
const totalSamples = profile.samples.length;
|
|
159
|
-
const profileTimeMs = (totalSamples * sampleIntervalUs) / 1000;
|
|
160
|
-
|
|
161
|
-
// Build hotspots
|
|
162
|
-
const hotspots: { name: string; file: string; timeMs: number }[] = [];
|
|
163
|
-
for (const node of profile.nodes) {
|
|
164
|
-
const hitCount = hitCounts.get(node.id) || 0;
|
|
165
|
-
if (hitCount === 0) continue;
|
|
166
|
-
|
|
167
|
-
const selfTimeMs = (hitCount * sampleIntervalUs) / 1000;
|
|
168
|
-
const file =
|
|
169
|
-
node.callFrame.url?.split("/").slice(-1)[0]?.split("?")[0] || "(native)";
|
|
170
|
-
|
|
171
|
-
hotspots.push({
|
|
172
|
-
name: node.callFrame.functionName || "(anonymous)",
|
|
173
|
-
file,
|
|
174
|
-
timeMs: selfTimeMs,
|
|
175
|
-
});
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
hotspots.sort((a, b) => b.timeMs - a.timeMs);
|
|
179
|
-
|
|
180
|
-
// Group by file
|
|
181
|
-
const byFile = new Map<string, number>();
|
|
182
|
-
for (const h of hotspots) {
|
|
183
|
-
byFile.set(h.file, (byFile.get(h.file) || 0) + h.timeMs);
|
|
184
|
-
}
|
|
185
|
-
const sortedFiles = Array.from(byFile.entries()).sort((a, b) => b[1] - a[1]);
|
|
186
|
-
|
|
187
|
-
console.log(
|
|
188
|
-
`\n📊 Profile Summary (${wallClockMs}ms wall clock, ${profileTimeMs.toFixed(1)}ms profile time)`,
|
|
189
|
-
);
|
|
190
|
-
console.log(`\n Top files:`);
|
|
191
|
-
for (const [file, time] of sortedFiles.slice(0, 10)) {
|
|
192
|
-
const pct = ((time / profileTimeMs) * 100).toFixed(1);
|
|
193
|
-
console.log(
|
|
194
|
-
` ${time.toFixed(1).padStart(8)}ms (${pct.padStart(5)}%) ${file}`,
|
|
195
|
-
);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// Our code
|
|
199
|
-
const ourCode = hotspots.filter(
|
|
200
|
-
(h) =>
|
|
201
|
-
h.file.includes(".ts") &&
|
|
202
|
-
!h.file.includes("node_modules") &&
|
|
203
|
-
(h.file.includes("render") ||
|
|
204
|
-
h.file.includes("preview") ||
|
|
205
|
-
h.file.includes("element") ||
|
|
206
|
-
h.file.includes("Timegroup") ||
|
|
207
|
-
h.file.includes("clone") ||
|
|
208
|
-
h.file.includes("sync")),
|
|
209
|
-
);
|
|
210
|
-
|
|
211
|
-
if (ourCode.length > 0) {
|
|
212
|
-
console.log(`\n Top functions in our code:`);
|
|
213
|
-
for (const h of ourCode.slice(0, 10)) {
|
|
214
|
-
console.log(
|
|
215
|
-
` ${h.timeMs.toFixed(1).padStart(8)}ms ${h.name.slice(0, 40).padEnd(40)} ${h.file}`,
|
|
216
|
-
);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
console.log();
|
|
221
|
-
}
|
|
@@ -1,428 +0,0 @@
|
|
|
1
|
-
import crypto from "node:crypto";
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
3
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
-
import { dirname, join } from "node:path";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
6
|
-
import debug from "debug";
|
|
7
|
-
import { TEST_SERVER_PORT } from "./constants.js";
|
|
8
|
-
|
|
9
|
-
const log = debug("ef:recordReplayProxyPlugin");
|
|
10
|
-
|
|
11
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
|
-
const CACHE_DIR = join(__dirname, "__cache__");
|
|
13
|
-
const TARGET_HOST = "host.docker.internal";
|
|
14
|
-
const TARGET_PORT = 3000;
|
|
15
|
-
// Get worktree domain from environment (set by worktree-config)
|
|
16
|
-
const WORKTREE_DOMAIN = process.env.WORKTREE_DOMAIN || "main.localhost";
|
|
17
|
-
|
|
18
|
-
// Detect CI environment - check multiple indicators
|
|
19
|
-
// GITHUB_ACTIONS is set by GitHub Actions, CI is a common CI indicator
|
|
20
|
-
// Also check if we're running in ci-runner service (no Traefik)
|
|
21
|
-
const isCI =
|
|
22
|
-
Boolean(process.env.GITHUB_ACTIONS) ||
|
|
23
|
-
Boolean(process.env.CI) ||
|
|
24
|
-
process.env.DOCKER_SERVICE === "ci-runner";
|
|
25
|
-
|
|
26
|
-
// Check if we should run in cache-only mode (for CI/prepare-release)
|
|
27
|
-
const CACHE_ONLY_MODE = process.env.EF_CACHE_ONLY === "true";
|
|
28
|
-
|
|
29
|
-
// Determine the proxy host to use for URL rewriting
|
|
30
|
-
// In CI, use localhost:TEST_SERVER_PORT (no Traefik)
|
|
31
|
-
// In local dev, use WORKTREE_DOMAIN:4322 (Traefik routing)
|
|
32
|
-
function getProxyHost() {
|
|
33
|
-
if (isCI) {
|
|
34
|
-
return `http://localhost:${TEST_SERVER_PORT}`;
|
|
35
|
-
}
|
|
36
|
-
return `http://${WORKTREE_DOMAIN}:4322`;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Vite plugin that adds record-and-replay proxy middleware
|
|
41
|
-
* This proxy intercepts requests to /api/v1/transcode/*, caches responses to disk,
|
|
42
|
-
* and serves cached responses when the real server is unavailable.
|
|
43
|
-
*/
|
|
44
|
-
export function recordReplayProxyPlugin() {
|
|
45
|
-
return {
|
|
46
|
-
name: "record-replay-proxy",
|
|
47
|
-
|
|
48
|
-
configureServer(server) {
|
|
49
|
-
log(
|
|
50
|
-
`[Proxy Plugin] Configuring record-replay proxy middleware... ${CACHE_ONLY_MODE ? "(CACHE-ONLY MODE)" : ""}`,
|
|
51
|
-
);
|
|
52
|
-
|
|
53
|
-
// Initialize cache directory
|
|
54
|
-
mkdir(CACHE_DIR, { recursive: true }).catch(console.error);
|
|
55
|
-
|
|
56
|
-
// Add middleware to handle /api/v1/transcode/* requests
|
|
57
|
-
server.middlewares.use("/api/v1/transcode", async (req, res, next) => {
|
|
58
|
-
await handleProxyRequest(req, res, next);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
// Add middleware to handle /api/v1/url-token requests (for URL signing)
|
|
62
|
-
server.middlewares.use("/api/v1/url-token", async (req, res, next) => {
|
|
63
|
-
await handleProxyRequest(req, res, next);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
log("[Proxy Plugin] Proxy middleware configured");
|
|
67
|
-
log(`[Proxy Plugin] Cache directory: ${CACHE_DIR}`);
|
|
68
|
-
if (CACHE_ONLY_MODE) {
|
|
69
|
-
log(
|
|
70
|
-
"[Proxy Plugin] ⚠️ Running in CACHE-ONLY mode - no remote fetching",
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
},
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
// Create cache key from request
|
|
77
|
-
function getCacheKey(method, url, headers) {
|
|
78
|
-
const range = headers.range || "";
|
|
79
|
-
const key = `${method}_${url}_${range}`;
|
|
80
|
-
const hash = crypto.createHash("md5").update(key).digest("hex");
|
|
81
|
-
const sanitized = key.replace(/[^a-zA-Z0-9]/g, "_").substring(0, 100);
|
|
82
|
-
return `${sanitized}_${hash}`; // Returns directory name
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Serve cached response
|
|
86
|
-
async function serveCachedResponse(res, cacheDir, _req) {
|
|
87
|
-
try {
|
|
88
|
-
const metadataFile = join(cacheDir, "metadata.json");
|
|
89
|
-
const dataFile = join(cacheDir, "data.bin");
|
|
90
|
-
|
|
91
|
-
const metadata = JSON.parse(await readFile(metadataFile, "utf-8"));
|
|
92
|
-
let body = await readFile(dataFile);
|
|
93
|
-
|
|
94
|
-
// Rewrite URLs in cached JSON/text responses to point back to current proxy
|
|
95
|
-
const responseHeaders = { ...metadata.headers };
|
|
96
|
-
const contentType = responseHeaders["content-type"] || "";
|
|
97
|
-
if (
|
|
98
|
-
contentType.includes("application/json") ||
|
|
99
|
-
contentType.includes("text/")
|
|
100
|
-
) {
|
|
101
|
-
try {
|
|
102
|
-
const originalHost = `http://${TARGET_HOST}:${TARGET_PORT}`;
|
|
103
|
-
// Determine the correct proxy host to use
|
|
104
|
-
// In CI, use localhost:TEST_SERVER_PORT; in local dev, use Traefik URL
|
|
105
|
-
const proxyHost = getProxyHost();
|
|
106
|
-
const bodyText = body.toString("utf-8");
|
|
107
|
-
// Replace both the original host and localhost:63315 with the proxy host
|
|
108
|
-
let rewrittenText = bodyText.replace(
|
|
109
|
-
new RegExp(
|
|
110
|
-
originalHost.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
|
|
111
|
-
"g",
|
|
112
|
-
),
|
|
113
|
-
proxyHost,
|
|
114
|
-
);
|
|
115
|
-
// Always replace localhost:63315 (cached responses may contain it)
|
|
116
|
-
// Note: Using hardcoded port here for regex matching cached responses
|
|
117
|
-
rewrittenText = rewrittenText.replace(
|
|
118
|
-
/http:\/\/localhost:63315/g,
|
|
119
|
-
proxyHost,
|
|
120
|
-
);
|
|
121
|
-
body = Buffer.from(rewrittenText, "utf-8");
|
|
122
|
-
|
|
123
|
-
// Update content-length if it changed
|
|
124
|
-
if (bodyText.length !== rewrittenText.length) {
|
|
125
|
-
responseHeaders["content-length"] = body.length.toString();
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
log(`[Proxy] ✓ Rewrote cached URLs: ${originalHost} → ${proxyHost}`);
|
|
129
|
-
} catch (error) {
|
|
130
|
-
console.warn(
|
|
131
|
-
`[Proxy] Failed to rewrite cached URLs: ${error.message}`,
|
|
132
|
-
);
|
|
133
|
-
// Continue with original body on error
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
res.writeHead(metadata.statusCode, responseHeaders);
|
|
138
|
-
res.end(body);
|
|
139
|
-
} catch (error) {
|
|
140
|
-
console.error(
|
|
141
|
-
`[Proxy] Failed to serve cached response: ${error.message}`,
|
|
142
|
-
);
|
|
143
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
144
|
-
res.end(JSON.stringify({ error: "Failed to read cache" }));
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Normalize metadata by removing irrelevant dynamic fields
|
|
149
|
-
function normalizeMetadata(metadata) {
|
|
150
|
-
const normalized = { ...metadata };
|
|
151
|
-
|
|
152
|
-
// Remove dynamic headers that change on every request but don't affect functionality
|
|
153
|
-
if (normalized.headers) {
|
|
154
|
-
const headers = { ...normalized.headers };
|
|
155
|
-
delete headers.date;
|
|
156
|
-
delete headers["x-total-server-time-ms"];
|
|
157
|
-
delete headers["x-transcode-time-ms"]; // This varies between requests
|
|
158
|
-
delete headers["x-cache"]; // This can vary between HIT/MISS
|
|
159
|
-
normalized.headers = headers;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// Remove timestamp field since it always changes
|
|
163
|
-
delete normalized.timestamp;
|
|
164
|
-
|
|
165
|
-
return normalized;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Save response to cache
|
|
169
|
-
async function cacheResponse(
|
|
170
|
-
cacheDir,
|
|
171
|
-
statusCode,
|
|
172
|
-
headers,
|
|
173
|
-
body,
|
|
174
|
-
method,
|
|
175
|
-
url,
|
|
176
|
-
range,
|
|
177
|
-
) {
|
|
178
|
-
try {
|
|
179
|
-
await mkdir(cacheDir, { recursive: true }); // Create cache directory
|
|
180
|
-
|
|
181
|
-
const metadata = {
|
|
182
|
-
statusCode,
|
|
183
|
-
headers,
|
|
184
|
-
url,
|
|
185
|
-
method,
|
|
186
|
-
range: range || null,
|
|
187
|
-
timestamp: new Date().toISOString(),
|
|
188
|
-
};
|
|
189
|
-
|
|
190
|
-
// Always write the response to cache - binary content can change even if headers don't
|
|
191
|
-
// Write normalized metadata to disk (without dynamic fields)
|
|
192
|
-
const normalizedMetadata = normalizeMetadata(metadata);
|
|
193
|
-
|
|
194
|
-
const metadataFile = join(cacheDir, "metadata.json");
|
|
195
|
-
await writeFile(
|
|
196
|
-
metadataFile,
|
|
197
|
-
JSON.stringify(normalizedMetadata, null, 2),
|
|
198
|
-
);
|
|
199
|
-
|
|
200
|
-
const dataFile = join(cacheDir, "data.bin");
|
|
201
|
-
await writeFile(dataFile, body); // Write raw binary data
|
|
202
|
-
|
|
203
|
-
log("[Proxy] ✓ Cached response");
|
|
204
|
-
} catch (error) {
|
|
205
|
-
console.warn(`[Proxy] Failed to cache: ${error.message}`);
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// Handle proxy request as middleware
|
|
210
|
-
async function handleProxyRequest(req, res, next) {
|
|
211
|
-
// Determine the API path prefix based on the request URL
|
|
212
|
-
// req.url will be like "/transcode/manifest.json" or "/url-token" or "" (empty) or "/" (root for exact match)
|
|
213
|
-
let apiPath;
|
|
214
|
-
if (req.url.startsWith("/transcode")) {
|
|
215
|
-
apiPath = `/api/v1/transcode${req.url}`;
|
|
216
|
-
} else if (
|
|
217
|
-
req.url.startsWith("/url-token") ||
|
|
218
|
-
req.url === "" ||
|
|
219
|
-
req.url === "/"
|
|
220
|
-
) {
|
|
221
|
-
// Handle empty string or root path for exact /api/v1/url-token match
|
|
222
|
-
// When middleware is registered with "/api/v1/url-token", exact match gives req.url = "" or "/"
|
|
223
|
-
apiPath = `/api/v1/url-token${req.url.replace("/url-token", "")}`;
|
|
224
|
-
} else {
|
|
225
|
-
// Fallback: assume transcode if path doesn't match
|
|
226
|
-
apiPath = `/api/v1/transcode${req.url}`;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
const fullPath = apiPath;
|
|
230
|
-
log(`[Proxy] → ${req.method} ${fullPath}`);
|
|
231
|
-
if (req.headers.range) {
|
|
232
|
-
log(`[Proxy] Range: ${req.headers.range}`);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// Set CORS headers
|
|
236
|
-
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
237
|
-
res.setHeader(
|
|
238
|
-
"Access-Control-Allow-Methods",
|
|
239
|
-
"GET, POST, PUT, DELETE, OPTIONS",
|
|
240
|
-
);
|
|
241
|
-
res.setHeader(
|
|
242
|
-
"Access-Control-Allow-Headers",
|
|
243
|
-
"Content-Type, Range, Authorization",
|
|
244
|
-
);
|
|
245
|
-
|
|
246
|
-
if (req.method === "OPTIONS") {
|
|
247
|
-
res.writeHead(200);
|
|
248
|
-
res.end();
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
const cacheKey = getCacheKey(req.method, fullPath, req.headers);
|
|
253
|
-
const cacheDir = join(CACHE_DIR, cacheKey);
|
|
254
|
-
|
|
255
|
-
// In cache-only mode, try to serve from cache first
|
|
256
|
-
if (CACHE_ONLY_MODE) {
|
|
257
|
-
if (existsSync(cacheDir)) {
|
|
258
|
-
try {
|
|
259
|
-
const metadataFile = join(cacheDir, "metadata.json");
|
|
260
|
-
if (existsSync(metadataFile)) {
|
|
261
|
-
log(`[Proxy] ✓ CACHE-ONLY: Serving from cache: ${cacheKey}`);
|
|
262
|
-
await serveCachedResponse(res, cacheDir, req);
|
|
263
|
-
return;
|
|
264
|
-
}
|
|
265
|
-
} catch (cacheError) {
|
|
266
|
-
console.error(`[Proxy] Failed to read cache: ${cacheError.message}`);
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
log(`[Proxy] ✗ CACHE-ONLY: No cache available for ${cacheKey}`);
|
|
271
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
272
|
-
res.end(
|
|
273
|
-
JSON.stringify({
|
|
274
|
-
error: "Cache-only mode enabled but no cache found",
|
|
275
|
-
cacheKey,
|
|
276
|
-
suggestion: "Run tests locally first to populate cache",
|
|
277
|
-
}),
|
|
278
|
-
);
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
try {
|
|
283
|
-
// Collect request body
|
|
284
|
-
const requestChunks = [];
|
|
285
|
-
req.on("data", (chunk) => {
|
|
286
|
-
requestChunks.push(chunk);
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
req.on("end", async () => {
|
|
290
|
-
try {
|
|
291
|
-
const requestBody = Buffer.concat(requestChunks);
|
|
292
|
-
// Use the full path we determined earlier
|
|
293
|
-
const targetUrl = `http://${TARGET_HOST}:${TARGET_PORT}${fullPath}`;
|
|
294
|
-
|
|
295
|
-
const fetchOptions = {
|
|
296
|
-
method: req.method,
|
|
297
|
-
headers: req.headers,
|
|
298
|
-
body: requestBody.length > 0 ? requestBody : undefined,
|
|
299
|
-
};
|
|
300
|
-
|
|
301
|
-
const response = await fetch(targetUrl, fetchOptions);
|
|
302
|
-
let body = Buffer.from(await response.arrayBuffer());
|
|
303
|
-
|
|
304
|
-
const responseHeaders = {};
|
|
305
|
-
response.headers.forEach((value, key) => {
|
|
306
|
-
responseHeaders[key] = value;
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
// Rewrite URLs in JSON/text responses to point back to proxy
|
|
310
|
-
const contentType = responseHeaders["content-type"] || "";
|
|
311
|
-
if (
|
|
312
|
-
contentType.includes("application/json") ||
|
|
313
|
-
contentType.includes("text/")
|
|
314
|
-
) {
|
|
315
|
-
try {
|
|
316
|
-
const originalHost = `http://${TARGET_HOST}:${TARGET_PORT}`;
|
|
317
|
-
// Determine the correct proxy host to use
|
|
318
|
-
// In CI, use localhost:TEST_SERVER_PORT; in local dev, use Traefik URL
|
|
319
|
-
const proxyHost = getProxyHost();
|
|
320
|
-
const bodyText = body.toString("utf-8");
|
|
321
|
-
// Replace both the original host and localhost:63315 with the proxy host
|
|
322
|
-
let rewrittenText = bodyText.replace(
|
|
323
|
-
new RegExp(
|
|
324
|
-
originalHost.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
|
|
325
|
-
"g",
|
|
326
|
-
),
|
|
327
|
-
proxyHost,
|
|
328
|
-
);
|
|
329
|
-
// Always replace localhost:63315 (responses may contain it from previous rewrites or cache)
|
|
330
|
-
rewrittenText = rewrittenText.replace(
|
|
331
|
-
/http:\/\/localhost:63315/g,
|
|
332
|
-
proxyHost,
|
|
333
|
-
);
|
|
334
|
-
body = Buffer.from(rewrittenText, "utf-8");
|
|
335
|
-
|
|
336
|
-
// Update content-length if it changed
|
|
337
|
-
if (bodyText.length !== rewrittenText.length) {
|
|
338
|
-
responseHeaders["content-length"] = body.length.toString();
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
log(`[Proxy] ✓ Rewrote URLs: ${originalHost} → ${proxyHost}`);
|
|
342
|
-
} catch (error) {
|
|
343
|
-
console.warn(`[Proxy] Failed to rewrite URLs: ${error.message}`);
|
|
344
|
-
// Continue with original body on error
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
// If we get a 404, try to serve from cache first
|
|
349
|
-
if (response.status === 404) {
|
|
350
|
-
log("[Proxy] ✗ Target server returned 404");
|
|
351
|
-
log(`[Proxy] Checking cache: ${cacheKey}`);
|
|
352
|
-
|
|
353
|
-
if (existsSync(cacheDir)) {
|
|
354
|
-
try {
|
|
355
|
-
const metadataFile = join(cacheDir, "metadata.json");
|
|
356
|
-
if (existsSync(metadataFile)) {
|
|
357
|
-
const metadata = JSON.parse(
|
|
358
|
-
await readFile(metadataFile, "utf-8"),
|
|
359
|
-
);
|
|
360
|
-
log(
|
|
361
|
-
`[Proxy] ✓ Serving from cache instead of 404 (${metadata.timestamp})`,
|
|
362
|
-
);
|
|
363
|
-
await serveCachedResponse(res, cacheDir, req);
|
|
364
|
-
return;
|
|
365
|
-
}
|
|
366
|
-
} catch (cacheError) {
|
|
367
|
-
console.error(
|
|
368
|
-
`[Proxy] Failed to read cache: ${cacheError.message}`,
|
|
369
|
-
);
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
log("[Proxy] ✗ No cache available, passing through 404");
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
res.writeHead(response.status, responseHeaders);
|
|
377
|
-
res.end(body);
|
|
378
|
-
|
|
379
|
-
if (response.status >= 200 && response.status < 300) {
|
|
380
|
-
await cacheResponse(
|
|
381
|
-
cacheDir,
|
|
382
|
-
response.status,
|
|
383
|
-
responseHeaders,
|
|
384
|
-
body,
|
|
385
|
-
req.method,
|
|
386
|
-
fullPath,
|
|
387
|
-
req.headers.range,
|
|
388
|
-
);
|
|
389
|
-
}
|
|
390
|
-
} catch (err) {
|
|
391
|
-
log(`[Proxy] ✗ Real server failed: ${err.message}`);
|
|
392
|
-
log(`[Proxy] Checking cache: ${cacheKey}`);
|
|
393
|
-
|
|
394
|
-
if (existsSync(cacheDir)) {
|
|
395
|
-
try {
|
|
396
|
-
const metadataFile = join(cacheDir, "metadata.json");
|
|
397
|
-
if (existsSync(metadataFile)) {
|
|
398
|
-
const metadata = JSON.parse(
|
|
399
|
-
await readFile(metadataFile, "utf-8"),
|
|
400
|
-
);
|
|
401
|
-
log(`[Proxy] ✓ Serving from cache (${metadata.timestamp})`);
|
|
402
|
-
await serveCachedResponse(res, cacheDir, req);
|
|
403
|
-
return;
|
|
404
|
-
}
|
|
405
|
-
} catch (cacheError) {
|
|
406
|
-
console.error(
|
|
407
|
-
`[Proxy] Failed to read cache: ${cacheError.message}`,
|
|
408
|
-
);
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
log("[Proxy] ✗ No cache available, failing request");
|
|
413
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
414
|
-
res.end(
|
|
415
|
-
JSON.stringify({
|
|
416
|
-
error: "Server unavailable and no cache found",
|
|
417
|
-
cacheKey,
|
|
418
|
-
originalError: err.message,
|
|
419
|
-
}),
|
|
420
|
-
);
|
|
421
|
-
}
|
|
422
|
-
});
|
|
423
|
-
} catch (error) {
|
|
424
|
-
console.error(`[Proxy] Middleware error: ${error.message}`);
|
|
425
|
-
next();
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
}
|