@flow-player/test-streams 0.6.0 → 0.6.2

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 (40) hide show
  1. package/{src/cli/extend-vod.ts → dist/cli/extend-vod.js} +39 -61
  2. package/dist/cli/generate.js +473 -0
  3. package/dist/cli/serve.js +644 -0
  4. package/dist/config.d.ts +29 -0
  5. package/dist/fixtures/catalog.d.ts +37 -0
  6. package/dist/fixtures/catalog.js +63 -0
  7. package/dist/fixtures/codec.d.ts +10 -0
  8. package/dist/fixtures/format.d.ts +13 -0
  9. package/dist/fixtures/generate.d.ts +11 -0
  10. package/dist/fixtures/generate.js +361 -0
  11. package/dist/fixtures/matrix.d.ts +18 -0
  12. package/dist/index.d.ts +8 -0
  13. package/dist/index.js +894 -0
  14. package/dist/server/live.d.ts +14 -0
  15. package/dist/server/routes.d.ts +18 -0
  16. package/dist/server/server.d.ts +18 -0
  17. package/dist/server/server.js +534 -0
  18. package/dist/server/static.d.ts +13 -0
  19. package/dist/vite-plugin.d.ts +12 -0
  20. package/dist/vite-plugin.js +58 -0
  21. package/package.json +24 -11
  22. package/src/cli/generate.ts +0 -138
  23. package/src/cli/serve.ts +0 -112
  24. package/src/config.ts +0 -34
  25. package/src/fixtures/catalog.ts +0 -99
  26. package/src/fixtures/codec.ts +0 -43
  27. package/src/fixtures/format.ts +0 -118
  28. package/src/fixtures/generate.ts +0 -209
  29. package/src/fixtures/matrix.ts +0 -94
  30. package/src/index.ts +0 -41
  31. package/src/server/live.ts +0 -220
  32. package/src/server/routes.ts +0 -141
  33. package/src/server/server.ts +0 -193
  34. package/src/server/static.ts +0 -83
  35. package/src/visual/analyze.ts +0 -234
  36. package/src/visual/dhash.ts +0 -76
  37. package/src/visual/index.ts +0 -18
  38. package/src/visual/report.ts +0 -80
  39. package/src/vite-plugin.d.ts +0 -10
  40. package/src/vite-plugin.ts +0 -76
@@ -0,0 +1,14 @@
1
+ import { type IncomingMessage, type ServerResponse } from "node:http";
2
+ import { type CodecId, type FormatId } from "../config.js";
3
+ export declare function matchLivePath(path: string): {
4
+ format: FormatId;
5
+ codec: CodecId;
6
+ } | null;
7
+ export type LiveStreamOptions = {
8
+ ffmpegPath?: string;
9
+ width?: number;
10
+ height?: number;
11
+ fps?: number;
12
+ bitrate?: string;
13
+ };
14
+ export declare function handleLiveStream(req: IncomingMessage, res: ServerResponse, format: FormatId, codec: CodecId, options?: LiveStreamOptions): void;
@@ -0,0 +1,18 @@
1
+ import { type ServerResponse } from "node:http";
2
+ import { type Manifest } from "../fixtures/catalog.js";
3
+ export declare function resolveRoute(path: string): string | null;
4
+ export declare function handleHealth(res: ServerResponse, manifest: Manifest | null, fixtureDir: string): void;
5
+ export declare function handleList(res: ServerResponse, manifest: Manifest | null): void;
6
+ export type VodSegmentDescriptor = {
7
+ url: string;
8
+ startTimestamp: number;
9
+ endTimestamp: number;
10
+ timeLen: number;
11
+ fileSize: number;
12
+ channelID: number;
13
+ RecordType: number;
14
+ };
15
+ export type VodListResponse = {
16
+ segments: ReadonlyArray<VodSegmentDescriptor>;
17
+ };
18
+ export declare function handleVodList(res: ServerResponse, manifest: Manifest | null, fixtureDir: string): void;
@@ -0,0 +1,18 @@
1
+ import { type Server } from "node:http";
2
+ import { type Manifest } from "../fixtures/catalog.js";
3
+ export type StreamsServerHandle = {
4
+ server: Server;
5
+ port: number;
6
+ host: string;
7
+ close: () => Promise<void>;
8
+ refreshManifest: () => Manifest | null;
9
+ };
10
+ export type StreamsServerOptions = {
11
+ port?: number;
12
+ host?: string;
13
+ fixtureDir: string;
14
+ cert?: string;
15
+ key?: string;
16
+ };
17
+ export declare function createStreamsServer(options: StreamsServerOptions): StreamsServerHandle;
18
+ export declare function listen(server: StreamsServerHandle): Promise<StreamsServerHandle>;
@@ -0,0 +1,534 @@
1
+ // src/server/server.ts
2
+ import { createReadStream as createReadStream2, readFileSync as readFileSync3 } from "node:fs";
3
+ import {
4
+ createServer as createHttpServer
5
+ } from "node:http";
6
+ import { createServer as createHttpsServer } from "node:https";
7
+ import { extname, resolve as resolve2, sep } from "node:path";
8
+
9
+ // src/config.ts
10
+ var DEFAULT_PORT = 4177;
11
+ var DEFAULT_HOST = "127.0.0.1";
12
+ var ALL_CODECS = ["h264", "hevc"];
13
+ var ALL_FORMATS = ["flv", "hls", "mp4", "fmp4", "ts"];
14
+
15
+ // src/fixtures/catalog.ts
16
+ import { existsSync, readFileSync, statSync } from "node:fs";
17
+ import { resolve } from "node:path";
18
+ function loadManifest(fixtureDir) {
19
+ const path = resolve(fixtureDir, "manifest.json");
20
+ if (!existsSync(path)) return null;
21
+ const raw = JSON.parse(readFileSync(path, "utf8"));
22
+ return raw;
23
+ }
24
+ function summarizeCatalog(fixtureDir) {
25
+ const manifest = loadManifest(fixtureDir);
26
+ if (!manifest) {
27
+ return {
28
+ codecs: ALL_CODECS,
29
+ formats: ALL_FORMATS,
30
+ fixtureCount: 0,
31
+ exists: false,
32
+ reason: `manifest.json not found at ${resolve(fixtureDir, "manifest.json")}`
33
+ };
34
+ }
35
+ let total = 0;
36
+ for (const item of manifest.fixtures) total += item.entries.length;
37
+ return {
38
+ codecs: ALL_CODECS,
39
+ formats: ALL_FORMATS,
40
+ fixtureCount: total,
41
+ exists: true
42
+ };
43
+ }
44
+
45
+ // src/server/static.ts
46
+ import { createReadStream, statSync as statSync2 } from "node:fs";
47
+ function applyCors(res) {
48
+ res.setHeader("Access-Control-Allow-Origin", "*");
49
+ res.setHeader("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS");
50
+ res.setHeader("Access-Control-Allow-Headers", "Range, Content-Type, If-None-Match");
51
+ }
52
+ function writeJson(res, status, body) {
53
+ const text = JSON.stringify(body);
54
+ res.writeHead(status, {
55
+ "Content-Type": "application/json; charset=utf-8",
56
+ "Content-Length": Buffer.byteLength(text),
57
+ "Cache-Control": "no-store"
58
+ });
59
+ res.end(text);
60
+ }
61
+ function writeNotFound(res) {
62
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
63
+ res.end("Not Found\n");
64
+ }
65
+ function writeBadRequest(res, message) {
66
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
67
+ res.end(`${message}
68
+ `);
69
+ }
70
+ function setRangeHeaders(req, res, fullPath, mime) {
71
+ const stat = statSync2(fullPath);
72
+ const total = stat.size;
73
+ res.setHeader("Accept-Ranges", "bytes");
74
+ const range = req.headers.range;
75
+ if (!range) {
76
+ res.setHeader("Content-Length", total);
77
+ res.setHeader("Content-Type", mime);
78
+ return null;
79
+ }
80
+ const match = /^bytes=(\d*)-(\d*)$/.exec(range);
81
+ if (!match) {
82
+ res.setHeader("Content-Length", total);
83
+ res.setHeader("Content-Type", mime);
84
+ return null;
85
+ }
86
+ const startRaw = match[1];
87
+ const endRaw = match[2];
88
+ let start = startRaw === "" ? Math.max(0, total - Number(endRaw || 0)) : Number(startRaw);
89
+ let end = endRaw === "" ? total - 1 : Number(endRaw);
90
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || end >= total) {
91
+ res.setHeader("Content-Range", `bytes */${total}`);
92
+ res.writeHead(416);
93
+ res.end();
94
+ return { alreadyServed: true };
95
+ }
96
+ start = Math.max(0, start);
97
+ end = Math.min(total - 1, end);
98
+ res.setHeader("Content-Range", `bytes ${start}-${end}/${total}`);
99
+ res.setHeader("Content-Length", end - start + 1);
100
+ res.setHeader("Content-Type", mime);
101
+ res.statusCode = 206;
102
+ return { start, end };
103
+ }
104
+
105
+ // src/server/routes.ts
106
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
107
+ import { join } from "node:path";
108
+ var FORMAT_SET = Object.fromEntries(ALL_FORMATS.map((f) => [f, true]));
109
+ var CODEC_SET = Object.fromEntries(ALL_CODECS.map((c) => [c, true]));
110
+ var HLS_FMP4 = { hls: true, fmp4: true };
111
+ var SINGLE = /^\/streams\/([a-z0-9]+)\/([a-z0-9]+)\/?$/;
112
+ var PLAYLIST = /^\/streams\/(hls|fmp4)\/([a-z0-9]+)\/(.+)$/;
113
+ var VOD = /^\/streams\/vod\/([a-zA-Z0-9._-]+)\/?$/;
114
+ function resolveRoute(path) {
115
+ const single = SINGLE.exec(path);
116
+ if (single) {
117
+ const [, formatRaw, codecRaw] = single;
118
+ if (!FORMAT_SET[formatRaw] || !CODEC_SET[codecRaw]) return null;
119
+ if (HLS_FMP4[formatRaw]) return null;
120
+ const format = formatRaw;
121
+ const codec = codecRaw;
122
+ return join(format, `${codec}.${format}`);
123
+ }
124
+ const playlist = PLAYLIST.exec(path);
125
+ if (playlist) {
126
+ const [, formatRaw, codecRaw, rest] = playlist;
127
+ if (!FORMAT_SET[formatRaw] || !CODEC_SET[codecRaw]) return null;
128
+ return join(formatRaw, codecRaw, rest);
129
+ }
130
+ const vod = VOD.exec(path);
131
+ if (vod) {
132
+ const [, name] = vod;
133
+ return join("vod", name);
134
+ }
135
+ return null;
136
+ }
137
+ function handleHealth(res, manifest, fixtureDir) {
138
+ const summary = summarizeCatalog(fixtureDir);
139
+ const status = manifest ? 200 : 503;
140
+ writeJson(res, status, {
141
+ status: summary.exists ? "ok" : "down",
142
+ codecs: summary.codecs,
143
+ formats: summary.formats,
144
+ fixtures: summary.fixtureCount,
145
+ reason: manifest ? void 0 : summary.reason
146
+ });
147
+ }
148
+ function handleList(res, manifest) {
149
+ if (!manifest) {
150
+ writeJson(res, 503, { error: "manifest not found" });
151
+ return;
152
+ }
153
+ writeJson(res, 200, manifest);
154
+ }
155
+ function handleVodList(res, manifest, fixtureDir) {
156
+ const vodListPath = join(fixtureDir, "vod", "vod-list.json");
157
+ if (existsSync2(vodListPath)) {
158
+ try {
159
+ const raw = JSON.parse(readFileSync2(vodListPath, "utf8"));
160
+ const segMs = manifest?.durationSeconds ? manifest.durationSeconds * 1e3 : 0;
161
+ const segments = raw.entries.map((e, i) => ({
162
+ url: `/streams/${e.path}`,
163
+ startTimestamp: i * segMs,
164
+ endTimestamp: (i + 1) * segMs,
165
+ timeLen: segMs,
166
+ fileSize: e.size,
167
+ channelID: 1,
168
+ RecordType: 1
169
+ }));
170
+ writeJson(res, 200, { segments });
171
+ return;
172
+ } catch {
173
+ }
174
+ }
175
+ if (!manifest) {
176
+ writeJson(res, 503, { error: "manifest not found" });
177
+ return;
178
+ }
179
+ const h264mp4 = manifest.fixtures.find((f) => f.codec === "h264" && f.format === "mp4");
180
+ const fileSize = h264mp4?.entries[0]?.size ?? 0;
181
+ const durMs = manifest.durationSeconds * 1e3;
182
+ writeJson(res, 200, {
183
+ segments: [
184
+ {
185
+ url: "/streams/vod/sample-1.mp4",
186
+ startTimestamp: 0,
187
+ endTimestamp: durMs,
188
+ timeLen: durMs,
189
+ fileSize,
190
+ channelID: 1,
191
+ RecordType: 1
192
+ },
193
+ {
194
+ url: "/streams/vod/sample-2.mp4",
195
+ startTimestamp: durMs,
196
+ endTimestamp: durMs * 2,
197
+ timeLen: durMs,
198
+ fileSize,
199
+ channelID: 1,
200
+ RecordType: 1
201
+ }
202
+ ]
203
+ });
204
+ }
205
+
206
+ // src/server/live.ts
207
+ import { spawn } from "node:child_process";
208
+
209
+ // src/fixtures/codec.ts
210
+ var CODEC_SPEC = {
211
+ h264: {
212
+ codec: "h264",
213
+ encoder: "libx264",
214
+ videoArgs: ["-profile:v", "high", "-preset", "veryfast"],
215
+ audioArgs: ["-c:a", "aac", "-b:a", "128k"],
216
+ pixelFormat: "yuv420p"
217
+ },
218
+ hevc: {
219
+ codec: "hevc",
220
+ encoder: "libx265",
221
+ // 注意:FLV / TS 容器用 enhanced-rtmp 1cvh tag,不能强行写 hvc1;
222
+ // MP4 / fMP4 / HLS 走 hvc1。containerArgs 在 buildPlan 时按 format 决定。
223
+ videoArgs: ["-preset", "veryfast"],
224
+ audioArgs: ["-c:a", "aac", "-b:a", "128k"],
225
+ pixelFormat: "yuv420p"
226
+ }
227
+ };
228
+
229
+ // src/server/live.ts
230
+ var LIVE_MIME = {
231
+ flv: "video/x-flv",
232
+ hls: "application/vnd.apple.mpegurl",
233
+ mp4: "video/mp4",
234
+ fmp4: "video/iso.segment",
235
+ ts: "video/mp2t"
236
+ };
237
+ var CODEC_SET2 = { h264: true, hevc: true };
238
+ var FORMAT_SET2 = { flv: true, hls: true, mp4: true, fmp4: true, ts: true };
239
+ var LIVE_PATTERN = /^\/streams\/live\/([a-z0-9]+)\/([a-z0-9]+)\/?$/;
240
+ function matchLivePath(path) {
241
+ const match = LIVE_PATTERN.exec(path);
242
+ if (!match) return null;
243
+ const [, formatRaw, codecRaw] = match;
244
+ if (!FORMAT_SET2[formatRaw] || !CODEC_SET2[codecRaw]) return null;
245
+ return { format: formatRaw, codec: codecRaw };
246
+ }
247
+ function handleLiveStream(req, res, format, codec, options = {}) {
248
+ if (req.method !== "GET" && req.method !== "HEAD") {
249
+ res.writeHead(405, { Allow: "GET, HEAD, OPTIONS" });
250
+ res.end("Method Not Allowed\n");
251
+ return;
252
+ }
253
+ if (format === "hls") {
254
+ writeJson(res, 501, {
255
+ error: "HLS live streaming not yet supported in this build",
256
+ reason: "use /streams/live/flv/hevc etc. for non-HLS live, or /streams/hls/hevc/master.m3u8 for VOD HLS"
257
+ });
258
+ return;
259
+ }
260
+ const ffmpegPath = options.ffmpegPath ?? "ffmpeg";
261
+ const width = options.width ?? 1280;
262
+ const height = options.height ?? 720;
263
+ const fps = options.fps ?? 25;
264
+ const bitrate = options.bitrate ?? "1500k";
265
+ const spec = CODEC_SPEC[codec];
266
+ const mime = LIVE_MIME[format];
267
+ const ffmpegArgs = buildLiveFfmpegArgs(format, codec, spec, {
268
+ width,
269
+ height,
270
+ fps,
271
+ bitrate
272
+ });
273
+ if (req.method === "HEAD") {
274
+ res.writeHead(200, {
275
+ "Content-Type": mime,
276
+ "Access-Control-Allow-Origin": "*",
277
+ "X-Live-Source": "ffmpeg-stream-loop"
278
+ });
279
+ res.end();
280
+ return;
281
+ }
282
+ let proc;
283
+ try {
284
+ proc = spawn(ffmpegPath, ffmpegArgs, { stdio: ["ignore", "pipe", "pipe"] });
285
+ } catch (err) {
286
+ const message = err instanceof Error ? err.message : String(err);
287
+ writeJson(res, 503, { error: "ffmpeg spawn failed", reason: message });
288
+ return;
289
+ }
290
+ proc.once("error", (err) => {
291
+ if (!res.headersSent) {
292
+ writeJson(res, 503, { error: "ffmpeg failed to start", reason: err.message });
293
+ return;
294
+ }
295
+ res.destroy(err);
296
+ });
297
+ proc.stdout.once("data", () => {
298
+ if (!res.headersSent) {
299
+ res.writeHead(200, {
300
+ "Content-Type": mime,
301
+ "Access-Control-Allow-Origin": "*",
302
+ "Transfer-Encoding": "chunked",
303
+ "X-Live-Source": "ffmpeg-stream-loop"
304
+ });
305
+ }
306
+ });
307
+ proc.stdout.pipe(res);
308
+ const cleanup = () => {
309
+ if (!proc.killed) proc.kill("SIGKILL");
310
+ };
311
+ req.once("close", cleanup);
312
+ res.once("close", cleanup);
313
+ }
314
+ function buildLiveFfmpegArgs(format, codec, spec, opts) {
315
+ const gop = String(opts.fps);
316
+ const videoInput = `testsrc2=size=${opts.width}x${opts.height}:rate=${opts.fps},format=yuv420p`;
317
+ const audioInput = `sine=frequency=1000:sample_rate=44100`;
318
+ const hevcTag = codec === "hevc" && format === "mp4" ? ["-tag:v", "hvc1"] : [];
319
+ const baseArgs = [
320
+ "-hide_banner",
321
+ "-loglevel",
322
+ "error",
323
+ "-fflags",
324
+ "+genpts",
325
+ "-re",
326
+ "-stream_loop",
327
+ "-1",
328
+ "-f",
329
+ "lavfi",
330
+ "-i",
331
+ videoInput,
332
+ "-f",
333
+ "lavfi",
334
+ "-i",
335
+ audioInput,
336
+ "-c:v",
337
+ spec.encoder,
338
+ ...spec.videoArgs,
339
+ ...liveLowLatencyVideoArgs(codec),
340
+ "-pix_fmt",
341
+ spec.pixelFormat,
342
+ "-g",
343
+ gop,
344
+ "-keyint_min",
345
+ gop,
346
+ "-sc_threshold",
347
+ "0",
348
+ "-b:v",
349
+ opts.bitrate,
350
+ ...hevcTag,
351
+ ...spec.audioArgs
352
+ ];
353
+ if (format === "flv") {
354
+ return [...baseArgs, "-f", "flv", "-flvflags", "no_sequence_end", "-"];
355
+ }
356
+ if (format === "mp4") {
357
+ return [
358
+ ...baseArgs,
359
+ "-f",
360
+ "mp4",
361
+ "-movflags",
362
+ "+faststart+frag_keyframe+empty_moov+default_base_moof",
363
+ "-"
364
+ ];
365
+ }
366
+ if (format === "fmp4") {
367
+ return [
368
+ ...baseArgs,
369
+ "-f",
370
+ "mp4",
371
+ "-movflags",
372
+ "frag_keyframe+empty_moov+default_base_moof",
373
+ "-frag_duration",
374
+ "2000000",
375
+ "-"
376
+ ];
377
+ }
378
+ if (format === "ts") {
379
+ return [...baseArgs, "-f", "mpegts", "-"];
380
+ }
381
+ throw new Error(`Unknown format: ${String(format)}`);
382
+ }
383
+ function liveLowLatencyVideoArgs(codec) {
384
+ if (codec === "h264" || codec === "hevc") {
385
+ return ["-tune", "zerolatency"];
386
+ }
387
+ return [];
388
+ }
389
+
390
+ // src/server/server.ts
391
+ function createStreamsServer(options) {
392
+ const host = options.host ?? DEFAULT_HOST;
393
+ const port = options.port ?? DEFAULT_PORT;
394
+ const fixtureDir = resolve2(options.fixtureDir);
395
+ const ctx = {
396
+ manifest: loadManifest(fixtureDir),
397
+ fixtureDir
398
+ };
399
+ const refreshManifest = () => {
400
+ ctx.manifest = loadManifest(fixtureDir);
401
+ return ctx.manifest;
402
+ };
403
+ const handler = (req, res) => {
404
+ applyCors(res);
405
+ if (req.method === "OPTIONS") {
406
+ res.writeHead(204, {
407
+ "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
408
+ "Access-Control-Allow-Headers": "Range, Content-Type, If-None-Match"
409
+ });
410
+ res.end();
411
+ return;
412
+ }
413
+ if (req.method !== "GET" && req.method !== "HEAD") {
414
+ res.writeHead(405, { Allow: "GET, HEAD, OPTIONS" });
415
+ res.end("Method Not Allowed\n");
416
+ return;
417
+ }
418
+ try {
419
+ const path = decodePath(req.url ?? "/");
420
+ if (path.includes("..")) {
421
+ writeBadRequest(res, "Path traversal not allowed");
422
+ return;
423
+ }
424
+ if (path === "/streams/health" || path === "/streams/health/") {
425
+ handleHealth(res, ctx.manifest, fixtureDir);
426
+ return;
427
+ }
428
+ if (path === "/streams/list" || path === "/streams/list/") {
429
+ handleList(res, ctx.manifest);
430
+ return;
431
+ }
432
+ if (path === "/streams/vod-list" || path === "/streams/vod-list/") {
433
+ handleVodList(res, ctx.manifest, ctx.fixtureDir);
434
+ return;
435
+ }
436
+ const resolved = resolveRoute(path);
437
+ if (resolved) {
438
+ serveEntry(req, res, fixtureDir, resolved);
439
+ return;
440
+ }
441
+ const liveMatch = matchLivePath(path);
442
+ if (liveMatch) {
443
+ handleLiveStream(req, res, liveMatch.format, liveMatch.codec);
444
+ return;
445
+ }
446
+ writeNotFound(res);
447
+ } catch (err) {
448
+ const message = err instanceof Error ? err.message : String(err);
449
+ if (!res.headersSent) {
450
+ res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
451
+ res.end(`Internal Error: ${message}
452
+ `);
453
+ } else {
454
+ res.destroy(err instanceof Error ? err : new Error(message));
455
+ }
456
+ }
457
+ };
458
+ const server = options.cert && options.key ? createHttpsServer(
459
+ {
460
+ cert: readFileSync3(resolve2(options.cert)),
461
+ key: readFileSync3(resolve2(options.key))
462
+ },
463
+ handler
464
+ ) : createHttpServer(handler);
465
+ return {
466
+ server,
467
+ port,
468
+ host,
469
+ close: () => new Promise((resolveClose, rejectClose) => {
470
+ server.close((err) => err ? rejectClose(err) : resolveClose());
471
+ }),
472
+ refreshManifest
473
+ };
474
+ }
475
+ async function listen(server) {
476
+ const { promise, resolve: resolve3, reject } = Promise.withResolvers();
477
+ server.server.once("error", reject);
478
+ server.server.listen(server.port, server.host, () => {
479
+ const addr = server.server.address();
480
+ if (!addr) {
481
+ reject(new Error("server.address() returned null after listen"));
482
+ return;
483
+ }
484
+ server.port = addr.port;
485
+ resolve3(server);
486
+ });
487
+ return promise;
488
+ }
489
+ var FIXTURE_MIME = {
490
+ ".flv": "video/x-flv",
491
+ ".mp4": "video/mp4",
492
+ ".m4s": "video/iso.segment",
493
+ ".ts": "video/mp2t",
494
+ ".m3u8": "application/vnd.apple.mpegurl",
495
+ ".json": "application/json; charset=utf-8",
496
+ ".aac": "audio/aac"
497
+ };
498
+ function serveEntry(req, res, fixtureDir, relPath) {
499
+ const abs = resolve2(fixtureDir, relPath);
500
+ const rel = relativePosix(fixtureDir, abs);
501
+ if (rel === "" || rel.startsWith("..") || rel.includes(`..${sep}`)) {
502
+ writeNotFound(res);
503
+ return;
504
+ }
505
+ const mime = FIXTURE_MIME[extname(abs).toLowerCase()] ?? "application/octet-stream";
506
+ const rangeInfo = setRangeHeaders(req, res, abs, mime);
507
+ if (rangeInfo && "alreadyServed" in rangeInfo) return;
508
+ if (req.method === "HEAD") {
509
+ res.end();
510
+ return;
511
+ }
512
+ const startEnd = rangeInfo && "start" in rangeInfo ? rangeInfo : null;
513
+ const stream = startEnd ? createReadStream2(abs, { start: startEnd.start, end: startEnd.end }) : createReadStream2(abs);
514
+ stream.on("error", (err) => res.destroy(err));
515
+ stream.pipe(res);
516
+ }
517
+ function relativePosix(root, abs) {
518
+ if (abs === root) return "";
519
+ if (abs.startsWith(root + sep)) return abs.slice(root.length + 1);
520
+ if (abs.startsWith(root)) return abs.slice(root.length);
521
+ return abs;
522
+ }
523
+ function decodePath(rawTarget) {
524
+ const stripped = rawTarget.split(/[?#]/, 1)[0] || "/";
525
+ try {
526
+ return decodeURIComponent(stripped);
527
+ } catch {
528
+ return stripped;
529
+ }
530
+ }
531
+ export {
532
+ createStreamsServer,
533
+ listen
534
+ };
@@ -0,0 +1,13 @@
1
+ import { type IncomingMessage, type ServerResponse } from "node:http";
2
+ export declare function applyCors(res: ServerResponse): void;
3
+ export declare function writeJson(res: ServerResponse, status: number, body: unknown): void;
4
+ export declare function writeNotFound(res: ServerResponse): void;
5
+ export declare function writeBadRequest(res: ServerResponse, message: string): void;
6
+ export declare function writeStaticFile(res: ServerResponse, fullPath: string, mime: string): void;
7
+ export type RangeInfo = {
8
+ alreadyServed: true;
9
+ } | {
10
+ start: number;
11
+ end: number;
12
+ } | null;
13
+ export declare function setRangeHeaders(req: IncomingMessage, res: ServerResponse, fullPath: string, mime: string): RangeInfo;
@@ -0,0 +1,12 @@
1
+ import type { Plugin } from "vite";
2
+ export interface TestStreamsVitePluginOptions {
3
+ /** TLS 证书路径,传给 streams serve(默认 /tmp/flow-player-local.pem)。 */
4
+ cert?: string;
5
+ /** TLS 私钥路径,传给 streams serve(默认 /tmp/flow-player-local-key.pem)。 */
6
+ key?: string;
7
+ /** streams server 健康检查 URL(默认 https://127.0.0.1:4177/streams/health)。 */
8
+ healthUrl?: string;
9
+ /** streams server 日志文件(默认 /tmp/test-streams.log)。 */
10
+ logPath?: string;
11
+ }
12
+ export declare function testStreamsVitePlugin(options?: TestStreamsVitePluginOptions): Plugin;
@@ -0,0 +1,58 @@
1
+ // src/vite-plugin.ts
2
+ import { spawn } from "node:child_process";
3
+ import { openSync } from "node:fs";
4
+ import https from "node:https";
5
+ var DEFAULT_CERT = "/tmp/flow-player-local.pem";
6
+ var DEFAULT_KEY = "/tmp/flow-player-local-key.pem";
7
+ var DEFAULT_HEALTH_URL = "https://127.0.0.1:4177/streams/health";
8
+ var DEFAULT_LOG_PATH = "/tmp/test-streams.log";
9
+ function streamsHealthy(healthUrl) {
10
+ return new Promise((resolveHealthy) => {
11
+ const req = https.get(
12
+ healthUrl,
13
+ { rejectUnauthorized: false, timeout: 2e3 },
14
+ (res) => {
15
+ res.resume();
16
+ resolveHealthy(
17
+ res.statusCode !== void 0 && res.statusCode >= 200 && res.statusCode < 300
18
+ );
19
+ }
20
+ );
21
+ req.on("timeout", () => {
22
+ req.destroy();
23
+ resolveHealthy(false);
24
+ });
25
+ req.on("error", () => resolveHealthy(false));
26
+ });
27
+ }
28
+ function testStreamsVitePlugin(options = {}) {
29
+ const {
30
+ cert = DEFAULT_CERT,
31
+ key = DEFAULT_KEY,
32
+ healthUrl = DEFAULT_HEALTH_URL,
33
+ logPath = DEFAULT_LOG_PATH
34
+ } = options;
35
+ const serveScript = "./cli/serve.js" ? "./cli/serve.js" : resolve(dirname(fileURLToPath(import.meta.url)), "cli/serve.ts");
36
+ return {
37
+ name: "flow-player-test-streams",
38
+ apply: "serve",
39
+ configureServer() {
40
+ void (async () => {
41
+ if (await streamsHealthy(healthUrl)) {
42
+ return;
43
+ }
44
+ const logFd = openSync(logPath, "w");
45
+ const child = spawn(process.execPath, [serveScript, "--cert", cert, "--key", key], {
46
+ detached: true,
47
+ windowsHide: true,
48
+ stdio: ["ignore", logFd, logFd]
49
+ });
50
+ child.unref();
51
+ console.log(`[test-streams] \u672A\u5C31\u7EEA\uFF0C\u5DF2\u5728\u540E\u53F0\u542F\u52A8\uFF0C\u65E5\u5FD7: ${logPath}`);
52
+ })();
53
+ }
54
+ };
55
+ }
56
+ export {
57
+ testStreamsVitePlugin
58
+ };