@molecule/api-media-streaming-hls 1.0.0 → 1.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.
package/README.md ADDED
@@ -0,0 +1,278 @@
1
+ <!--
2
+ AUTO-GENERATED — DO NOT EDIT THIS FILE.
3
+ Generated by `mlcl sync-docs` from the package's src/index.ts JSDoc + mlcl/registry.json.
4
+ Edits here are overwritten on the next commit (molecule's pre-commit hook regenerates).
5
+ To change this document, edit the module-level JSDoc in src/index.ts.
6
+ Generated: 2026-09-18T06:08:25.367Z
7
+ -->
8
+
9
+ # @molecule/api-media-streaming-hls
10
+
11
+ > **Auto-generated, AI-first package reference** for the [molecule.dev](https://molecule.dev) ecosystem.
12
+ > It is written to be read by coding agents as much as by people, and is generated from this
13
+ > package's source — edit `src/index.ts` JSDoc, not this file.
14
+
15
+ HLS media streaming provider for molecule.dev.
16
+
17
+ Provides HLS (HTTP Live Streaming) support via ffmpeg for media segmentation
18
+ and transcoding, with pure-TypeScript M3U8 playlist generation. Requires
19
+ ffmpeg to be installed on the host system.
20
+
21
+ ## Quick Start
22
+
23
+ ```typescript
24
+ import { setProvider, createStream } from '@molecule/api-media-streaming'
25
+ import { provider } from '@molecule/api-media-streaming-hls'
26
+
27
+ setProvider(provider)
28
+
29
+ const manifest = await createStream('/path/to/video.mp4', {
30
+ segmentDuration: 6,
31
+ protocol: 'hls',
32
+ })
33
+ console.log(manifest.manifestUri) // '/hls-…/index.m3u8'
34
+ ```
35
+
36
+ ## Type
37
+
38
+ `provider`
39
+
40
+ ## Installation
41
+
42
+ ```bash
43
+ npm install @molecule/api-media-streaming-hls @molecule/api-media-streaming
44
+ ```
45
+
46
+ ## API
47
+
48
+ ### Interfaces
49
+
50
+ #### `HlsConfig`
51
+
52
+ Configuration options for the HLS streaming provider.
53
+
54
+ ```typescript
55
+ interface HlsConfig {
56
+ /** Path to the ffmpeg binary. Defaults to `'ffmpeg'` (resolved via PATH). */
57
+ ffmpegPath?: string
58
+
59
+ /**
60
+ * Path to the ffprobe binary. RESERVED for future use — the current
61
+ * provider never invokes ffprobe (segment durations are taken from
62
+ * `segmentDuration`, not probed). Setting this has no effect today.
63
+ */
64
+ ffprobePath?: string
65
+
66
+ /** Base directory where stream output files are written. Defaults to `os.tmpdir()`. */
67
+ outputBasePath?: string
68
+
69
+ /** Default segment duration in seconds. Defaults to `6`. */
70
+ segmentDuration?: number
71
+
72
+ /** HLS playlist version. Defaults to `3`. */
73
+ hlsVersion?: number
74
+ }
75
+ ```
76
+
77
+ #### `M3u8PlaylistOptions`
78
+
79
+ Options for generating an M3U8 media playlist.
80
+
81
+ ```typescript
82
+ interface M3u8PlaylistOptions {
83
+ /** HLS playlist version. Defaults to `3`. */
84
+ version?: number
85
+
86
+ /** Target segment duration in seconds. Defaults to the maximum segment duration. */
87
+ targetDuration?: number
88
+
89
+ /** Whether this is a VOD (complete) or live (in-progress) playlist. Defaults to `'vod'`. */
90
+ playlistType?: 'vod' | 'event'
91
+
92
+ /** Media sequence number for the first segment. Defaults to `0`. */
93
+ mediaSequence?: number
94
+ }
95
+ ```
96
+
97
+ ### Functions
98
+
99
+ #### `assertSafePathComponent(value, label)`
100
+
101
+ Asserts that a caller-supplied value is a safe single path component.
102
+
103
+ Rejects empty strings, the relative segments `.` and `..`, anything
104
+ containing a path separator or NUL byte, and anything outside the
105
+ `[A-Za-z0-9._-]` allow-list (which also rejects shell metacharacters).
106
+
107
+ ```typescript
108
+ function assertSafePathComponent(value: string, label: string): string
109
+ ```
110
+
111
+ - `value` — The caller-supplied component (e.g. a stream id or profile name).
112
+ - `label` — Human-readable name of the field, used in the error message.
113
+
114
+ **Returns:** The validated component, unchanged.
115
+
116
+ #### `assertSegmentIndex(index)`
117
+
118
+ Asserts that a caller-supplied segment index is a non-negative integer.
119
+
120
+ ```typescript
121
+ function assertSegmentIndex(index: number): number
122
+ ```
123
+
124
+ - `index` — The caller-supplied segment index.
125
+
126
+ **Returns:** The validated index, unchanged.
127
+
128
+ #### `createProvider(config)`
129
+
130
+ Creates an HLS streaming provider.
131
+
132
+ ```typescript
133
+ function createProvider(config?: HlsConfig): StreamingProvider
134
+ ```
135
+
136
+ - `config` — Optional provider configuration.
137
+
138
+ **Returns:** A `StreamingProvider` backed by HLS / ffmpeg.
139
+
140
+ #### `generateMasterPlaylist(variants)`
141
+
142
+ Generates an M3U8 master playlist for adaptive bitrate streaming.
143
+
144
+ ```typescript
145
+ function generateMasterPlaylist(variants: TranscodeVariant[]): string
146
+ ```
147
+
148
+ - `variants` — The transcoded variant streams.
149
+
150
+ **Returns:** The master M3U8 playlist content as a string.
151
+
152
+ #### `generateMediaPlaylist(segments, options)`
153
+
154
+ Generates an M3U8 media playlist from a list of stream segments.
155
+
156
+ ```typescript
157
+ function generateMediaPlaylist(segments: StreamSegment[], options?: M3u8PlaylistOptions): string
158
+ ```
159
+
160
+ - `segments` — Ordered list of stream segments.
161
+ - `options` — Playlist generation options.
162
+
163
+ **Returns:** The M3U8 playlist content as a string.
164
+
165
+ #### `resolveWithinBase(base, parts)`
166
+
167
+ Resolves `parts` against `base` and asserts the result stays within `base`.
168
+
169
+ Defense-in-depth on top of {@link assertSafePathComponent}: even if a
170
+ component slipped through, the resolved absolute path is rejected unless it
171
+ is `base` itself or a descendant of it.
172
+
173
+ ```typescript
174
+ function resolveWithinBase(base: string, parts?: string[]): string
175
+ ```
176
+
177
+ - `base` — The intended base directory.
178
+ - `parts` — Path segments to append.
179
+
180
+ **Returns:** The resolved absolute path, guaranteed to be inside `base`.
181
+
182
+ ### Constants
183
+
184
+ #### `provider`
185
+
186
+ The provider implementation with default configuration.
187
+
188
+ ```typescript
189
+ const provider: StreamingProvider
190
+ ```
191
+
192
+ ## Core Interface
193
+
194
+ Implements `@molecule/api-media-streaming` interface.
195
+
196
+ ## Bond Wiring
197
+
198
+ Setup function to register this provider with the core interface:
199
+
200
+ ```typescript
201
+ import { setProvider } from '@molecule/api-media-streaming'
202
+ import { provider } from '@molecule/api-media-streaming-hls'
203
+
204
+ export function setupMediaStreamingHls(): void {
205
+ setProvider(provider)
206
+ }
207
+ ```
208
+
209
+ ## Injection Notes
210
+
211
+ ### Requirements
212
+
213
+ Peer dependencies:
214
+
215
+ - `@molecule/api-media-streaming` ^1.0.1
216
+
217
+ ### Runtime Dependencies
218
+
219
+ - `@molecule/api-media-streaming`
220
+
221
+ - **String inputs must be local absolute file paths.** `createStream()` and
222
+ `transcode()` accept `Buffer | string`; a string is passed straight to
223
+ ffmpeg's `-i`, and ffmpeg natively fetches `http(s)`, `tcp`, `tls`, and
224
+ more — so an unvalidated string would make the bond an SSRF / file-read
225
+ primitive acting with your server's network position. Strings that
226
+ contain `://`, carry a `scheme:` prefix, or are not absolute paths are
227
+ rejected with a thrown error. For remote media, fetch the bytes yourself
228
+ (with your own SSRF guard) and pass a `Buffer`. As defense-in-depth the
229
+ ffmpeg protocol whitelist is trimmed to local protocols (`file,crypto`) —
230
+ network protocols are never enabled.
231
+ - **Requires the `ffmpeg` binary on the host** (resolved via PATH, or set
232
+ `createProvider({ ffmpegPath })`). A missing binary fails at first
233
+ `createStream()`/`transcode()` call with `spawn ffmpeg ENOENT` — verify
234
+ with `ffmpeg -version` before shipping.
235
+ - **The default output directory is `os.tmpdir()`** — volatile and served by
236
+ nothing. Pass `createProvider({ outputBasePath })` pointing at a directory
237
+ your server exposes (see the core remarks), or serve bytes through
238
+ `getSegment()` / `generateManifest()` endpoints.
239
+ - `createStream()` also caches every segment `Buffer` in an in-process map
240
+ (never evicted) so `getSegment()` is fast; memory grows by the full video
241
+ size per stream. `getSegment()` disk fallback looks ONLY under
242
+ `outputBasePath/<streamId>/` — a per-call `createStream(..., { outputPath })`
243
+ override writes segments where the fallback cannot find them after a
244
+ restart, and `transcode()` ignores `outputPath` entirely (always writes
245
+ under `outputBasePath`).
246
+ - `ffprobePath` in `HlsConfig` is currently RESERVED — no ffprobe call exists
247
+ yet; segment durations come from the requested `segmentDuration`.
248
+
249
+ ## E2E Tests
250
+
251
+ Integration checklist — drive the real UI (live preview, no mocks), adapt
252
+ each item to this app's actual upload/player screens, and check every box
253
+ off one by one. A box you can't check is an integration bug to fix — not a
254
+ skip. You can't judge real transcode QUALITY or a live A/V feed in the
255
+ sandbox; verify the pipeline + playback WIRING you own:
256
+
257
+ - [ ] Uploading/ingesting a media asset produces a real PLAYABLE stream: the
258
+ returned `manifestUri` (`.m3u8` for HLS / `.mpd` for DASH) loads in the
259
+ app's video player and actually plays — frames advance and the player
260
+ fetches segments (watch the network panel), never a broken/blank player.
261
+ - [ ] The stream is served from/through the APP'S OWN origin — an
262
+ `outputPath` under a directory the server exposes, or endpoints that return
263
+ `generateManifest(segments)` and stream `getSegment(streamId, index)` bytes.
264
+ The player must NOT hotlink a raw expiring provider URL, and no manifest or
265
+ segment request may 404.
266
+ - [ ] Processing STATE is observable and playback is gated on it: an asset
267
+ moves pending → processing → ready (StreamStatus), the UI reflects that,
268
+ and the player mounts only once status is 'ready' — never a dead player on
269
+ a still-transcoding asset.
270
+ - [ ] If adaptive bitrate is exposed, `transcode()` produced multiple
271
+ renditions: the master manifest (`masterManifestUri`) lists more than one
272
+ `variant` and the player can switch quality across them.
273
+ - [ ] If the app exposes a poster/thumbnail for an asset, it generates and
274
+ renders before playback (no blank tile).
275
+ - [ ] SECURITY — private media is AUTHORIZED on playback: the manifest and
276
+ segment endpoints check the requester (or hand out a signed/expiring URL),
277
+ so a user CANNOT fetch another user's stream by guessing its `id`/URL; and
278
+ provider keys stay server-side (never shipped to the client bundle).
package/dist/index.d.ts CHANGED
@@ -20,6 +20,16 @@
20
20
  * ```
21
21
  *
22
22
  * @remarks
23
+ * - **String inputs must be local absolute file paths.** `createStream()` and
24
+ * `transcode()` accept `Buffer | string`; a string is passed straight to
25
+ * ffmpeg's `-i`, and ffmpeg natively fetches `http(s)`, `tcp`, `tls`, and
26
+ * more — so an unvalidated string would make the bond an SSRF / file-read
27
+ * primitive acting with your server's network position. Strings that
28
+ * contain `://`, carry a `scheme:` prefix, or are not absolute paths are
29
+ * rejected with a thrown error. For remote media, fetch the bytes yourself
30
+ * (with your own SSRF guard) and pass a `Buffer`. As defense-in-depth the
31
+ * ffmpeg protocol whitelist is trimmed to local protocols (`file,crypto`) —
32
+ * network protocols are never enabled.
23
33
  * - **Requires the `ffmpeg` binary on the host** (resolved via PATH, or set
24
34
  * `createProvider({ ffmpegPath })`). A missing binary fails at first
25
35
  * `createStream()`/`transcode()` call with `spawn ffmpeg ENOENT` — verify
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AAEH,cAAc,oBAAoB,CAAA;AAClC,cAAc,WAAW,CAAA;AACzB,cAAc,eAAe,CAAA;AAC7B,cAAc,YAAY,CAAA;AAC1B,cAAc,eAAe,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AAEH,cAAc,oBAAoB,CAAA;AAClC,cAAc,WAAW,CAAA;AACzB,cAAc,eAAe,CAAA;AAC7B,cAAc,YAAY,CAAA;AAC1B,cAAc,eAAe,CAAA"}
package/dist/index.js CHANGED
@@ -20,6 +20,16 @@
20
20
  * ```
21
21
  *
22
22
  * @remarks
23
+ * - **String inputs must be local absolute file paths.** `createStream()` and
24
+ * `transcode()` accept `Buffer | string`; a string is passed straight to
25
+ * ffmpeg's `-i`, and ffmpeg natively fetches `http(s)`, `tcp`, `tls`, and
26
+ * more — so an unvalidated string would make the bond an SSRF / file-read
27
+ * primitive acting with your server's network position. Strings that
28
+ * contain `://`, carry a `scheme:` prefix, or are not absolute paths are
29
+ * rejected with a thrown error. For remote media, fetch the bytes yourself
30
+ * (with your own SSRF guard) and pass a `Buffer`. As defense-in-depth the
31
+ * ffmpeg protocol whitelist is trimmed to local protocols (`file,crypto`) —
32
+ * network protocols are never enabled.
23
33
  * - **Requires the `ffmpeg` binary on the host** (resolved via PATH, or set
24
34
  * `createProvider({ ffmpegPath })`). A missing binary fails at first
25
35
  * `createStream()`/`transcode()` call with `spawn ffmpeg ENOENT` — verify
@@ -1 +1 @@
1
- {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAQH,OAAO,KAAK,EACV,iBAAiB,EAOlB,MAAM,+BAA+B,CAAA;AAGtC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA;AAwD3C;;;;;GAKG;AACH,eAAO,MAAM,cAAc,GAAI,SAAQ,SAAc,KAAG,iBA8JvD,CAAA;AAED;;GAEG;AACH,eAAO,MAAM,QAAQ,EAAE,iBAAoC,CAAA"}
1
+ {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAQH,OAAO,KAAK,EACV,iBAAiB,EAOlB,MAAM,+BAA+B,CAAA;AAGtC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA;AAoG3C;;;;;GAKG;AACH,eAAO,MAAM,cAAc,GAAI,SAAQ,SAAc,KAAG,iBAmKvD,CAAA;AAED;;GAEG;AACH,eAAO,MAAM,QAAQ,EAAE,iBAAoC,CAAA"}
package/dist/provider.js CHANGED
@@ -10,11 +10,48 @@
10
10
  import { execFile } from 'node:child_process';
11
11
  import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
12
12
  import { tmpdir } from 'node:os';
13
- import { join } from 'node:path';
13
+ import { isAbsolute, join } from 'node:path';
14
14
  import { promisify } from 'node:util';
15
15
  import { generateMasterPlaylist, generateMediaPlaylist } from './m3u8.js';
16
16
  import { assertSafePathComponent, assertSegmentIndex, resolveWithinBase } from './validate.js';
17
17
  const execFileAsync = promisify(execFile);
18
+ /**
19
+ * Matches a URL scheme prefix (`scheme:`) the way ffmpeg's own protocol
20
+ * handler resolution does — including scheme-only forms like `http:host/x`
21
+ * that carry no `//`.
22
+ */
23
+ const URL_SCHEME_PREFIX = /^[a-z][a-z0-9+.-]*:/i;
24
+ /**
25
+ * Asserts that a caller-supplied string input is a LOCAL ABSOLUTE FILE PATH.
26
+ *
27
+ * String inputs are passed verbatim as ffmpeg's `-i` argument, and ffmpeg
28
+ * natively speaks `http`, `https`, `tcp`, `tls`, `concat`, `gopher`, and
29
+ * more. Forwarding an unvalidated string therefore turns this bond into an
30
+ * SSRF / file-read primitive: `http://169.254.169.254/…` (cloud metadata),
31
+ * `http://intra-host/…`, or any other URL the host can reach, executed by
32
+ * ffmpeg with the server's network position. We reject:
33
+ *
34
+ * - anything containing `://` (absolute URLs),
35
+ * - anything with a `scheme:` prefix (ffmpeg accepts `http:host/x` too),
36
+ * - anything that is not an absolute path (`/…`) — a relative path would
37
+ * resolve against ffmpeg's CWD, not the caller's.
38
+ *
39
+ * Applications that need remote media must fetch the bytes themselves (with
40
+ * their own SSRF guard) and pass a `Buffer`.
41
+ *
42
+ * Module-private: not part of the package's public export surface.
43
+ *
44
+ * @param inputPath - The caller-supplied string input.
45
+ * @returns The validated path, unchanged.
46
+ * @throws {Error} When the string is not a local absolute file path.
47
+ */
48
+ const assertLocalInputPath = (inputPath) => {
49
+ if (inputPath.includes('://') || URL_SCHEME_PREFIX.test(inputPath) || !isAbsolute(inputPath)) {
50
+ throw new Error(`Invalid media input: string inputs must be local absolute file paths (got ${JSON.stringify(inputPath)}). ` +
51
+ 'Fetch remote media yourself (with an SSRF guard) and pass a Buffer instead — ffmpeg URLs are rejected to prevent server-side request forgery.');
52
+ }
53
+ return inputPath;
54
+ };
18
55
  let streamCounter = 0;
19
56
  /**
20
57
  * Generates a unique stream identifier.
@@ -34,7 +71,10 @@ const generateStreamId = () => {
34
71
  */
35
72
  const prepareInput = async (input, dir) => {
36
73
  if (typeof input === 'string') {
37
- return input;
74
+ // SSRF guard: a string is treated as a local absolute FILE path only —
75
+ // ffmpeg speaks http/tcp/… natively, so an unvalidated string is a
76
+ // fetch-anything primitive. Remote media must arrive as a Buffer.
77
+ return assertLocalInputPath(input);
38
78
  }
39
79
  const inputPath = join(dir, 'input.tmp');
40
80
  await writeFile(inputPath, input);
@@ -78,10 +118,13 @@ export const createProvider = (config = {}) => {
78
118
  await mkdir(outputDir, { recursive: true });
79
119
  const inputPath = await prepareInput(input, outputDir);
80
120
  await execFileAsync(ffmpegPath, [
81
- // Restrict ffmpeg to safe protocols so a string input can't abuse dangerous
82
- // ones (concat/gopher/ftp/subfile/unix) for LFI/SSRF escalation. [P5BONDS DiD]
121
+ // Defense-in-depth behind the string-input SSRF guard: restrict ffmpeg
122
+ // to LOCAL protocols only (file, crypto). Network protocols (http,
123
+ // https, tcp, tls) are deliberately NOT whitelisted — a string input
124
+ // must be a local absolute file path (see assertLocalInputPath), so
125
+ // nothing legitimate ever needs network access here. [P5BONDS DiD]
83
126
  '-protocol_whitelist',
84
- 'file,crypto,data,http,https,tcp,tls',
127
+ 'file,crypto',
85
128
  '-i',
86
129
  inputPath,
87
130
  '-codec',
@@ -128,9 +171,11 @@ export const createProvider = (config = {}) => {
128
171
  await mkdir(profileDir, { recursive: true });
129
172
  const codec = profile.codec ?? 'h264';
130
173
  const args = [
131
- // Restrict ffmpeg to safe protocols (no concat/gopher/ftp/subfile). [P5BONDS DiD]
174
+ // Defense-in-depth behind the string-input SSRF guard: local
175
+ // protocols only — see the createStream whitelist comment.
176
+ // [P5BONDS DiD]
132
177
  '-protocol_whitelist',
133
- 'file,crypto,data,http,https,tcp,tls',
178
+ 'file,crypto',
134
179
  '-i',
135
180
  inputPath,
136
181
  '-c:v',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@molecule/api-media-streaming-hls",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "HLS media streaming provider for molecule.dev — ffmpeg-based segmentation, transcoding, and M3U8 playlist generation",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -17,7 +17,8 @@
17
17
  }
18
18
  },
19
19
  "files": [
20
- "dist"
20
+ "dist",
21
+ "README.md"
21
22
  ],
22
23
  "keywords": [
23
24
  "molecule",
@@ -30,13 +31,13 @@
30
31
  ],
31
32
  "license": "Apache-2.0",
32
33
  "peerDependencies": {
33
- "@molecule/api-media-streaming": "^1.0.0"
34
+ "@molecule/api-media-streaming": "^1.0.1"
34
35
  },
35
36
  "devDependencies": {
36
- "@molecule/api-media-streaming": "1.0.0",
37
+ "@molecule/api-media-streaming": "1.0.1",
37
38
  "@types/node": "26.1.2",
38
39
  "typescript": "6.0.3",
39
- "vitest": "4.1.10"
40
+ "vitest": "4.1.11"
40
41
  },
41
42
  "repository": {
42
43
  "type": "git",