@portalshq/capability-queue-broadcast 0.1.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.
- package/README.md +131 -0
- package/dist/client.d.ts +174 -0
- package/dist/client.js +312 -0
- package/dist/generated/api.d.ts +500 -0
- package/dist/generated/api.js +246 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +7 -0
- package/dist/monitoring/metrics.d.ts +90 -0
- package/dist/monitoring/metrics.js +190 -0
- package/dist/streaming/frame-buffer.d.ts +34 -0
- package/dist/streaming/frame-buffer.js +61 -0
- package/dist/streaming/index.d.ts +2 -0
- package/dist/streaming/index.js +2 -0
- package/dist/streaming/rtmp/rtmp-streamer.d.ts +75 -0
- package/dist/streaming/rtmp/rtmp-streamer.js +299 -0
- package/package.json +34 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { TextOverlayConfig } from "../../client.js";
|
|
2
|
+
/** A Base64-encoded JPEG image, optionally prefixed with a JPEG data URL. */
|
|
3
|
+
export type Base64JpegFrame = string;
|
|
4
|
+
export interface RTMPStreamerOptions {
|
|
5
|
+
streamKey: string;
|
|
6
|
+
fps?: number;
|
|
7
|
+
width?: number;
|
|
8
|
+
height?: number;
|
|
9
|
+
videoBitrate?: string;
|
|
10
|
+
audioBitrate?: string;
|
|
11
|
+
rtmpUrl?: string;
|
|
12
|
+
/** Include a silent AAC track for RTMP services that require audio. */
|
|
13
|
+
enableAudio?: boolean;
|
|
14
|
+
/** Path to the FFmpeg executable. Defaults to `ffmpeg` on PATH. */
|
|
15
|
+
ffmpegPath?: string;
|
|
16
|
+
/** Maximum number of decoded JPEG frames retained while FFmpeg catches up. */
|
|
17
|
+
maxBufferSize?: number;
|
|
18
|
+
/** Grace period before FFmpeg is force-killed during shutdown. */
|
|
19
|
+
shutdownTimeoutMs?: number;
|
|
20
|
+
/** Text rendered by FFmpeg directly onto the outgoing video. */
|
|
21
|
+
textOverlay?: TextOverlayConfig;
|
|
22
|
+
/** Duration of the generated audio used to time `textOverlay`. */
|
|
23
|
+
audioDurationSeconds?: number;
|
|
24
|
+
}
|
|
25
|
+
export interface StreamStatus {
|
|
26
|
+
isStreaming: boolean;
|
|
27
|
+
framesSent: number;
|
|
28
|
+
framesDropped: number;
|
|
29
|
+
framesUnderrun: number;
|
|
30
|
+
queueSize: number;
|
|
31
|
+
currentFps: number;
|
|
32
|
+
targetFps: number;
|
|
33
|
+
uptime: number;
|
|
34
|
+
lastError?: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Streams Base64 JPEG frames to one RTMP destination through an FFmpeg child
|
|
38
|
+
* process. It never owns content generation or stream provisioning.
|
|
39
|
+
*/
|
|
40
|
+
export declare class RTMPStreamer {
|
|
41
|
+
private readonly options;
|
|
42
|
+
private readonly frameBuffer;
|
|
43
|
+
private ffmpegProcess;
|
|
44
|
+
private isStreaming;
|
|
45
|
+
private isWaitingForDrain;
|
|
46
|
+
private streamingTimer;
|
|
47
|
+
private startTime;
|
|
48
|
+
private framesSent;
|
|
49
|
+
private framesUnderrun;
|
|
50
|
+
private lastFrame;
|
|
51
|
+
private lastError;
|
|
52
|
+
constructor(options: RTMPStreamerOptions);
|
|
53
|
+
startStream(): Promise<void>;
|
|
54
|
+
stopStream(): Promise<void>;
|
|
55
|
+
/** Queues one Base64 JPEG frame. Invalid frame data is rejected synchronously. */
|
|
56
|
+
addFrame(frame: Base64JpegFrame): boolean;
|
|
57
|
+
addFrameBatch(frames: readonly Base64JpegFrame[]): number;
|
|
58
|
+
getStatus(): StreamStatus;
|
|
59
|
+
getMetrics(): Record<string, unknown>;
|
|
60
|
+
resetMetrics(): void;
|
|
61
|
+
private createFfmpegProcess;
|
|
62
|
+
private getFfmpegArguments;
|
|
63
|
+
private waitForProcessStart;
|
|
64
|
+
private scheduleNextFrame;
|
|
65
|
+
private writeNextFrame;
|
|
66
|
+
private waitForProcessExit;
|
|
67
|
+
private handleProcessFailure;
|
|
68
|
+
private clearStreamingTimer;
|
|
69
|
+
private validateOptions;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Keeps an overlay on screen for the audio plus a 1.5 second head and tail.
|
|
73
|
+
* Streams without generated audio use a five second default instead.
|
|
74
|
+
*/
|
|
75
|
+
export declare function getTextOverlayVisibleDuration(audioDurationSeconds?: number): number;
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { FrameBuffer } from "../frame-buffer.js";
|
|
3
|
+
const JPEG_DATA_URL = /^data:image\/jpeg;base64,([A-Za-z0-9+/]+={0,2})$/i;
|
|
4
|
+
const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
5
|
+
const DEFAULT_OVERLAY_DURATION_SECONDS = 5;
|
|
6
|
+
const OVERLAY_HEAD_AND_TAIL_SECONDS = 1.5;
|
|
7
|
+
const OVERLAY_PADDING = 20;
|
|
8
|
+
/**
|
|
9
|
+
* Streams Base64 JPEG frames to one RTMP destination through an FFmpeg child
|
|
10
|
+
* process. It never owns content generation or stream provisioning.
|
|
11
|
+
*/
|
|
12
|
+
export class RTMPStreamer {
|
|
13
|
+
options;
|
|
14
|
+
frameBuffer;
|
|
15
|
+
ffmpegProcess = null;
|
|
16
|
+
isStreaming = false;
|
|
17
|
+
isWaitingForDrain = false;
|
|
18
|
+
streamingTimer = null;
|
|
19
|
+
startTime = 0;
|
|
20
|
+
framesSent = 0;
|
|
21
|
+
framesUnderrun = 0;
|
|
22
|
+
lastFrame = null;
|
|
23
|
+
lastError;
|
|
24
|
+
constructor(options) {
|
|
25
|
+
this.options = {
|
|
26
|
+
streamKey: options.streamKey,
|
|
27
|
+
fps: options.fps ?? 24,
|
|
28
|
+
width: options.width ?? 640,
|
|
29
|
+
height: options.height ?? 480,
|
|
30
|
+
videoBitrate: options.videoBitrate ?? "1500k",
|
|
31
|
+
audioBitrate: options.audioBitrate ?? "128k",
|
|
32
|
+
rtmpUrl: options.rtmpUrl ?? `rtmp://live.twitch.tv/app/${options.streamKey}`,
|
|
33
|
+
enableAudio: options.enableAudio ?? false,
|
|
34
|
+
ffmpegPath: options.ffmpegPath ?? "ffmpeg",
|
|
35
|
+
maxBufferSize: options.maxBufferSize ?? 1_000,
|
|
36
|
+
shutdownTimeoutMs: options.shutdownTimeoutMs ?? 5_000,
|
|
37
|
+
textOverlay: options.textOverlay,
|
|
38
|
+
audioDurationSeconds: options.audioDurationSeconds,
|
|
39
|
+
};
|
|
40
|
+
this.validateOptions();
|
|
41
|
+
this.frameBuffer = new FrameBuffer({ maxSize: this.options.maxBufferSize });
|
|
42
|
+
}
|
|
43
|
+
async startStream() {
|
|
44
|
+
if (this.isStreaming)
|
|
45
|
+
return;
|
|
46
|
+
this.lastError = undefined;
|
|
47
|
+
this.resetMetrics();
|
|
48
|
+
const process = this.createFfmpegProcess();
|
|
49
|
+
await this.waitForProcessStart(process);
|
|
50
|
+
this.ffmpegProcess = process;
|
|
51
|
+
this.isStreaming = true;
|
|
52
|
+
this.startTime = Date.now();
|
|
53
|
+
process.once("error", (error) => this.handleProcessFailure(error));
|
|
54
|
+
process.once("exit", (code, signal) => {
|
|
55
|
+
if (this.isStreaming) {
|
|
56
|
+
this.handleProcessFailure(new Error(`FFmpeg exited (${code ?? signal ?? "unknown"})`));
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
this.scheduleNextFrame(0);
|
|
60
|
+
}
|
|
61
|
+
async stopStream() {
|
|
62
|
+
if (!this.isStreaming && !this.ffmpegProcess)
|
|
63
|
+
return;
|
|
64
|
+
this.isStreaming = false;
|
|
65
|
+
this.clearStreamingTimer();
|
|
66
|
+
this.frameBuffer.clear();
|
|
67
|
+
this.lastFrame = null;
|
|
68
|
+
const process = this.ffmpegProcess;
|
|
69
|
+
this.ffmpegProcess = null;
|
|
70
|
+
if (!process)
|
|
71
|
+
return;
|
|
72
|
+
process.stdin?.end();
|
|
73
|
+
await this.waitForProcessExit(process);
|
|
74
|
+
}
|
|
75
|
+
/** Queues one Base64 JPEG frame. Invalid frame data is rejected synchronously. */
|
|
76
|
+
addFrame(frame) {
|
|
77
|
+
if (!this.isStreaming)
|
|
78
|
+
return false;
|
|
79
|
+
return this.frameBuffer.addFrame(decodeJpegFrame(frame));
|
|
80
|
+
}
|
|
81
|
+
addFrameBatch(frames) {
|
|
82
|
+
let processedCount = 0;
|
|
83
|
+
for (const frame of frames) {
|
|
84
|
+
this.addFrame(frame);
|
|
85
|
+
processedCount++;
|
|
86
|
+
}
|
|
87
|
+
return processedCount;
|
|
88
|
+
}
|
|
89
|
+
getStatus() {
|
|
90
|
+
const uptime = this.isStreaming ? Date.now() - this.startTime : 0;
|
|
91
|
+
const currentFps = uptime > 0 ? this.framesSent / (uptime / 1_000) : 0;
|
|
92
|
+
const bufferStatus = this.frameBuffer.getStatus();
|
|
93
|
+
return {
|
|
94
|
+
isStreaming: this.isStreaming,
|
|
95
|
+
framesSent: this.framesSent,
|
|
96
|
+
framesDropped: bufferStatus.framesDropped,
|
|
97
|
+
framesUnderrun: this.framesUnderrun,
|
|
98
|
+
queueSize: bufferStatus.queueSize,
|
|
99
|
+
currentFps: Math.round(currentFps * 10) / 10,
|
|
100
|
+
targetFps: this.options.fps,
|
|
101
|
+
uptime,
|
|
102
|
+
...(this.lastError ? { lastError: this.lastError } : {}),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
getMetrics() {
|
|
106
|
+
return { ...this.getStatus() };
|
|
107
|
+
}
|
|
108
|
+
resetMetrics() {
|
|
109
|
+
this.framesSent = 0;
|
|
110
|
+
this.framesUnderrun = 0;
|
|
111
|
+
this.frameBuffer.clear();
|
|
112
|
+
this.frameBuffer.resetMetrics();
|
|
113
|
+
}
|
|
114
|
+
createFfmpegProcess() {
|
|
115
|
+
return spawn(this.options.ffmpegPath, this.getFfmpegArguments(), {
|
|
116
|
+
stdio: ["pipe", "ignore", "pipe"],
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
getFfmpegArguments() {
|
|
120
|
+
const videoArguments = [
|
|
121
|
+
"-f", "image2pipe",
|
|
122
|
+
"-vcodec", "mjpeg",
|
|
123
|
+
"-framerate", String(this.options.fps),
|
|
124
|
+
"-i", "pipe:0",
|
|
125
|
+
"-c:v", "libx264",
|
|
126
|
+
"-preset", "veryfast",
|
|
127
|
+
"-tune", "zerolatency",
|
|
128
|
+
"-pix_fmt", "yuv420p",
|
|
129
|
+
"-r", String(this.options.fps),
|
|
130
|
+
"-g", String(this.options.fps),
|
|
131
|
+
"-b:v", this.options.videoBitrate,
|
|
132
|
+
];
|
|
133
|
+
const audioArguments = this.options.enableAudio
|
|
134
|
+
? ["-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=44100", "-c:a", "aac", "-b:a", this.options.audioBitrate]
|
|
135
|
+
: ["-an"];
|
|
136
|
+
const overlayArguments = this.options.textOverlay
|
|
137
|
+
? ["-vf", createDrawtextFilter(this.options.textOverlay, getTextOverlayVisibleDuration(this.options.audioDurationSeconds))]
|
|
138
|
+
: [];
|
|
139
|
+
return [
|
|
140
|
+
"-hide_banner",
|
|
141
|
+
"-loglevel", "warning",
|
|
142
|
+
...videoArguments,
|
|
143
|
+
...audioArguments,
|
|
144
|
+
...overlayArguments,
|
|
145
|
+
"-f", "flv",
|
|
146
|
+
"-flvflags", "no_duration_filesize",
|
|
147
|
+
this.options.rtmpUrl,
|
|
148
|
+
];
|
|
149
|
+
}
|
|
150
|
+
async waitForProcessStart(process) {
|
|
151
|
+
await new Promise((resolve, reject) => {
|
|
152
|
+
const onSpawn = () => {
|
|
153
|
+
cleanup();
|
|
154
|
+
resolve();
|
|
155
|
+
};
|
|
156
|
+
const onError = (error) => {
|
|
157
|
+
cleanup();
|
|
158
|
+
reject(new Error(`Unable to start FFmpeg: ${error.message}`, { cause: error }));
|
|
159
|
+
};
|
|
160
|
+
const cleanup = () => {
|
|
161
|
+
process.removeListener("spawn", onSpawn);
|
|
162
|
+
process.removeListener("error", onError);
|
|
163
|
+
};
|
|
164
|
+
process.once("spawn", onSpawn);
|
|
165
|
+
process.once("error", onError);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
scheduleNextFrame(delayMs) {
|
|
169
|
+
if (!this.isStreaming || this.isWaitingForDrain)
|
|
170
|
+
return;
|
|
171
|
+
this.streamingTimer = setTimeout(() => this.writeNextFrame(), delayMs);
|
|
172
|
+
}
|
|
173
|
+
writeNextFrame() {
|
|
174
|
+
if (!this.isStreaming || this.isWaitingForDrain)
|
|
175
|
+
return;
|
|
176
|
+
const frame = this.frameBuffer.getNextFrame() ?? this.lastFrame;
|
|
177
|
+
if (!frame) {
|
|
178
|
+
this.framesUnderrun++;
|
|
179
|
+
this.scheduleNextFrame(1_000 / this.options.fps);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
this.lastFrame = frame;
|
|
184
|
+
const stdin = this.ffmpegProcess?.stdin;
|
|
185
|
+
if (!stdin || stdin.destroyed || !stdin.writable) {
|
|
186
|
+
throw new Error("FFmpeg stdin is not writable");
|
|
187
|
+
}
|
|
188
|
+
this.framesSent++;
|
|
189
|
+
if (!stdin.write(frame)) {
|
|
190
|
+
this.isWaitingForDrain = true;
|
|
191
|
+
stdin.once("drain", () => {
|
|
192
|
+
this.isWaitingForDrain = false;
|
|
193
|
+
this.scheduleNextFrame(0);
|
|
194
|
+
});
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
this.scheduleNextFrame(1_000 / this.options.fps);
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
this.handleProcessFailure(error);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
async waitForProcessExit(process) {
|
|
204
|
+
if (process.exitCode !== null || process.killed)
|
|
205
|
+
return;
|
|
206
|
+
const exited = new Promise((resolve) => process.once("exit", () => resolve()));
|
|
207
|
+
const timedOut = new Promise((resolve) => setTimeout(resolve, this.options.shutdownTimeoutMs));
|
|
208
|
+
await Promise.race([exited, timedOut]);
|
|
209
|
+
if (process.exitCode === null && !process.killed)
|
|
210
|
+
process.kill("SIGKILL");
|
|
211
|
+
}
|
|
212
|
+
handleProcessFailure(error) {
|
|
213
|
+
this.lastError = error instanceof Error ? error.message : String(error);
|
|
214
|
+
this.isStreaming = false;
|
|
215
|
+
this.clearStreamingTimer();
|
|
216
|
+
}
|
|
217
|
+
clearStreamingTimer() {
|
|
218
|
+
if (this.streamingTimer)
|
|
219
|
+
clearTimeout(this.streamingTimer);
|
|
220
|
+
this.streamingTimer = null;
|
|
221
|
+
}
|
|
222
|
+
validateOptions() {
|
|
223
|
+
if (!this.options.streamKey.trim())
|
|
224
|
+
throw new TypeError("streamKey is required");
|
|
225
|
+
if (!Number.isInteger(this.options.fps) || this.options.fps < 1)
|
|
226
|
+
throw new TypeError("fps must be a positive integer");
|
|
227
|
+
if (!Number.isInteger(this.options.width) || this.options.width < 1)
|
|
228
|
+
throw new TypeError("width must be a positive integer");
|
|
229
|
+
if (!Number.isInteger(this.options.height) || this.options.height < 1)
|
|
230
|
+
throw new TypeError("height must be a positive integer");
|
|
231
|
+
if (!Number.isInteger(this.options.shutdownTimeoutMs) || this.options.shutdownTimeoutMs < 0) {
|
|
232
|
+
throw new TypeError("shutdownTimeoutMs must be a non-negative integer");
|
|
233
|
+
}
|
|
234
|
+
const rtmpUrl = new URL(this.options.rtmpUrl);
|
|
235
|
+
if (rtmpUrl.protocol !== "rtmp:" && rtmpUrl.protocol !== "rtmps:") {
|
|
236
|
+
throw new TypeError("rtmpUrl must use rtmp or rtmps");
|
|
237
|
+
}
|
|
238
|
+
if (this.options.audioDurationSeconds !== undefined && (!Number.isFinite(this.options.audioDurationSeconds) || this.options.audioDurationSeconds < 0)) {
|
|
239
|
+
throw new TypeError("audioDurationSeconds must be a non-negative finite number");
|
|
240
|
+
}
|
|
241
|
+
if (this.options.textOverlay)
|
|
242
|
+
validateTextOverlay(this.options.textOverlay);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Keeps an overlay on screen for the audio plus a 1.5 second head and tail.
|
|
247
|
+
* Streams without generated audio use a five second default instead.
|
|
248
|
+
*/
|
|
249
|
+
export function getTextOverlayVisibleDuration(audioDurationSeconds) {
|
|
250
|
+
if (audioDurationSeconds !== undefined && (!Number.isFinite(audioDurationSeconds) || audioDurationSeconds < 0)) {
|
|
251
|
+
throw new TypeError("audioDurationSeconds must be a non-negative finite number");
|
|
252
|
+
}
|
|
253
|
+
return audioDurationSeconds === undefined
|
|
254
|
+
? DEFAULT_OVERLAY_DURATION_SECONDS
|
|
255
|
+
: audioDurationSeconds + (OVERLAY_HEAD_AND_TAIL_SECONDS * 2);
|
|
256
|
+
}
|
|
257
|
+
function createDrawtextFilter(overlay, visibleDurationSeconds) {
|
|
258
|
+
const position = overlay.position ?? "bottom";
|
|
259
|
+
const coordinates = getOverlayCoordinates(position);
|
|
260
|
+
const fontFile = overlay.fontFile ? `:fontfile='${escapeDrawtextValue(overlay.fontFile)}'` : "";
|
|
261
|
+
const fontSize = overlay.fontSize ?? 36;
|
|
262
|
+
const fontColor = overlay.fontColor ?? "white";
|
|
263
|
+
return `drawtext=text='${escapeDrawtextValue(overlay.text)}'${fontFile}:x=${coordinates.x}:y=${coordinates.y}:fontsize=${fontSize}:fontcolor=${escapeDrawtextValue(fontColor)}:enable='between(t,0,${visibleDurationSeconds})'`;
|
|
264
|
+
}
|
|
265
|
+
function getOverlayCoordinates(position) {
|
|
266
|
+
const x = "(w-text_w)/2";
|
|
267
|
+
if (position === "top")
|
|
268
|
+
return { x, y: String(OVERLAY_PADDING) };
|
|
269
|
+
if (position === "center")
|
|
270
|
+
return { x, y: "(h-text_h)/2" };
|
|
271
|
+
return { x, y: `h-text_h-${OVERLAY_PADDING}` };
|
|
272
|
+
}
|
|
273
|
+
function validateTextOverlay(overlay) {
|
|
274
|
+
if (!overlay.text.trim())
|
|
275
|
+
throw new TypeError("textOverlay.text is required");
|
|
276
|
+
if (overlay.fontSize !== undefined && (!Number.isFinite(overlay.fontSize) || overlay.fontSize <= 0)) {
|
|
277
|
+
throw new TypeError("textOverlay.fontSize must be a positive finite number");
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
function escapeDrawtextValue(value) {
|
|
281
|
+
return value
|
|
282
|
+
.replace(/\\/g, "\\\\")
|
|
283
|
+
.replace(/'/g, "\\'")
|
|
284
|
+
.replace(/:/g, "\\:")
|
|
285
|
+
.replace(/,/g, "\\,")
|
|
286
|
+
.replace(/[\[\]]/g, "\\$&")
|
|
287
|
+
.replace(/\r?\n/g, "\\n");
|
|
288
|
+
}
|
|
289
|
+
function decodeJpegFrame(frame) {
|
|
290
|
+
const encoded = JPEG_DATA_URL.exec(frame)?.[1] ?? frame;
|
|
291
|
+
if (!BASE64.test(encoded) || encoded.length % 4 === 1) {
|
|
292
|
+
throw new TypeError("frame must be a Base64-encoded JPEG image");
|
|
293
|
+
}
|
|
294
|
+
const decoded = Buffer.from(encoded, "base64");
|
|
295
|
+
if (decoded.length < 4 || decoded[0] !== 0xff || decoded[1] !== 0xd8 || decoded.at(-2) !== 0xff || decoded.at(-1) !== 0xd9) {
|
|
296
|
+
throw new TypeError("frame must contain a complete JPEG image");
|
|
297
|
+
}
|
|
298
|
+
return decoded;
|
|
299
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@portalshq/capability-queue-broadcast",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/portalshq/portals-cloud.git"
|
|
7
|
+
},
|
|
8
|
+
"description": "Server-only client for isolated Queue Broadcast Server instances.",
|
|
9
|
+
"type": "module",
|
|
10
|
+
"main": "dist/index.js",
|
|
11
|
+
"types": "dist/index.d.ts",
|
|
12
|
+
"files": [
|
|
13
|
+
"dist"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "rm -rf dist && tsc -p tsconfig.json",
|
|
17
|
+
"publish": "node ../../scripts/publish-workspace.mjs",
|
|
18
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"test:coverage": "vitest run --coverage",
|
|
21
|
+
"generate": "../../lib/api-spec/node_modules/.bin/orval --config ./orval.config.cjs",
|
|
22
|
+
"generate:check": "npm run generate && npm run typecheck"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@portalshq/capability-realtime-fanout": "^0.1.3",
|
|
26
|
+
"@portalshq/capability-video-delivery": "^0.1.3"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^20.0.0",
|
|
30
|
+
"@vitest/coverage-v8": "^3.2.7",
|
|
31
|
+
"typescript": "^5.5.0",
|
|
32
|
+
"vitest": "^3.2.7"
|
|
33
|
+
}
|
|
34
|
+
}
|