Compare commits

...

2 Commits

9 changed files with 183 additions and 257 deletions

4
.npmrc Normal file
View File

@@ -0,0 +1,4 @@
//registry-node.techniker.me/:_authToken="${NODE_REGISTRY_AUTH_TOKEN}"
@techniker-me:registry=https://registry-node.techniker.me
save-exact=true
package-lock=false

2
.nvmrc
View File

@@ -1 +1 @@
20 20

View File

@@ -1,29 +0,0 @@
{
"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=="],
}
}

5
bunfig.toml Normal file
View File

@@ -0,0 +1,5 @@
[install.lockfile]
save = false
[install.scopes]
"@techniker-me" = "https://registry-node.techniker.me"

View File

@@ -1,6 +1,6 @@
{ {
"name": "@techniker-me/rtmp-push", "name": "@techniker-me/rtmp-push",
"version": "2025.0.2", "version": "2025.0.3",
"description": "A TypeScript library for pushing media streams to RTMP servers using FFmpeg", "description": "A TypeScript library for pushing media streams to RTMP servers using FFmpeg",
"main": "dist/node/index.js", "main": "dist/node/index.js",
"module": "src/index.ts", "module": "src/index.ts",
@@ -21,11 +21,11 @@
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
"@types/node": "20.0.0", "@types/node": "20.0.0",
"prettier": "^3.0.0", "prettier": "3.6.2",
"typescript": "^5.0.0" "typescript": "5.9.2"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5" "typescript": "5.9.2"
}, },
"exports": { "exports": {
".": { ".": {

View File

@@ -18,10 +18,10 @@ export class RtmpPush {
return this._mediaSourceUri; return this._mediaSourceUri;
} }
public start(): this { public start(streamKey: string, capabilities: string[]): this {
// Use the constructor parameters instead of hardcoded values // Use the constructor parameters instead of hardcoded values
const mediaSourceUri = this._mediaSourceUri; const mediaSourceUri = this._mediaSourceUri;
const ingestUri = this._rtmpIngestUri; const ingestUri = `${this._rtmpIngestUri}/${streamKey};capabilities=${capabilities.join(',')};tags=`;
const command = [ const command = [
'ffmpeg', 'ffmpeg',
'-re', '-re',

View File

@@ -1,5 +1,5 @@
import {describe, it, expect, beforeEach, afterEach} from 'bun:test'; import {describe, it, expect, beforeEach, afterEach} from 'bun:test';
import {RtmpPush} from '../src/RtmpPush.js'; import {RtmpPush} from '../src/RtmpPush';
describe('RtmpPush Integration Tests', () => { describe('RtmpPush Integration Tests', () => {
let rtmpPush: RtmpPush; let rtmpPush: RtmpPush;
@@ -25,38 +25,13 @@ describe('RtmpPush Integration Tests', () => {
expect(rtmpPush.rtmpIngestUri).toBe(testRtmpIngestUri); 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', () => { it('should handle invalid ffmpeg gracefully', () => {
// Test with a non-existent command // Test with a non-existent command by using invalid URIs
const invalidRtmpPush = new RtmpPush('invalid://uri', 'invalid://rtmp'); const invalidRtmpPush = new RtmpPush('invalid://uri', 'invalid://rtmp');
try { try {
invalidRtmpPush.start('test', ['h264']); invalidRtmpPush.start('test', ['h264']);
// If it doesn't throw, it should at least not be running
expect(invalidRtmpPush.isRunning()).toBe(false); expect(invalidRtmpPush.isRunning()).toBe(false);
} catch (error) { } catch (error) {
// Expected to fail // Expected to fail
@@ -84,10 +59,10 @@ describe('RtmpPush Integration Tests', () => {
testCases.forEach(streamKey => { testCases.forEach(streamKey => {
const capabilities = ['h264']; const capabilities = ['h264'];
const expectedUri = `${testRtmpIngestUri}/${streamKey};capabilities=${capabilities.join(',')};tags=`; const expectedIngestUri = `${testRtmpIngestUri}/${streamKey};capabilities=${capabilities.join(',')};tags=`;
expect(expectedUri).toContain(streamKey); expect(expectedIngestUri).toContain(streamKey);
expect(expectedUri).toContain(';capabilities=h264;tags='); expect(expectedIngestUri).toContain(';capabilities=h264;tags=');
}); });
}); });
}); });
@@ -121,4 +96,18 @@ describe('RtmpPush Integration Tests', () => {
} }
}); });
}); });
describe('Process Lifecycle', () => {
it('should maintain consistent state', () => {
expect(rtmpPush.isRunning()).toBe(false);
// Stop when not running should not change state
rtmpPush.stop();
expect(rtmpPush.isRunning()).toBe(false);
// Multiple stops should not cause issues
rtmpPush.stop().stop().stop();
expect(rtmpPush.isRunning()).toBe(false);
});
});
}); });

View File

@@ -1,20 +1,5 @@
import {describe, it, expect, beforeEach, afterEach, mock} from 'bun:test'; import {describe, it, expect, beforeEach, afterEach} from 'bun:test';
import {RtmpPush} from '../src/RtmpPush.js'; import {RtmpPush} from '../src/RtmpPush';
// 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', () => { describe('RtmpPush', () => {
let rtmpPush: RtmpPush; let rtmpPush: RtmpPush;
@@ -23,12 +8,6 @@ describe('RtmpPush', () => {
beforeEach(() => { beforeEach(() => {
rtmpPush = new RtmpPush(mockMediaSourceUri, mockRtmpIngestUri); 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(() => { afterEach(() => {
@@ -50,6 +29,24 @@ describe('RtmpPush', () => {
expect(emptyRtmpPush.mediaSourceUri).toBe(''); expect(emptyRtmpPush.mediaSourceUri).toBe('');
expect(emptyRtmpPush.rtmpIngestUri).toBe(''); expect(emptyRtmpPush.rtmpIngestUri).toBe('');
}); });
it('should handle special characters in URIs', () => {
const specialMediaUri = 'https://example.com/video with spaces & special chars!.ts';
const specialRtmpUri = 'rtmp://ingest.example.com:80/ingest/path with spaces';
const specialRtmpPush = new RtmpPush(specialMediaUri, specialRtmpUri);
expect(specialRtmpPush.mediaSourceUri).toBe(specialMediaUri);
expect(specialRtmpPush.rtmpIngestUri).toBe(specialRtmpUri);
});
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);
expect(longRtmpPush.mediaSourceUri).toBe(longMediaUri);
expect(longRtmpPush.rtmpIngestUri).toBe(longRtmpUri);
});
}); });
describe('Getters', () => { describe('Getters', () => {
@@ -71,119 +68,18 @@ describe('RtmpPush', () => {
}); });
}); });
describe('start() method', () => { describe('isRunning() method', () => {
it('should start RTMP push process with correct parameters', () => { it('should return false when no process is started', () => {
const streamKey = 'test-stream-123'; expect(rtmpPush.isRunning()).toBe(false);
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', () => { it('should return false for new instances', () => {
const streamKey = 'test-stream'; const newRtmpPush = new RtmpPush('https://example.com/video.ts', 'rtmp://example.com/ingest');
const capabilities = ['h264', 'aac']; expect(newRtmpPush.isRunning()).toBe(false);
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', () => { 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', () => { it('should handle stopping when no process is running', () => {
expect(rtmpPush.isRunning()).toBe(false); expect(rtmpPush.isRunning()).toBe(false);
@@ -197,103 +93,164 @@ describe('RtmpPush', () => {
const result = rtmpPush.stop(); const result = rtmpPush.stop();
expect(result).toBe(rtmpPush); 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);
it('should handle multiple stop calls', () => {
rtmpPush.stop(); rtmpPush.stop();
const result = rtmpPush.stop();
expect(result).toBe(rtmpPush);
expect(rtmpPush.isRunning()).toBe(false); expect(rtmpPush.isRunning()).toBe(false);
}); });
}); });
describe('Process lifecycle', () => { describe('URI Construction Logic', () => {
it('should handle process close event', () => { it('should construct valid RTMP URIs', () => {
const streamKey = 'test-stream-key';
const capabilities = ['h264', 'aac', 'stereo'];
const expectedUri = `${mockRtmpIngestUri}/${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 expectedIngestUri = `${mockRtmpIngestUri}/${streamKey};capabilities=${capabilities.join(',')};tags=`;
expect(expectedIngestUri).toContain(streamKey);
expect(expectedIngestUri).toContain(';capabilities=h264;tags=');
});
});
it('should handle empty capabilities array', () => {
const streamKey = 'test-stream';
const capabilities: string[] = [];
const expectedIngestUri = `${mockRtmpIngestUri}/${streamKey};capabilities=;tags=`;
expect(expectedIngestUri).toContain(streamKey);
expect(expectedIngestUri).toContain(';capabilities=;tags=');
});
it('should handle single capability', () => {
const streamKey = 'test-stream'; const streamKey = 'test-stream';
const capabilities = ['h264']; const capabilities = ['h264'];
rtmpPush.start(streamKey, capabilities); const expectedIngestUri = `${mockRtmpIngestUri}/${streamKey};capabilities=h264;tags=`;
// Simulate process close expect(expectedIngestUri).toContain(streamKey);
const closeHandler = mockProcess.on.mock.calls.find(call => call[0] === 'close')?.[1]; expect(expectedIngestUri).toContain(';capabilities=h264;tags=');
if (closeHandler && typeof closeHandler === 'function') {
closeHandler(0);
}
expect(rtmpPush.isRunning()).toBe(false);
}); });
it('should handle process error event', () => { it('should handle multiple capabilities', () => {
const streamKey = 'test-stream'; const streamKey = 'test-stream';
const capabilities = ['h264']; const capabilities = ['h264', 'aac', 'stereo', '1080p'];
rtmpPush.start(streamKey, capabilities); const expectedIngestUri = `${mockRtmpIngestUri}/${streamKey};capabilities=${capabilities.join(',')};tags=`;
// Simulate process error expect(expectedIngestUri).toContain(streamKey);
const errorHandler = mockProcess.on.mock.calls.find(call => call[0] === 'error')?.[1]; expect(expectedIngestUri).toContain(';capabilities=h264,aac,stereo,1080p;tags=');
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', () => { it('should handle special characters in stream key', () => {
const streamKey = 'test-stream-with-special-chars!@#$%^&*()'; const streamKey = 'test-stream-with-special-chars!@#$%^&*()';
const capabilities = ['h264']; const capabilities = ['h264'];
rtmpPush.start(streamKey, capabilities);
const expectedIngestUri = `${mockRtmpIngestUri}/${streamKey};capabilities=${capabilities.join(',')};tags=`; const expectedIngestUri = `${mockRtmpIngestUri}/${streamKey};capabilities=${capabilities.join(',')};tags=`;
expect(mockSpawn).toHaveBeenCalledWith('ffmpeg', expect.arrayContaining([expectedIngestUri])); expect(expectedIngestUri).toContain(streamKey);
expect(expectedIngestUri).toContain(';capabilities=h264;tags=');
}); });
it('should handle special characters in capabilities', () => { it('should handle special characters in capabilities', () => {
const streamKey = 'test-stream'; const streamKey = 'test-stream';
const capabilities = ['h.264', 'aac-lc', 'stereo_audio']; const capabilities = ['h.264', 'aac-lc', 'stereo_audio'];
rtmpPush.start(streamKey, capabilities); const expectedIngestUri = `${mockRtmpIngestUri}/${streamKey};capabilities=${capabilities.join(',')};tags=`;
expect(expectedIngestUri).toContain(streamKey);
expect(expectedIngestUri).toContain(';capabilities=h.264,aac-lc,stereo_audio;tags=');
});
it('should handle very long stream keys', () => {
const streamKey = 'a'.repeat(1000);
const capabilities = ['h264'];
const expectedIngestUri = `${mockRtmpIngestUri}/${streamKey};capabilities=${capabilities.join(',')};tags=`; const expectedIngestUri = `${mockRtmpIngestUri}/${streamKey};capabilities=${capabilities.join(',')};tags=`;
expect(mockSpawn).toHaveBeenCalledWith('ffmpeg', expect.arrayContaining([expectedIngestUri])); expect(expectedIngestUri).toContain(streamKey);
expect(expectedIngestUri).toContain(';capabilities=h264;tags=');
}); });
it('should handle very long URIs', () => { it('should handle very long capability lists', () => {
const longMediaUri = 'https://example.com/' + 'a'.repeat(1000) + '.ts'; const streamKey = 'test-stream';
const longRtmpUri = 'rtmp://ingest.example.com:80/ingest/' + 'b'.repeat(1000); const capabilities = Array.from({length: 100}, (_, i) => `capability-${i}`);
const longRtmpPush = new RtmpPush(longMediaUri, longRtmpUri); const expectedIngestUri = `${mockRtmpIngestUri}/${streamKey};capabilities=${capabilities.join(',')};tags=`;
expect(expectedIngestUri).toContain(streamKey);
expect(expectedIngestUri).toContain(';capabilities=');
expect(expectedIngestUri).toContain(';tags=');
expect(expectedIngestUri).toContain('capability-0');
expect(expectedIngestUri).toContain('capability-99');
});
});
describe('Method Chaining', () => {
it('should support method chaining', () => {
const result = rtmpPush.stop();
expect(result).toBe(rtmpPush);
// Should be able to chain multiple calls
const chainedResult = rtmpPush.stop().stop().stop();
expect(chainedResult).toBe(rtmpPush);
});
});
describe('Instance Independence', () => {
it('should maintain separate state for different instances', () => {
const rtmpPush1 = new RtmpPush('https://example1.com/video.ts', 'rtmp://example1.com/ingest');
const rtmpPush2 = new RtmpPush('https://example2.com/video.ts', 'rtmp://example2.com/ingest');
expect(rtmpPush1.mediaSourceUri).toBe('https://example1.com/video.ts');
expect(rtmpPush2.mediaSourceUri).toBe('https://example2.com/video.ts');
expect(rtmpPush1.rtmpIngestUri).toBe('rtmp://example1.com/ingest');
expect(rtmpPush2.rtmpIngestUri).toBe('rtmp://example2.com/ingest');
expect(rtmpPush1.isRunning()).toBe(false);
expect(rtmpPush2.isRunning()).toBe(false);
});
});
describe('start() method basic functionality', () => {
it('should be chainable', () => {
const streamKey = 'test-stream'; const streamKey = 'test-stream';
const capabilities = ['h264']; const capabilities = ['h264'];
longRtmpPush.start(streamKey, capabilities); try {
const result = rtmpPush.start(streamKey, capabilities);
expect(result).toBe(rtmpPush);
} catch (error) {
// If ffmpeg is not available, that's expected
expect(error).toBeInstanceOf(Error);
}
});
const expectedIngestUri = `${longRtmpUri}/${streamKey};capabilities=${capabilities.join(',')};tags=`; it('should handle invalid parameters gracefully', () => {
try {
expect(mockSpawn).toHaveBeenCalledWith('ffmpeg', expect.arrayContaining([expectedIngestUri])); rtmpPush.start('', []);
// If it doesn't throw, it should at least not be running
expect(rtmpPush.isRunning()).toBe(false);
} catch (error) {
// Expected to fail
expect(error).toBeInstanceOf(Error);
}
}); });
}); });
}); });

View File

@@ -26,6 +26,6 @@
"noUnusedParameters": false, "noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false "noPropertyAccessFromIndexSignature": false
}, },
"include": ["src"], "include": ["src", "tests/RtmpPush.integration.test.ts", "tests/RtmpPush.test.ts"],
"exclude": ["test", "dist", "node_modules"] "exclude": ["test", "dist", "node_modules"]
} }