@hyperframes/engine 0.1.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.
Files changed (119) hide show
  1. package/dist/config.d.ts +62 -0
  2. package/dist/config.d.ts.map +1 -0
  3. package/dist/config.js +92 -0
  4. package/dist/config.js.map +1 -0
  5. package/dist/config.test.d.ts +2 -0
  6. package/dist/config.test.d.ts.map +1 -0
  7. package/dist/config.test.js +77 -0
  8. package/dist/config.test.js.map +1 -0
  9. package/dist/index.d.ts +49 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +55 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/services/audioMixer.d.ts +11 -0
  14. package/dist/services/audioMixer.d.ts.map +1 -0
  15. package/dist/services/audioMixer.js +306 -0
  16. package/dist/services/audioMixer.js.map +1 -0
  17. package/dist/services/audioMixer.types.d.ts +27 -0
  18. package/dist/services/audioMixer.types.d.ts.map +1 -0
  19. package/dist/services/audioMixer.types.js +2 -0
  20. package/dist/services/audioMixer.types.js.map +1 -0
  21. package/dist/services/browserManager.d.ts +29 -0
  22. package/dist/services/browserManager.d.ts.map +1 -0
  23. package/dist/services/browserManager.js +175 -0
  24. package/dist/services/browserManager.js.map +1 -0
  25. package/dist/services/chunkEncoder.d.ts +32 -0
  26. package/dist/services/chunkEncoder.d.ts.map +1 -0
  27. package/dist/services/chunkEncoder.js +347 -0
  28. package/dist/services/chunkEncoder.js.map +1 -0
  29. package/dist/services/chunkEncoder.test.d.ts +2 -0
  30. package/dist/services/chunkEncoder.test.d.ts.map +1 -0
  31. package/dist/services/chunkEncoder.test.js +24 -0
  32. package/dist/services/chunkEncoder.test.js.map +1 -0
  33. package/dist/services/chunkEncoder.types.d.ts +26 -0
  34. package/dist/services/chunkEncoder.types.d.ts.map +1 -0
  35. package/dist/services/chunkEncoder.types.js +2 -0
  36. package/dist/services/chunkEncoder.types.js.map +1 -0
  37. package/dist/services/fileServer.d.ts +25 -0
  38. package/dist/services/fileServer.d.ts.map +1 -0
  39. package/dist/services/fileServer.js +138 -0
  40. package/dist/services/fileServer.js.map +1 -0
  41. package/dist/services/frameCapture.d.ts +52 -0
  42. package/dist/services/frameCapture.d.ts.map +1 -0
  43. package/dist/services/frameCapture.js +317 -0
  44. package/dist/services/frameCapture.js.map +1 -0
  45. package/dist/services/parallelCoordinator.d.ts +40 -0
  46. package/dist/services/parallelCoordinator.d.ts.map +1 -0
  47. package/dist/services/parallelCoordinator.js +192 -0
  48. package/dist/services/parallelCoordinator.js.map +1 -0
  49. package/dist/services/parallelCoordinator.test.d.ts +2 -0
  50. package/dist/services/parallelCoordinator.test.d.ts.map +1 -0
  51. package/dist/services/parallelCoordinator.test.js +38 -0
  52. package/dist/services/parallelCoordinator.test.js.map +1 -0
  53. package/dist/services/screenshotService.d.ts +28 -0
  54. package/dist/services/screenshotService.d.ts.map +1 -0
  55. package/dist/services/screenshotService.js +190 -0
  56. package/dist/services/screenshotService.js.map +1 -0
  57. package/dist/services/streamingEncoder.d.ts +49 -0
  58. package/dist/services/streamingEncoder.d.ts.map +1 -0
  59. package/dist/services/streamingEncoder.js +227 -0
  60. package/dist/services/streamingEncoder.js.map +1 -0
  61. package/dist/services/videoFrameExtractor.d.ts +65 -0
  62. package/dist/services/videoFrameExtractor.d.ts.map +1 -0
  63. package/dist/services/videoFrameExtractor.js +302 -0
  64. package/dist/services/videoFrameExtractor.js.map +1 -0
  65. package/dist/services/videoFrameInjector.d.ts +17 -0
  66. package/dist/services/videoFrameInjector.d.ts.map +1 -0
  67. package/dist/services/videoFrameInjector.js +92 -0
  68. package/dist/services/videoFrameInjector.js.map +1 -0
  69. package/dist/types.d.ts +79 -0
  70. package/dist/types.d.ts.map +1 -0
  71. package/dist/types.js +8 -0
  72. package/dist/types.js.map +1 -0
  73. package/dist/utils/ffprobe.d.ts +18 -0
  74. package/dist/utils/ffprobe.d.ts.map +1 -0
  75. package/dist/utils/ffprobe.js +137 -0
  76. package/dist/utils/ffprobe.js.map +1 -0
  77. package/dist/utils/gpuEncoder.d.ts +11 -0
  78. package/dist/utils/gpuEncoder.d.ts.map +1 -0
  79. package/dist/utils/gpuEncoder.js +55 -0
  80. package/dist/utils/gpuEncoder.js.map +1 -0
  81. package/dist/utils/parityContract.d.ts +3 -0
  82. package/dist/utils/parityContract.d.ts.map +1 -0
  83. package/dist/utils/parityContract.js +35 -0
  84. package/dist/utils/parityContract.js.map +1 -0
  85. package/dist/utils/runFfmpeg.d.ts +19 -0
  86. package/dist/utils/runFfmpeg.d.ts.map +1 -0
  87. package/dist/utils/runFfmpeg.js +62 -0
  88. package/dist/utils/runFfmpeg.js.map +1 -0
  89. package/dist/utils/urlDownloader.d.ts +3 -0
  90. package/dist/utils/urlDownloader.d.ts.map +1 -0
  91. package/dist/utils/urlDownloader.js +68 -0
  92. package/dist/utils/urlDownloader.js.map +1 -0
  93. package/package.json +30 -0
  94. package/src/cdp-headless-experimental.d.ts +54 -0
  95. package/src/config.test.ts +95 -0
  96. package/src/config.ts +176 -0
  97. package/src/index.ts +154 -0
  98. package/src/services/audioMixer.ts +392 -0
  99. package/src/services/audioMixer.types.ts +28 -0
  100. package/src/services/browserManager.ts +219 -0
  101. package/src/services/chunkEncoder.test.ts +27 -0
  102. package/src/services/chunkEncoder.ts +399 -0
  103. package/src/services/chunkEncoder.types.ts +27 -0
  104. package/src/services/fileServer.ts +175 -0
  105. package/src/services/frameCapture.ts +452 -0
  106. package/src/services/parallelCoordinator.test.ts +44 -0
  107. package/src/services/parallelCoordinator.ts +293 -0
  108. package/src/services/screenshotService.ts +222 -0
  109. package/src/services/streamingEncoder.ts +308 -0
  110. package/src/services/videoFrameExtractor.ts +395 -0
  111. package/src/services/videoFrameInjector.ts +113 -0
  112. package/src/types.ts +91 -0
  113. package/src/utils/ffprobe.ts +192 -0
  114. package/src/utils/gpuEncoder.ts +58 -0
  115. package/src/utils/parityContract.ts +35 -0
  116. package/src/utils/runFfmpeg.ts +81 -0
  117. package/src/utils/urlDownloader.ts +78 -0
  118. package/tsconfig.json +18 -0
  119. package/vitest.config.ts +7 -0
@@ -0,0 +1,399 @@
1
+ /**
2
+ * Chunk Encoder Service
3
+ *
4
+ * Encodes captured frames into video using FFmpeg.
5
+ * Supports CPU (libx264) and GPU encoding.
6
+ */
7
+
8
+ import { spawn } from "child_process";
9
+ import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "fs";
10
+ import { join, dirname } from "path";
11
+ import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
12
+ import { type GpuEncoder, getCachedGpuEncoder, getGpuEncoderName } from "../utils/gpuEncoder.js";
13
+ import { runFfmpeg } from "../utils/runFfmpeg.js";
14
+ import type { EncoderOptions, EncodeResult, MuxResult } from "./chunkEncoder.types.js";
15
+
16
+ export type { EncoderOptions, EncodeResult, MuxResult } from "./chunkEncoder.types.js";
17
+
18
+ export const ENCODER_PRESETS = {
19
+ draft: { preset: "ultrafast", quality: 28, codec: "h264" as const },
20
+ standard: { preset: "medium", quality: 23, codec: "h264" as const },
21
+ high: { preset: "slow", quality: 18, codec: "h264" as const },
22
+ };
23
+
24
+ // Re-export GPU utilities so existing consumers that import from chunkEncoder still work.
25
+ export { detectGpuEncoder, type GpuEncoder } from "../utils/gpuEncoder.js";
26
+
27
+ function buildEncoderArgs(
28
+ options: EncoderOptions,
29
+ inputArgs: string[],
30
+ outputPath: string,
31
+ gpuEncoder: GpuEncoder = null,
32
+ ): string[] {
33
+ const {
34
+ fps,
35
+ codec = "h264",
36
+ preset = "medium",
37
+ quality = 23,
38
+ bitrate,
39
+ pixelFormat = "yuv420p",
40
+ useGpu = false,
41
+ } = options;
42
+
43
+ const args: string[] = [...inputArgs, "-r", String(fps)];
44
+ const shouldUseGpu = useGpu && gpuEncoder !== null;
45
+
46
+ if (codec === "h264" || codec === "h265") {
47
+ if (shouldUseGpu) {
48
+ const encoderName = getGpuEncoderName(gpuEncoder, codec);
49
+ args.push("-c:v", encoderName);
50
+
51
+ switch (gpuEncoder) {
52
+ case "nvenc":
53
+ args.push("-preset", preset);
54
+ if (bitrate) args.push("-b:v", bitrate);
55
+ else args.push("-cq", String(quality));
56
+ break;
57
+ case "videotoolbox":
58
+ if (bitrate) args.push("-b:v", bitrate);
59
+ else {
60
+ const vtQuality = Math.max(0, Math.min(100, 100 - quality * 2));
61
+ args.push("-q:v", String(vtQuality));
62
+ }
63
+ args.push("-allow_sw", "1");
64
+ break;
65
+ case "vaapi":
66
+ args.unshift("-vaapi_device", "/dev/dri/renderD128");
67
+ args.push("-vf", "format=nv12,hwupload");
68
+ if (bitrate) args.push("-b:v", bitrate);
69
+ else args.push("-qp", String(quality));
70
+ break;
71
+ case "qsv":
72
+ args.push("-preset", preset);
73
+ if (bitrate) args.push("-b:v", bitrate);
74
+ else args.push("-global_quality", String(quality));
75
+ break;
76
+ }
77
+ } else {
78
+ const encoderName = codec === "h264" ? "libx264" : "libx265";
79
+ args.push("-c:v", encoderName, "-preset", preset);
80
+ if (bitrate) args.push("-b:v", bitrate);
81
+ else args.push("-crf", String(quality));
82
+ }
83
+ } else if (codec === "vp9") {
84
+ args.push("-c:v", "libvpx-vp9", "-b:v", bitrate || "0", "-crf", String(quality));
85
+ args.push("-deadline", preset === "ultrafast" ? "realtime" : "good");
86
+ } else if (codec === "prores") {
87
+ args.push("-c:v", "prores_ks", "-profile:v", preset, "-vendor", "apl0");
88
+ return [...args, "-y", outputPath];
89
+ }
90
+
91
+ if (gpuEncoder !== "vaapi") {
92
+ args.push("-pix_fmt", pixelFormat);
93
+ }
94
+
95
+ args.push("-y", outputPath);
96
+ return args;
97
+ }
98
+
99
+ export async function encodeFramesFromDir(
100
+ framesDir: string,
101
+ framePattern: string,
102
+ outputPath: string,
103
+ options: EncoderOptions,
104
+ signal?: AbortSignal,
105
+ config?: Partial<Pick<EngineConfig, "ffmpegEncodeTimeout">>,
106
+ ): Promise<EncodeResult> {
107
+ const startTime = Date.now();
108
+
109
+ const outputDir = dirname(outputPath);
110
+ if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
111
+
112
+ const files = readdirSync(framesDir).filter((f) => f.match(/\.(jpg|jpeg|png)$/i));
113
+ const frameCount = files.length;
114
+
115
+ if (frameCount === 0) {
116
+ return {
117
+ success: false,
118
+ outputPath,
119
+ durationMs: Date.now() - startTime,
120
+ framesEncoded: 0,
121
+ fileSize: 0,
122
+ error: "[FFmpeg] No frame files found in directory",
123
+ };
124
+ }
125
+
126
+ let gpuEncoder: GpuEncoder = null;
127
+ if (options.useGpu) {
128
+ gpuEncoder = await getCachedGpuEncoder();
129
+ }
130
+
131
+ const inputPath = join(framesDir, framePattern);
132
+ const inputArgs = ["-framerate", String(options.fps), "-i", inputPath];
133
+ const args = buildEncoderArgs(options, inputArgs, outputPath, gpuEncoder);
134
+
135
+ return new Promise((resolve) => {
136
+ const ffmpeg = spawn("ffmpeg", args);
137
+ let stderr = "";
138
+ const onAbort = () => {
139
+ ffmpeg.kill("SIGTERM");
140
+ };
141
+ if (signal) {
142
+ if (signal.aborted) {
143
+ ffmpeg.kill("SIGTERM");
144
+ } else {
145
+ signal.addEventListener("abort", onAbort, { once: true });
146
+ }
147
+ }
148
+
149
+ const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout;
150
+ const timer = setTimeout(() => {
151
+ ffmpeg.kill("SIGTERM");
152
+ }, encodeTimeout);
153
+
154
+ ffmpeg.stderr.on("data", (data) => {
155
+ stderr += data.toString();
156
+ });
157
+
158
+ ffmpeg.on("close", (code) => {
159
+ clearTimeout(timer);
160
+ if (signal) signal.removeEventListener("abort", onAbort);
161
+ const durationMs = Date.now() - startTime;
162
+ if (signal?.aborted) {
163
+ resolve({
164
+ success: false,
165
+ outputPath,
166
+ durationMs,
167
+ framesEncoded: 0,
168
+ fileSize: 0,
169
+ error: "FFmpeg encode cancelled",
170
+ });
171
+ return;
172
+ }
173
+
174
+ if (code !== 0) {
175
+ resolve({
176
+ success: false,
177
+ outputPath,
178
+ durationMs,
179
+ framesEncoded: 0,
180
+ fileSize: 0,
181
+ error: `FFmpeg exited with code ${code}`,
182
+ });
183
+ return;
184
+ }
185
+
186
+ const fileSize = existsSync(outputPath) ? statSync(outputPath).size : 0;
187
+ resolve({ success: true, outputPath, durationMs, framesEncoded: frameCount, fileSize });
188
+ });
189
+
190
+ ffmpeg.on("error", (err) => {
191
+ clearTimeout(timer);
192
+ if (signal) signal.removeEventListener("abort", onAbort);
193
+ resolve({
194
+ success: false,
195
+ outputPath,
196
+ durationMs: Date.now() - startTime,
197
+ framesEncoded: 0,
198
+ fileSize: 0,
199
+ error: `[FFmpeg] ${err.message}`,
200
+ });
201
+ });
202
+ });
203
+ }
204
+
205
+ export async function encodeFramesChunkedConcat(
206
+ framesDir: string,
207
+ framePattern: string,
208
+ outputPath: string,
209
+ options: EncoderOptions,
210
+ chunkSizeFrames: number,
211
+ signal?: AbortSignal,
212
+ ): Promise<EncodeResult> {
213
+ const start = Date.now();
214
+ const files = readdirSync(framesDir)
215
+ .filter((f) => f.match(/\.(jpg|jpeg|png)$/i))
216
+ .sort();
217
+ if (files.length === 0) {
218
+ return {
219
+ success: false,
220
+ outputPath,
221
+ durationMs: Date.now() - start,
222
+ framesEncoded: 0,
223
+ fileSize: 0,
224
+ error: "[FFmpeg] No frame files found in directory",
225
+ };
226
+ }
227
+ const chunkSize = Math.max(30, Math.floor(chunkSizeFrames));
228
+ const chunkCount = Math.ceil(files.length / chunkSize);
229
+ const chunkDir = join(dirname(outputPath), "chunk-encode");
230
+ if (!existsSync(chunkDir)) mkdirSync(chunkDir, { recursive: true });
231
+ const chunkPaths: string[] = [];
232
+
233
+ for (let i = 0; i < chunkCount; i++) {
234
+ if (signal?.aborted) {
235
+ return {
236
+ success: false,
237
+ outputPath,
238
+ durationMs: Date.now() - start,
239
+ framesEncoded: 0,
240
+ fileSize: 0,
241
+ error: "Chunked encode cancelled",
242
+ };
243
+ }
244
+ const startNumber = i * chunkSize;
245
+ const framesInChunk = Math.min(chunkSize, files.length - startNumber);
246
+ const chunkPath = join(chunkDir, `chunk_${String(i).padStart(4, "0")}.mp4`);
247
+ const inputPath = join(framesDir, framePattern);
248
+ const inputArgs = [
249
+ "-framerate",
250
+ String(options.fps),
251
+ "-start_number",
252
+ String(startNumber),
253
+ "-i",
254
+ inputPath,
255
+ "-frames:v",
256
+ String(framesInChunk),
257
+ ];
258
+ let gpuEncoder: GpuEncoder = null;
259
+ if (options.useGpu) gpuEncoder = await getCachedGpuEncoder();
260
+ const args = buildEncoderArgs(options, inputArgs, chunkPath, gpuEncoder);
261
+ const chunkResult = await new Promise<{ success: boolean; error?: string }>((resolve) => {
262
+ const ffmpeg = spawn("ffmpeg", args);
263
+ let stderr = "";
264
+ ffmpeg.stderr.on("data", (d) => {
265
+ stderr += d.toString();
266
+ });
267
+ ffmpeg.on("close", (code) => {
268
+ if (code === 0) resolve({ success: true });
269
+ else resolve({ success: false, error: `Chunk ${i} encode failed: ${stderr.slice(-400)}` });
270
+ });
271
+ ffmpeg.on("error", (err) => {
272
+ resolve({ success: false, error: `Chunk ${i} encode error: ${err.message}` });
273
+ });
274
+ });
275
+ if (!chunkResult.success) {
276
+ return {
277
+ success: false,
278
+ outputPath,
279
+ durationMs: Date.now() - start,
280
+ framesEncoded: 0,
281
+ fileSize: 0,
282
+ error: chunkResult.error,
283
+ };
284
+ }
285
+ chunkPaths.push(chunkPath);
286
+ }
287
+
288
+ const concatListPath = join(chunkDir, "concat-list.txt");
289
+ const concatInput = chunkPaths.map((path) => `file '${path.replace(/'/g, "'\\''")}'`).join("\n");
290
+ writeFileSync(concatListPath, concatInput, "utf-8");
291
+
292
+ const concatArgs = ["-f", "concat", "-safe", "0", "-i", concatListPath, "-c", "copy", "-y", outputPath];
293
+ const concatResult = await new Promise<{ success: boolean; error?: string }>((resolve) => {
294
+ const ffmpeg = spawn("ffmpeg", concatArgs);
295
+ let stderr = "";
296
+ ffmpeg.stderr.on("data", (d) => {
297
+ stderr += d.toString();
298
+ });
299
+ ffmpeg.on("close", (code) => {
300
+ if (code === 0) resolve({ success: true });
301
+ else resolve({ success: false, error: `Chunk concat failed: ${stderr.slice(-400)}` });
302
+ });
303
+ ffmpeg.on("error", (err) => {
304
+ resolve({ success: false, error: `Chunk concat error: ${err.message}` });
305
+ });
306
+ });
307
+
308
+ if (!concatResult.success) {
309
+ return {
310
+ success: false,
311
+ outputPath,
312
+ durationMs: Date.now() - start,
313
+ framesEncoded: 0,
314
+ fileSize: 0,
315
+ error: concatResult.error,
316
+ };
317
+ }
318
+
319
+ const fileSize = existsSync(outputPath) ? statSync(outputPath).size : 0;
320
+ return {
321
+ success: true,
322
+ outputPath,
323
+ durationMs: Date.now() - start,
324
+ framesEncoded: files.length,
325
+ fileSize,
326
+ };
327
+ }
328
+
329
+ export async function muxVideoWithAudio(
330
+ videoPath: string,
331
+ audioPath: string,
332
+ outputPath: string,
333
+ signal?: AbortSignal,
334
+ config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
335
+ ): Promise<MuxResult> {
336
+ const outputDir = dirname(outputPath);
337
+ if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
338
+
339
+ const args = [
340
+ "-i",
341
+ videoPath,
342
+ "-i",
343
+ audioPath,
344
+ "-c:v",
345
+ "copy",
346
+ "-c:a",
347
+ "aac",
348
+ "-b:a",
349
+ "192k",
350
+ "-shortest",
351
+ "-movflags",
352
+ "+faststart",
353
+ "-y",
354
+ outputPath,
355
+ ];
356
+
357
+ const processTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
358
+ const result = await runFfmpeg(args, { signal, timeout: processTimeout });
359
+
360
+ if (signal?.aborted) {
361
+ return { success: false, outputPath, durationMs: result.durationMs, error: "FFmpeg mux cancelled" };
362
+ }
363
+ return {
364
+ success: result.success,
365
+ outputPath,
366
+ durationMs: result.durationMs,
367
+ error: !result.success
368
+ ? result.exitCode !== null
369
+ ? `FFmpeg exited with code ${result.exitCode}`
370
+ : `[FFmpeg] ${result.stderr}`
371
+ : undefined,
372
+ };
373
+ }
374
+
375
+ export async function applyFaststart(
376
+ inputPath: string,
377
+ outputPath: string,
378
+ signal?: AbortSignal,
379
+ config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
380
+ ): Promise<MuxResult> {
381
+ const args = ["-i", inputPath, "-c", "copy", "-movflags", "+faststart", "-y", outputPath];
382
+
383
+ const processTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
384
+ const result = await runFfmpeg(args, { signal, timeout: processTimeout });
385
+
386
+ if (signal?.aborted) {
387
+ return { success: false, outputPath, durationMs: result.durationMs, error: "FFmpeg faststart cancelled" };
388
+ }
389
+ return {
390
+ success: result.success,
391
+ outputPath,
392
+ durationMs: result.durationMs,
393
+ error: !result.success
394
+ ? result.exitCode !== null
395
+ ? `FFmpeg exited with code ${result.exitCode}`
396
+ : `[FFmpeg] ${result.stderr}`
397
+ : undefined,
398
+ };
399
+ }
@@ -0,0 +1,27 @@
1
+ export interface EncoderOptions {
2
+ fps: number;
3
+ width: number;
4
+ height: number;
5
+ codec?: "h264" | "h265" | "vp9" | "prores";
6
+ preset?: string;
7
+ quality?: number;
8
+ bitrate?: string;
9
+ pixelFormat?: string;
10
+ useGpu?: boolean;
11
+ }
12
+
13
+ export interface EncodeResult {
14
+ success: boolean;
15
+ outputPath: string;
16
+ durationMs: number;
17
+ framesEncoded: number;
18
+ fileSize: number;
19
+ error?: string;
20
+ }
21
+
22
+ export interface MuxResult {
23
+ success: boolean;
24
+ outputPath: string;
25
+ durationMs: number;
26
+ error?: string;
27
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * File Server
3
+ *
4
+ * Lightweight HTTP server that serves a project directory to headless Chrome.
5
+ * Optionally injects scripts into index.html on-the-fly (e.g. runtime, bridge).
6
+ * Framework-agnostic — the caller decides what scripts to inject.
7
+ */
8
+
9
+ import { Hono } from "hono";
10
+ import { serve } from "@hono/node-server";
11
+ import { readFileSync, existsSync, statSync } from "node:fs";
12
+ import { join, extname } from "node:path";
13
+
14
+ const MIME_TYPES: Record<string, string> = {
15
+ ".html": "text/html; charset=utf-8",
16
+ ".css": "text/css; charset=utf-8",
17
+ ".js": "application/javascript; charset=utf-8",
18
+ ".json": "application/json; charset=utf-8",
19
+ ".png": "image/png",
20
+ ".jpg": "image/jpeg",
21
+ ".jpeg": "image/jpeg",
22
+ ".gif": "image/gif",
23
+ ".svg": "image/svg+xml",
24
+ ".webp": "image/webp",
25
+ ".mp4": "video/mp4",
26
+ ".webm": "video/webm",
27
+ ".mp3": "audio/mpeg",
28
+ ".wav": "audio/wav",
29
+ ".ogg": "audio/ogg",
30
+ ".aac": "audio/aac",
31
+ ".woff": "font/woff",
32
+ ".woff2": "font/woff2",
33
+ ".ttf": "font/ttf",
34
+ ".otf": "font/otf",
35
+ };
36
+
37
+ function stripEmbeddedRuntimeScripts(html: string): string {
38
+ if (!html) return html;
39
+ const scriptRe = /<script\b[^>]*>[\s\S]*?<\/script>/gi;
40
+ const runtimeSrcMarkers = [
41
+ "hyperframe.runtime.iife.js",
42
+ "hyperframes-runtime.modular.inline.js",
43
+ "data-hyperframes-preview-runtime",
44
+ ];
45
+ const runtimeInlineMarkers = [
46
+ "__hyperframeRuntimeBootstrapped",
47
+ "__hyperframeRuntime",
48
+ "__hyperframeRuntimeTeardown",
49
+ "window.__player =",
50
+ "window.__playerReady",
51
+ "window.__renderReady",
52
+ ];
53
+
54
+ const shouldStrip = (block: string): boolean => {
55
+ const lowered = block.toLowerCase();
56
+ for (const marker of runtimeSrcMarkers) {
57
+ if (lowered.includes(marker.toLowerCase())) {
58
+ return true;
59
+ }
60
+ }
61
+ for (const marker of runtimeInlineMarkers) {
62
+ if (block.includes(marker)) {
63
+ return true;
64
+ }
65
+ }
66
+ return false;
67
+ };
68
+
69
+ return html.replace(scriptRe, (block) => (shouldStrip(block) ? "" : block));
70
+ }
71
+
72
+ function injectScriptsIntoHtml(
73
+ html: string,
74
+ headScripts: string[],
75
+ bodyScripts: string[],
76
+ stripEmbedded: boolean,
77
+ ): string {
78
+ if (stripEmbedded) {
79
+ html = stripEmbeddedRuntimeScripts(html);
80
+ }
81
+
82
+ if (headScripts.length > 0) {
83
+ const headTags = headScripts.map((src) => `<script>${src}</script>`).join("\n");
84
+ if (html.includes("</head>")) {
85
+ html = html.replace("</head>", () => `${headTags}\n</head>`);
86
+ } else if (html.includes("<body")) {
87
+ html = html.replace("<body", () => `${headTags}\n<body`);
88
+ } else {
89
+ html = headTags + "\n" + html;
90
+ }
91
+ }
92
+
93
+ if (bodyScripts.length > 0) {
94
+ const bodyTags = bodyScripts.map((src) => `<script>${src}</script>`).join("\n");
95
+ if (html.includes("</body>")) {
96
+ html = html.replace("</body>", () => `${bodyTags}\n</body>`);
97
+ } else {
98
+ html = html + "\n" + bodyTags;
99
+ }
100
+ }
101
+
102
+ return html;
103
+ }
104
+
105
+ export interface FileServerOptions {
106
+ projectDir: string;
107
+ compiledDir?: string;
108
+ port?: number;
109
+ /** Scripts injected into <head> of index.html. Default: none. */
110
+ headScripts?: string[];
111
+ /** Scripts injected before </body> of index.html. Default: none. */
112
+ bodyScripts?: string[];
113
+ /** Strip embedded runtime scripts from HTML before injection. Default: true. */
114
+ stripEmbeddedRuntime?: boolean;
115
+ }
116
+
117
+ export interface FileServerHandle {
118
+ url: string;
119
+ port: number;
120
+ close: () => void;
121
+ }
122
+
123
+ export function createFileServer(options: FileServerOptions): Promise<FileServerHandle> {
124
+ const { projectDir, compiledDir, port = 0, stripEmbeddedRuntime = true } = options;
125
+
126
+ const headScripts = options.headScripts ?? [];
127
+ const bodyScripts = options.bodyScripts ?? [];
128
+
129
+ const app = new Hono();
130
+
131
+ app.get("/*", (c) => {
132
+ let requestPath = c.req.path;
133
+ if (requestPath === "/") requestPath = "/index.html";
134
+
135
+ // Remove leading slash
136
+ const relativePath = requestPath.replace(/^\//, "");
137
+ const compiledPath = compiledDir ? join(compiledDir, relativePath) : null;
138
+ const hasCompiledFile = Boolean(compiledPath && existsSync(compiledPath) && statSync(compiledPath).isFile());
139
+ const filePath = hasCompiledFile ? (compiledPath as string) : join(projectDir, relativePath);
140
+
141
+ if (!existsSync(filePath) || !statSync(filePath).isFile()) {
142
+ return c.text("Not found", 404);
143
+ }
144
+
145
+ const ext = extname(filePath).toLowerCase();
146
+ const contentType = MIME_TYPES[ext] || "application/octet-stream";
147
+
148
+ if (ext === ".html") {
149
+ const rawHtml = readFileSync(filePath, "utf-8");
150
+ const html =
151
+ relativePath === "index.html"
152
+ ? injectScriptsIntoHtml(rawHtml, headScripts, bodyScripts, stripEmbeddedRuntime)
153
+ : rawHtml;
154
+ return c.text(html, 200, { "Content-Type": contentType });
155
+ }
156
+
157
+ const content = readFileSync(filePath);
158
+ return new Response(content, {
159
+ status: 200,
160
+ headers: { "Content-Type": contentType },
161
+ });
162
+ });
163
+
164
+ return new Promise((resolve) => {
165
+ const server = serve({ fetch: app.fetch, port }, (info) => {
166
+ const actualPort = info.port;
167
+ const url = `http://localhost:${actualPort}`;
168
+ resolve({
169
+ url,
170
+ port: actualPort,
171
+ close: () => server.close(),
172
+ });
173
+ });
174
+ });
175
+ }