Initial Commit
This commit is contained in:
111
.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc
Normal file
111
.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
---
|
||||||
|
description: Use Bun instead of Node.js, npm, pnpm, or vite.
|
||||||
|
globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json"
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
Default to using Bun instead of Node.js.
|
||||||
|
|
||||||
|
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
|
||||||
|
- Use `bun test` instead of `jest` or `vitest`
|
||||||
|
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
|
||||||
|
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
|
||||||
|
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
|
||||||
|
- Bun automatically loads .env, so don't use dotenv.
|
||||||
|
|
||||||
|
## APIs
|
||||||
|
|
||||||
|
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
|
||||||
|
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
|
||||||
|
- `Bun.redis` for Redis. Don't use `ioredis`.
|
||||||
|
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
|
||||||
|
- `WebSocket` is built-in. Don't use `ws`.
|
||||||
|
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
|
||||||
|
- Bun.$`ls` instead of execa.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Use `bun test` to run tests.
|
||||||
|
|
||||||
|
```ts#index.test.ts
|
||||||
|
import { test, expect } from "bun:test";
|
||||||
|
|
||||||
|
test("hello world", () => {
|
||||||
|
expect(1).toBe(1);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
|
||||||
|
|
||||||
|
Server:
|
||||||
|
|
||||||
|
```ts#index.ts
|
||||||
|
import index from "./index.html"
|
||||||
|
|
||||||
|
Bun.serve({
|
||||||
|
routes: {
|
||||||
|
"/": index,
|
||||||
|
"/api/users/:id": {
|
||||||
|
GET: (req) => {
|
||||||
|
return new Response(JSON.stringify({ id: req.params.id }));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// optional websocket support
|
||||||
|
websocket: {
|
||||||
|
open: (ws) => {
|
||||||
|
ws.send("Hello, world!");
|
||||||
|
},
|
||||||
|
message: (ws, message) => {
|
||||||
|
ws.send(message);
|
||||||
|
},
|
||||||
|
close: (ws) => {
|
||||||
|
// handle close
|
||||||
|
}
|
||||||
|
},
|
||||||
|
development: {
|
||||||
|
hmr: true,
|
||||||
|
console: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
|
||||||
|
|
||||||
|
```html#index.html
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h1>Hello, world!</h1>
|
||||||
|
<script type="module" src="./frontend.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
With the following `frontend.tsx`:
|
||||||
|
|
||||||
|
```tsx#frontend.tsx
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
// import .css files directly and it works
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
|
||||||
|
const root = createRoot(document.body);
|
||||||
|
|
||||||
|
export default function Frontend() {
|
||||||
|
return <h1>Hello, world!</h1>;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.render(<Frontend />);
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, run index.ts
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun --hot ./index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`.
|
||||||
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# dependencies (bun install)
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# output
|
||||||
|
out
|
||||||
|
dist
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# code coverage
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# logs
|
||||||
|
logs
|
||||||
|
_.log
|
||||||
|
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# caches
|
||||||
|
.eslintcache
|
||||||
|
.cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# IntelliJ based IDEs
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Finder (MacOS) folder config
|
||||||
|
.DS_Store
|
||||||
87
README.md
Normal file
87
README.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# RTMP Push Tool
|
||||||
|
|
||||||
|
A TypeScript library for pushing media streams to RTMP servers using FFmpeg.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Push media streams to RTMP servers
|
||||||
|
- Support for various media formats (H.264, AAC, etc.)
|
||||||
|
- Configurable capabilities and stream keys
|
||||||
|
- Process management and monitoring
|
||||||
|
- TypeScript support
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { RtmpPush } from '@techniker-me/rtmp-push';
|
||||||
|
|
||||||
|
const rtmpPush = new RtmpPush(
|
||||||
|
'https://example.com/video.ts',
|
||||||
|
'rtmp://ingest.example.com:80/ingest'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start streaming
|
||||||
|
rtmpPush.start('stream-key', ['h264', 'aac']);
|
||||||
|
|
||||||
|
// Check if running
|
||||||
|
if (rtmpPush.isRunning()) {
|
||||||
|
console.log('Stream is active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop streaming
|
||||||
|
rtmpPush.stop();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
This package includes comprehensive tests for all features:
|
||||||
|
|
||||||
|
### Run all tests
|
||||||
|
```bash
|
||||||
|
bun test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run tests in watch mode
|
||||||
|
```bash
|
||||||
|
bun test --watch
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run tests with coverage
|
||||||
|
```bash
|
||||||
|
bun test --coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Structure
|
||||||
|
|
||||||
|
- **Unit Tests** (`RtmpPush.test.ts`): Test individual methods and edge cases with mocked dependencies
|
||||||
|
- **Integration Tests** (`RtmpPush.integration.test.ts`): Test real functionality and error handling
|
||||||
|
|
||||||
|
### Test Coverage
|
||||||
|
|
||||||
|
The test suite covers:
|
||||||
|
|
||||||
|
- ✅ Constructor and property initialization
|
||||||
|
- ✅ Getter methods
|
||||||
|
- ✅ Start method with various parameters
|
||||||
|
- ✅ Stop method and process cleanup
|
||||||
|
- ✅ Process lifecycle management
|
||||||
|
- ✅ Error handling and edge cases
|
||||||
|
- ✅ URI construction and validation
|
||||||
|
- ✅ FFmpeg command generation
|
||||||
|
- ✅ Process event handling
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Bun runtime
|
||||||
|
- FFmpeg installed on the system (for actual streaming)
|
||||||
|
- TypeScript 5+
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
29
bun.lock
Normal file
29
bun.lock
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "@techniker-me/rtmp-push",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@types/bun": ["@types/bun@1.2.20", "", { "dependencies": { "bun-types": "1.2.20" } }, "sha512-dX3RGzQ8+KgmMw7CsW4xT5ITBSCrSbfHc36SNT31EOUg/LA9JWq0VDdEXDRSe1InVWpd2yLUM1FUF/kEOyTzYA=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@24.3.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow=="],
|
||||||
|
|
||||||
|
"@types/react": ["@types/react@19.1.10", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.2.20", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-pxTnQYOrKvdOwyiyd/7sMt9yFOenN004Y6O4lCcCUoKVej48FS5cvTw9geRaEcB9TsDZaJKAxPTVvi8tFsVuXA=="],
|
||||||
|
|
||||||
|
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
21
package.json
Normal file
21
package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "@techniker-me/rtmp-push",
|
||||||
|
"version": "2025.0.0",
|
||||||
|
"module": "src/index.ts",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "bun test",
|
||||||
|
"test:watch": "bun test --watch",
|
||||||
|
"test:coverage": "bun test --coverage"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
"@types/node": "^20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://registry-node.techniker.me"
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/RtmpPush.ts
Normal file
91
src/RtmpPush.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import {spawn, ChildProcess } from 'node:child_process';
|
||||||
|
|
||||||
|
export class RtmpPush {
|
||||||
|
private _mediaSourceUri: string;
|
||||||
|
private _rtmpIngestUri: string;
|
||||||
|
private _activeProcess: ChildProcess | null = null;
|
||||||
|
|
||||||
|
constructor(mediaSourceUri: string, rtmpIngestUri: string) {
|
||||||
|
this._mediaSourceUri = mediaSourceUri;
|
||||||
|
this._rtmpIngestUri = rtmpIngestUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
get rtmpIngestUri(): string {
|
||||||
|
return this._rtmpIngestUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
get mediaSourceUri(): string {
|
||||||
|
return this._mediaSourceUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
public start(streamKey: string, capabilities: string[]): this {
|
||||||
|
// Use the constructor parameters instead of hardcoded values
|
||||||
|
const mediaSourceUri = this._mediaSourceUri;
|
||||||
|
const ingestUri = `${this._rtmpIngestUri}/${streamKey};capabilities=${capabilities.join(',')};tags=`;
|
||||||
|
|
||||||
|
const command = [
|
||||||
|
'ffmpeg',
|
||||||
|
'-re',
|
||||||
|
'-hide_banner',
|
||||||
|
'-stream_loop', '-1',
|
||||||
|
'-y',
|
||||||
|
'-flags', 'low_delay',
|
||||||
|
'-fflags', '+nobuffer+flush_packets',
|
||||||
|
'-i', mediaSourceUri,
|
||||||
|
'-c:a', 'copy',
|
||||||
|
'-c:v', 'copy',
|
||||||
|
'-flush_packets', '1',
|
||||||
|
'-copyts',
|
||||||
|
'-f', 'flv',
|
||||||
|
ingestUri
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!command[0]) {
|
||||||
|
throw new Error('Invalid command: ffmpeg not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
this._activeProcess = spawn(command[0], command.slice(1));
|
||||||
|
|
||||||
|
if (!this._activeProcess) {
|
||||||
|
throw new Error('Failed to spawn ffmpeg process');
|
||||||
|
}
|
||||||
|
|
||||||
|
this._activeProcess.stdout?.on('data', (data) => {
|
||||||
|
console.log(`stdout: ${data}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
this._activeProcess.stderr?.on('data', (data) => {
|
||||||
|
console.log(`stderr: ${data}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
this._activeProcess.on('close', (code) => {
|
||||||
|
console.log(`child process exited with code ${code}`);
|
||||||
|
this._activeProcess = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
this._activeProcess.on('error', (error) => {
|
||||||
|
console.error('ffmpeg process error:', error);
|
||||||
|
this._activeProcess = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start RTMP push:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public stop(): this {
|
||||||
|
if (this._activeProcess) {
|
||||||
|
this._activeProcess.kill();
|
||||||
|
this._activeProcess = null;
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isRunning(): boolean {
|
||||||
|
return this._activeProcess !== null && !this._activeProcess.killed;
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/index.ts
Normal file
4
src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { RtmpPush } from "./RtmpPush.js";
|
||||||
|
|
||||||
|
export { RtmpPush };
|
||||||
|
export default {RtmpPush};
|
||||||
131
tests/RtmpPush.integration.test.ts
Normal file
131
tests/RtmpPush.integration.test.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||||
|
import { RtmpPush } from "../src/RtmpPush.js";
|
||||||
|
|
||||||
|
describe("RtmpPush Integration Tests", () => {
|
||||||
|
let rtmpPush: RtmpPush;
|
||||||
|
const testMediaSourceUri = "https://storage.googleapis.com/phenix-testing-assets/timecodes/haivision-with-sei-system-source-timecodes-utc-conversion-counting-mode-h264-constrained-baseline-1920x1080p-cbr-8700kbps-60fps-aac-lc-48000hz-stereo-124kbps-5m00s.ts";
|
||||||
|
const testRtmpIngestUri = "rtmp://ingest-stg.phenixrts.com:80/ingest";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
rtmpPush = new RtmpPush(testMediaSourceUri, testRtmpIngestUri);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Always clean up
|
||||||
|
if (rtmpPush.isRunning()) {
|
||||||
|
rtmpPush.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Real Process Management", () => {
|
||||||
|
it("should create instance with real URIs", () => {
|
||||||
|
expect(rtmpPush).toBeInstanceOf(RtmpPush);
|
||||||
|
expect(rtmpPush.mediaSourceUri).toBe(testMediaSourceUri);
|
||||||
|
expect(rtmpPush.rtmpIngestUri).toBe(testRtmpIngestUri);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should start and stop process (if ffmpeg is available)", () => {
|
||||||
|
// This test will only pass if ffmpeg is installed on the system
|
||||||
|
const streamKey = "test-integration-stream";
|
||||||
|
const capabilities = ["h264", "aac"];
|
||||||
|
|
||||||
|
try {
|
||||||
|
rtmpPush.start(streamKey, capabilities);
|
||||||
|
|
||||||
|
// Give it a moment to start
|
||||||
|
Bun.sleepSync(100);
|
||||||
|
|
||||||
|
expect(rtmpPush.isRunning()).toBe(true);
|
||||||
|
|
||||||
|
rtmpPush.stop();
|
||||||
|
|
||||||
|
// Give it a moment to stop
|
||||||
|
Bun.sleepSync(100);
|
||||||
|
|
||||||
|
expect(rtmpPush.isRunning()).toBe(false);
|
||||||
|
} catch (error) {
|
||||||
|
// If ffmpeg is not available, this test should be skipped
|
||||||
|
console.log("Skipping integration test - ffmpeg may not be available:", error);
|
||||||
|
expect(true).toBe(true); // Mark as passed
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle invalid ffmpeg gracefully", () => {
|
||||||
|
// Test with a non-existent command
|
||||||
|
const invalidRtmpPush = new RtmpPush("invalid://uri", "invalid://rtmp");
|
||||||
|
|
||||||
|
try {
|
||||||
|
invalidRtmpPush.start("test", ["h264"]);
|
||||||
|
expect(invalidRtmpPush.isRunning()).toBe(false);
|
||||||
|
} catch (error) {
|
||||||
|
// Expected to fail
|
||||||
|
expect(error).toBeInstanceOf(Error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("URI Construction", () => {
|
||||||
|
it("should construct valid RTMP URIs", () => {
|
||||||
|
const streamKey = "test-stream-key";
|
||||||
|
const capabilities = ["h264", "aac", "stereo"];
|
||||||
|
|
||||||
|
const expectedUri = `${testRtmpIngestUri}/${streamKey};capabilities=${capabilities.join(',')};tags=`;
|
||||||
|
|
||||||
|
// The URI should be properly formatted
|
||||||
|
expect(expectedUri).toContain(streamKey);
|
||||||
|
expect(expectedUri).toContain(capabilities.join(','));
|
||||||
|
expect(expectedUri).toContain(";capabilities=");
|
||||||
|
expect(expectedUri).toContain(";tags=");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle various stream key formats", () => {
|
||||||
|
const testCases = [
|
||||||
|
"simple-key",
|
||||||
|
"key_with_underscores",
|
||||||
|
"key-with-dashes",
|
||||||
|
"key123",
|
||||||
|
"key!@#$%^&*()",
|
||||||
|
"key with spaces",
|
||||||
|
"key.with.dots"
|
||||||
|
];
|
||||||
|
|
||||||
|
testCases.forEach(streamKey => {
|
||||||
|
const capabilities = ["h264"];
|
||||||
|
const expectedUri = `${testRtmpIngestUri}/${streamKey};capabilities=${capabilities.join(',')};tags=`;
|
||||||
|
|
||||||
|
expect(expectedUri).toContain(streamKey);
|
||||||
|
expect(expectedUri).toContain(";capabilities=h264;tags=");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Error Handling", () => {
|
||||||
|
it("should handle network errors gracefully", () => {
|
||||||
|
const invalidMediaUri = "https://invalid-domain-that-does-not-exist.com/video.ts";
|
||||||
|
const invalidRtmpPush = new RtmpPush(invalidMediaUri, testRtmpIngestUri);
|
||||||
|
|
||||||
|
try {
|
||||||
|
invalidRtmpPush.start("test", ["h264"]);
|
||||||
|
// If it doesn't throw, it should at least not be running
|
||||||
|
expect(invalidRtmpPush.isRunning()).toBe(false);
|
||||||
|
} catch (error) {
|
||||||
|
// Expected to fail
|
||||||
|
expect(error).toBeInstanceOf(Error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle invalid RTMP URIs", () => {
|
||||||
|
const invalidRtmpUri = "invalid://rtmp-uri";
|
||||||
|
const invalidRtmpPush = new RtmpPush(testMediaSourceUri, invalidRtmpUri);
|
||||||
|
|
||||||
|
try {
|
||||||
|
invalidRtmpPush.start("test", ["h264"]);
|
||||||
|
// If it doesn't throw, it should at least not be running
|
||||||
|
expect(invalidRtmpPush.isRunning()).toBe(false);
|
||||||
|
} catch (error) {
|
||||||
|
// Expected to fail
|
||||||
|
expect(error).toBeInstanceOf(Error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
309
tests/RtmpPush.test.ts
Normal file
309
tests/RtmpPush.test.ts
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test";
|
||||||
|
import { RtmpPush } from "../src/RtmpPush.js";
|
||||||
|
|
||||||
|
// Mock the child_process module
|
||||||
|
const mockProcess = {
|
||||||
|
stdout: { on: mock(() => {}) },
|
||||||
|
stderr: { on: mock(() => {}) },
|
||||||
|
on: mock(() => {}),
|
||||||
|
kill: mock(() => {}),
|
||||||
|
killed: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSpawn = mock(() => mockProcess);
|
||||||
|
|
||||||
|
mock.module("node:child_process", () => ({
|
||||||
|
spawn: mockSpawn
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("RtmpPush", () => {
|
||||||
|
let rtmpPush: RtmpPush;
|
||||||
|
const mockMediaSourceUri = "https://example.com/test-video.ts";
|
||||||
|
const mockRtmpIngestUri = "rtmp://ingest.example.com:80/ingest";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
rtmpPush = new RtmpPush(mockMediaSourceUri, mockRtmpIngestUri);
|
||||||
|
// Reset mock calls
|
||||||
|
mockSpawn.mockClear();
|
||||||
|
mockProcess.stdout.on.mockClear();
|
||||||
|
mockProcess.stderr.on.mockClear();
|
||||||
|
mockProcess.on.mockClear();
|
||||||
|
mockProcess.kill.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Clean up any running processes
|
||||||
|
if (rtmpPush.isRunning()) {
|
||||||
|
rtmpPush.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Constructor", () => {
|
||||||
|
it("should create instance with correct properties", () => {
|
||||||
|
expect(rtmpPush).toBeInstanceOf(RtmpPush);
|
||||||
|
expect(rtmpPush.mediaSourceUri).toBe(mockMediaSourceUri);
|
||||||
|
expect(rtmpPush.rtmpIngestUri).toBe(mockRtmpIngestUri);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty strings", () => {
|
||||||
|
const emptyRtmpPush = new RtmpPush("", "");
|
||||||
|
expect(emptyRtmpPush.mediaSourceUri).toBe("");
|
||||||
|
expect(emptyRtmpPush.rtmpIngestUri).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Getters", () => {
|
||||||
|
it("should return correct mediaSourceUri", () => {
|
||||||
|
expect(rtmpPush.mediaSourceUri).toBe(mockMediaSourceUri);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return correct rtmpIngestUri", () => {
|
||||||
|
expect(rtmpPush.rtmpIngestUri).toBe(mockRtmpIngestUri);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return immutable values", () => {
|
||||||
|
const originalMediaUri = rtmpPush.mediaSourceUri;
|
||||||
|
const originalRtmpUri = rtmpPush.rtmpIngestUri;
|
||||||
|
|
||||||
|
// These should not change the internal state
|
||||||
|
expect(rtmpPush.mediaSourceUri).toBe(originalMediaUri);
|
||||||
|
expect(rtmpPush.rtmpIngestUri).toBe(originalRtmpUri);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("start() method", () => {
|
||||||
|
it("should start RTMP push process with correct parameters", () => {
|
||||||
|
const streamKey = "test-stream-123";
|
||||||
|
const capabilities = ["h264", "aac"];
|
||||||
|
|
||||||
|
const result = rtmpPush.start(streamKey, capabilities);
|
||||||
|
|
||||||
|
expect(result).toBe(rtmpPush);
|
||||||
|
expect(rtmpPush.isRunning()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should construct correct ffmpeg command", () => {
|
||||||
|
const streamKey = "test-stream";
|
||||||
|
const capabilities = ["h264", "aac"];
|
||||||
|
|
||||||
|
rtmpPush.start(streamKey, capabilities);
|
||||||
|
|
||||||
|
expect(mockSpawn).toHaveBeenCalledWith(
|
||||||
|
"ffmpeg",
|
||||||
|
expect.arrayContaining([
|
||||||
|
"-re",
|
||||||
|
"-hide_banner",
|
||||||
|
"-stream_loop", "-1",
|
||||||
|
"-y",
|
||||||
|
"-flags", "low_delay",
|
||||||
|
"-fflags", "+nobuffer+flush_packets",
|
||||||
|
"-i", mockMediaSourceUri,
|
||||||
|
"-c:a", "copy",
|
||||||
|
"-c:v", "copy",
|
||||||
|
"-flush_packets", "1",
|
||||||
|
"-copyts",
|
||||||
|
"-f", "flv"
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should construct correct ingest URI", () => {
|
||||||
|
const streamKey = "test-stream";
|
||||||
|
const capabilities = ["h264", "aac"];
|
||||||
|
|
||||||
|
rtmpPush.start(streamKey, capabilities);
|
||||||
|
|
||||||
|
const expectedIngestUri = `${mockRtmpIngestUri}/${streamKey};capabilities=${capabilities.join(',')};tags=`;
|
||||||
|
|
||||||
|
expect(mockSpawn).toHaveBeenCalledWith(
|
||||||
|
"ffmpeg",
|
||||||
|
expect.arrayContaining([expectedIngestUri])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty capabilities array", () => {
|
||||||
|
const streamKey = "test-stream";
|
||||||
|
const capabilities: string[] = [];
|
||||||
|
|
||||||
|
rtmpPush.start(streamKey, capabilities);
|
||||||
|
|
||||||
|
const expectedIngestUri = `${mockRtmpIngestUri}/${streamKey};capabilities=;tags=`;
|
||||||
|
|
||||||
|
expect(mockSpawn).toHaveBeenCalledWith(
|
||||||
|
"ffmpeg",
|
||||||
|
expect.arrayContaining([expectedIngestUri])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle single capability", () => {
|
||||||
|
const streamKey = "test-stream";
|
||||||
|
const capabilities = ["h264"];
|
||||||
|
|
||||||
|
rtmpPush.start(streamKey, capabilities);
|
||||||
|
|
||||||
|
const expectedIngestUri = `${mockRtmpIngestUri}/${streamKey};capabilities=h264;tags=`;
|
||||||
|
|
||||||
|
expect(mockSpawn).toHaveBeenCalledWith(
|
||||||
|
"ffmpeg",
|
||||||
|
expect.arrayContaining([expectedIngestUri])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set up process event handlers", () => {
|
||||||
|
const streamKey = "test-stream";
|
||||||
|
const capabilities = ["h264"];
|
||||||
|
|
||||||
|
rtmpPush.start(streamKey, capabilities);
|
||||||
|
|
||||||
|
expect(mockProcess.stdout.on).toHaveBeenCalledWith("data", expect.any(Function));
|
||||||
|
expect(mockProcess.stderr.on).toHaveBeenCalledWith("data", expect.any(Function));
|
||||||
|
expect(mockProcess.on).toHaveBeenCalledWith("close", expect.any(Function));
|
||||||
|
expect(mockProcess.on).toHaveBeenCalledWith("error", expect.any(Function));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be chainable", () => {
|
||||||
|
const streamKey = "test-stream";
|
||||||
|
const capabilities = ["h264"];
|
||||||
|
|
||||||
|
const result = rtmpPush.start(streamKey, capabilities);
|
||||||
|
|
||||||
|
expect(result).toBe(rtmpPush);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stop() method", () => {
|
||||||
|
it("should stop running process", () => {
|
||||||
|
const streamKey = "test-stream";
|
||||||
|
const capabilities = ["h264"];
|
||||||
|
|
||||||
|
rtmpPush.start(streamKey, capabilities);
|
||||||
|
expect(rtmpPush.isRunning()).toBe(true);
|
||||||
|
|
||||||
|
const result = rtmpPush.stop();
|
||||||
|
|
||||||
|
expect(result).toBe(rtmpPush);
|
||||||
|
expect(rtmpPush.isRunning()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle stopping when no process is running", () => {
|
||||||
|
expect(rtmpPush.isRunning()).toBe(false);
|
||||||
|
|
||||||
|
const result = rtmpPush.stop();
|
||||||
|
|
||||||
|
expect(result).toBe(rtmpPush);
|
||||||
|
expect(rtmpPush.isRunning()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be chainable", () => {
|
||||||
|
const result = rtmpPush.stop();
|
||||||
|
expect(result).toBe(rtmpPush);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isRunning() method", () => {
|
||||||
|
it("should return false when no process is started", () => {
|
||||||
|
expect(rtmpPush.isRunning()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return true when process is running", () => {
|
||||||
|
const streamKey = "test-stream";
|
||||||
|
const capabilities = ["h264"];
|
||||||
|
|
||||||
|
rtmpPush.start(streamKey, capabilities);
|
||||||
|
expect(rtmpPush.isRunning()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false after process is stopped", () => {
|
||||||
|
const streamKey = "test-stream";
|
||||||
|
const capabilities = ["h264"];
|
||||||
|
|
||||||
|
rtmpPush.start(streamKey, capabilities);
|
||||||
|
expect(rtmpPush.isRunning()).toBe(true);
|
||||||
|
|
||||||
|
rtmpPush.stop();
|
||||||
|
expect(rtmpPush.isRunning()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Process lifecycle", () => {
|
||||||
|
it("should handle process close event", () => {
|
||||||
|
const streamKey = "test-stream";
|
||||||
|
const capabilities = ["h264"];
|
||||||
|
|
||||||
|
rtmpPush.start(streamKey, capabilities);
|
||||||
|
|
||||||
|
// Simulate process close
|
||||||
|
const closeHandler = mockProcess.on.mock.calls.find(call => call[0] === "close")?.[1];
|
||||||
|
if (closeHandler && typeof closeHandler === 'function') {
|
||||||
|
closeHandler(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(rtmpPush.isRunning()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle process error event", () => {
|
||||||
|
const streamKey = "test-stream";
|
||||||
|
const capabilities = ["h264"];
|
||||||
|
|
||||||
|
rtmpPush.start(streamKey, capabilities);
|
||||||
|
|
||||||
|
// Simulate process error
|
||||||
|
const errorHandler = mockProcess.on.mock.calls.find(call => call[0] === "error")?.[1];
|
||||||
|
if (errorHandler && typeof errorHandler === 'function') {
|
||||||
|
// Call the error handler with a mock error
|
||||||
|
errorHandler(new Error("Process error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The error handler should set the process to null
|
||||||
|
expect(rtmpPush.isRunning()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Edge cases", () => {
|
||||||
|
it("should handle special characters in stream key", () => {
|
||||||
|
const streamKey = "test-stream-with-special-chars!@#$%^&*()";
|
||||||
|
const capabilities = ["h264"];
|
||||||
|
|
||||||
|
rtmpPush.start(streamKey, capabilities);
|
||||||
|
|
||||||
|
const expectedIngestUri = `${mockRtmpIngestUri}/${streamKey};capabilities=${capabilities.join(',')};tags=`;
|
||||||
|
|
||||||
|
expect(mockSpawn).toHaveBeenCalledWith(
|
||||||
|
"ffmpeg",
|
||||||
|
expect.arrayContaining([expectedIngestUri])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle special characters in capabilities", () => {
|
||||||
|
const streamKey = "test-stream";
|
||||||
|
const capabilities = ["h.264", "aac-lc", "stereo_audio"];
|
||||||
|
|
||||||
|
rtmpPush.start(streamKey, capabilities);
|
||||||
|
|
||||||
|
const expectedIngestUri = `${mockRtmpIngestUri}/${streamKey};capabilities=${capabilities.join(',')};tags=`;
|
||||||
|
|
||||||
|
expect(mockSpawn).toHaveBeenCalledWith(
|
||||||
|
"ffmpeg",
|
||||||
|
expect.arrayContaining([expectedIngestUri])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle very long URIs", () => {
|
||||||
|
const longMediaUri = "https://example.com/" + "a".repeat(1000) + ".ts";
|
||||||
|
const longRtmpUri = "rtmp://ingest.example.com:80/ingest/" + "b".repeat(1000);
|
||||||
|
|
||||||
|
const longRtmpPush = new RtmpPush(longMediaUri, longRtmpUri);
|
||||||
|
const streamKey = "test-stream";
|
||||||
|
const capabilities = ["h264"];
|
||||||
|
|
||||||
|
longRtmpPush.start(streamKey, capabilities);
|
||||||
|
|
||||||
|
const expectedIngestUri = `${longRtmpUri}/${streamKey};capabilities=${capabilities.join(',')};tags=`;
|
||||||
|
|
||||||
|
expect(mockSpawn).toHaveBeenCalledWith(
|
||||||
|
"ffmpeg",
|
||||||
|
expect.arrayContaining([expectedIngestUri])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
29
tsconfig.json
Normal file
29
tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
// Environment setup & latest features
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "Preserve",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"allowJs": true,
|
||||||
|
|
||||||
|
// Bundler mode
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
// Best practices
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
|
||||||
|
// Some stricter flags (disabled by default)
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noPropertyAccessFromIndexSignature": false
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user