@basmilius/apple-rtsp 0.9.17
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/dist/index.d.mts +54 -0
- package/dist/index.mjs +221 -0
- package/package.json +53 -0
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Connection, Context } from "@basmilius/apple-common";
|
|
2
|
+
|
|
3
|
+
//#region src/encoding.d.ts
|
|
4
|
+
type Method = "ANNOUNCE" | "FLUSH" | "GET" | "GET_PARAMETER" | "OPTIONS" | "POST" | "PUT" | "RECORD" | "SETUP" | "SET_PARAMETER" | "TEARDOWN";
|
|
5
|
+
type RtspRequest = {
|
|
6
|
+
readonly headers: Record<string, string>;
|
|
7
|
+
readonly method: Method;
|
|
8
|
+
readonly path: string;
|
|
9
|
+
readonly body: Buffer;
|
|
10
|
+
readonly requestLength: number;
|
|
11
|
+
};
|
|
12
|
+
type RtspResponse = {
|
|
13
|
+
readonly response: Response;
|
|
14
|
+
readonly responseLength: number;
|
|
15
|
+
};
|
|
16
|
+
type BuildResponseOptions = {
|
|
17
|
+
readonly status: number;
|
|
18
|
+
readonly statusText: string;
|
|
19
|
+
readonly headers?: Record<string, string | number>;
|
|
20
|
+
readonly body?: Buffer;
|
|
21
|
+
readonly protocol?: "RTSP/1.0" | "HTTP/1.1";
|
|
22
|
+
};
|
|
23
|
+
declare function buildResponse(options: BuildResponseOptions): Buffer;
|
|
24
|
+
declare function parseRequest(buffer: Buffer): RtspRequest | null;
|
|
25
|
+
declare function parseResponse(buffer: Buffer): RtspResponse | null;
|
|
26
|
+
//#endregion
|
|
27
|
+
//#region src/client.d.ts
|
|
28
|
+
type ExchangeOptions = {
|
|
29
|
+
contentType?: string;
|
|
30
|
+
headers?: Record<string, string>;
|
|
31
|
+
body?: Buffer | string | Record<string, unknown>;
|
|
32
|
+
allowError?: boolean;
|
|
33
|
+
protocol?: "RTSP/1.0" | "HTTP/1.1";
|
|
34
|
+
timeout?: number;
|
|
35
|
+
};
|
|
36
|
+
declare class RtspClient extends Connection<{}> {
|
|
37
|
+
#private;
|
|
38
|
+
constructor(context: Context, address: string, port: number);
|
|
39
|
+
/**
|
|
40
|
+
* Override to provide default headers for every request.
|
|
41
|
+
*/
|
|
42
|
+
protected getDefaultHeaders(): Record<string, string | number>;
|
|
43
|
+
/**
|
|
44
|
+
* Override to transform incoming data before RTSP parsing (e.g. decryption).
|
|
45
|
+
*/
|
|
46
|
+
protected transformIncoming(data: Buffer): Buffer | false;
|
|
47
|
+
/**
|
|
48
|
+
* Override to transform outgoing data after RTSP formatting (e.g. encryption).
|
|
49
|
+
*/
|
|
50
|
+
protected transformOutgoing(data: Buffer): Buffer;
|
|
51
|
+
protected exchange(method: Method, path: string, options?: ExchangeOptions): Promise<Response>;
|
|
52
|
+
}
|
|
53
|
+
//#endregion
|
|
54
|
+
export { type BuildResponseOptions, type ExchangeOptions, type Method, RtspClient, type RtspRequest, type RtspResponse, buildResponse, parseRequest, parseResponse };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { Connection, HTTP_TIMEOUT } from "@basmilius/apple-common";
|
|
2
|
+
import { Plist } from "@basmilius/apple-encoding";
|
|
3
|
+
|
|
4
|
+
//#region src/encoding.ts
|
|
5
|
+
function buildResponse(options) {
|
|
6
|
+
const { status, statusText, headers: extraHeaders = {}, body, protocol = "RTSP/1.0" } = options;
|
|
7
|
+
const headers = {
|
|
8
|
+
...extraHeaders,
|
|
9
|
+
"Content-Length": body?.byteLength ?? 0
|
|
10
|
+
};
|
|
11
|
+
const headerLines = [
|
|
12
|
+
`${protocol} ${status} ${statusText}`,
|
|
13
|
+
...Object.entries(headers).map(([k, v]) => `${k}: ${v}`),
|
|
14
|
+
"",
|
|
15
|
+
""
|
|
16
|
+
].join("\r\n");
|
|
17
|
+
if (body && body.byteLength > 0) return Buffer.concat([Buffer.from(headerLines), body]);
|
|
18
|
+
return Buffer.from(headerLines);
|
|
19
|
+
}
|
|
20
|
+
function parseRequest(buffer) {
|
|
21
|
+
const headerLength = buffer.indexOf("\r\n\r\n");
|
|
22
|
+
const { headers, method, path } = parseRequestHeaders(buffer.subarray(0, headerLength));
|
|
23
|
+
let contentLength = headers["Content-Length"] ? Number(headers["Content-Length"]) : 0;
|
|
24
|
+
if (isNaN(contentLength)) contentLength = 0;
|
|
25
|
+
const requestLength = headerLength + 4 + contentLength;
|
|
26
|
+
if (buffer.byteLength < requestLength) return null;
|
|
27
|
+
return {
|
|
28
|
+
headers,
|
|
29
|
+
method,
|
|
30
|
+
path,
|
|
31
|
+
body: buffer.subarray(headerLength + 4, requestLength),
|
|
32
|
+
requestLength
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function parseResponse(buffer) {
|
|
36
|
+
const headerLength = buffer.indexOf("\r\n\r\n");
|
|
37
|
+
const { headers, status, statusText } = parseResponseHeaders(buffer.subarray(0, headerLength));
|
|
38
|
+
let contentLength = headers["Content-Length"] ? Number(headers["Content-Length"]) : 0;
|
|
39
|
+
if (isNaN(contentLength)) contentLength = 0;
|
|
40
|
+
const responseLength = headerLength + 4 + contentLength;
|
|
41
|
+
if (buffer.byteLength < responseLength) return null;
|
|
42
|
+
const body = buffer.subarray(headerLength + 4, responseLength);
|
|
43
|
+
return {
|
|
44
|
+
response: new Response(body, {
|
|
45
|
+
status,
|
|
46
|
+
statusText,
|
|
47
|
+
headers
|
|
48
|
+
}),
|
|
49
|
+
responseLength
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function parseHeaders(lines) {
|
|
53
|
+
const headers = {};
|
|
54
|
+
for (let i = 0; i < lines.length; i++) {
|
|
55
|
+
const colon = lines[i].indexOf(":");
|
|
56
|
+
if (colon <= 0) continue;
|
|
57
|
+
const name = lines[i].substring(0, colon).trim();
|
|
58
|
+
headers[name] = lines[i].substring(colon + 1).trim();
|
|
59
|
+
}
|
|
60
|
+
return headers;
|
|
61
|
+
}
|
|
62
|
+
function parseRequestHeaders(buffer) {
|
|
63
|
+
const lines = buffer.toString("utf8").split("\r\n");
|
|
64
|
+
const rawRequest = lines[0].match(/^(\S+)\s+(\S+)\s+RTSP\/1\.0$/);
|
|
65
|
+
if (!rawRequest) throw new Error(`Invalid RTSP request line: ${lines[0]}`);
|
|
66
|
+
const method = rawRequest[1];
|
|
67
|
+
const path = rawRequest[2];
|
|
68
|
+
return {
|
|
69
|
+
headers: parseHeaders(lines.slice(1)),
|
|
70
|
+
method,
|
|
71
|
+
path
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function parseResponseHeaders(buffer) {
|
|
75
|
+
const lines = buffer.toString("utf8").split("\r\n");
|
|
76
|
+
const rawStatus = lines[0].match(/(HTTP|RTSP)\/[\d.]+\s+(\d+)\s+(.+)/);
|
|
77
|
+
if (!rawStatus) throw new Error(`Invalid RTSP/HTTP response line: ${lines[0]}`);
|
|
78
|
+
const status = Number(rawStatus[2]);
|
|
79
|
+
const statusText = rawStatus[3];
|
|
80
|
+
return {
|
|
81
|
+
headers: parseHeaders(lines.slice(1)),
|
|
82
|
+
status,
|
|
83
|
+
statusText
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
//#endregion
|
|
88
|
+
//#region src/client.ts
|
|
89
|
+
const MAX_BUFFER_SIZE = 2 * 1024 * 1024;
|
|
90
|
+
var RtspClient = class extends Connection {
|
|
91
|
+
#buffer = Buffer.alloc(0);
|
|
92
|
+
#cseq = 0;
|
|
93
|
+
#requests = /* @__PURE__ */ new Map();
|
|
94
|
+
constructor(context, address, port) {
|
|
95
|
+
super(context, address, port);
|
|
96
|
+
this.on("close", this.#onClose.bind(this));
|
|
97
|
+
this.on("data", this.#onData.bind(this));
|
|
98
|
+
this.on("error", this.#onError.bind(this));
|
|
99
|
+
this.on("timeout", this.#onTimeout.bind(this));
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Override to provide default headers for every request.
|
|
103
|
+
*/
|
|
104
|
+
getDefaultHeaders() {
|
|
105
|
+
return {};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Override to transform incoming data before RTSP parsing (e.g. decryption).
|
|
109
|
+
*/
|
|
110
|
+
transformIncoming(data) {
|
|
111
|
+
return data;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Override to transform outgoing data after RTSP formatting (e.g. encryption).
|
|
115
|
+
*/
|
|
116
|
+
transformOutgoing(data) {
|
|
117
|
+
return data;
|
|
118
|
+
}
|
|
119
|
+
async exchange(method, path, options = {}) {
|
|
120
|
+
const { contentType, headers: extraHeaders = {}, allowError = false, protocol = "RTSP/1.0", timeout = HTTP_TIMEOUT } = options;
|
|
121
|
+
let { body } = options;
|
|
122
|
+
const cseq = this.#cseq++;
|
|
123
|
+
const headers = {
|
|
124
|
+
"CSeq": cseq,
|
|
125
|
+
...this.getDefaultHeaders(),
|
|
126
|
+
...extraHeaders
|
|
127
|
+
};
|
|
128
|
+
let bodyBuffer;
|
|
129
|
+
if (body && typeof body === "object" && !Buffer.isBuffer(body)) {
|
|
130
|
+
bodyBuffer = Buffer.from(Plist.serialize(body));
|
|
131
|
+
headers["Content-Type"] = "application/x-apple-binary-plist";
|
|
132
|
+
} else if (body) {
|
|
133
|
+
bodyBuffer = typeof body === "string" ? Buffer.from(body) : body;
|
|
134
|
+
if (contentType) headers["Content-Type"] = contentType;
|
|
135
|
+
} else if (contentType) headers["Content-Type"] = contentType;
|
|
136
|
+
if (bodyBuffer) headers["Content-Length"] = bodyBuffer.length;
|
|
137
|
+
else headers["Content-Length"] = 0;
|
|
138
|
+
const headerLines = [
|
|
139
|
+
`${method} ${path} ${protocol}`,
|
|
140
|
+
...Object.entries(headers).map(([k, v]) => `${k}: ${v}`),
|
|
141
|
+
"",
|
|
142
|
+
""
|
|
143
|
+
].join("\r\n");
|
|
144
|
+
const raw = bodyBuffer ? Buffer.concat([Buffer.from(headerLines), bodyBuffer]) : Buffer.from(headerLines);
|
|
145
|
+
const data = this.transformOutgoing(Buffer.from(raw));
|
|
146
|
+
this.context.logger.net("[rtsp]", method, path, `cseq=${cseq}`);
|
|
147
|
+
return new Promise((resolve, reject) => {
|
|
148
|
+
const timer = setTimeout(() => {
|
|
149
|
+
this.#requests.delete(cseq);
|
|
150
|
+
reject(/* @__PURE__ */ new Error(`No response to CSeq ${cseq} (${path})`));
|
|
151
|
+
}, timeout);
|
|
152
|
+
this.#requests.set(cseq, {
|
|
153
|
+
resolve: (response) => {
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
if (!allowError && !response.ok) reject(/* @__PURE__ */ new Error(`RTSP error: ${response.status} ${response.statusText}`));
|
|
156
|
+
else resolve(response);
|
|
157
|
+
},
|
|
158
|
+
reject: (error) => {
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
reject(error);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
this.write(data);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
#onClose() {
|
|
167
|
+
this.#buffer = Buffer.alloc(0);
|
|
168
|
+
for (const [cseq, { reject }] of this.#requests) {
|
|
169
|
+
reject(/* @__PURE__ */ new Error("Connection closed"));
|
|
170
|
+
this.#requests.delete(cseq);
|
|
171
|
+
}
|
|
172
|
+
this.context.logger.net("[rtsp]", "#onClose()");
|
|
173
|
+
}
|
|
174
|
+
#onData(data) {
|
|
175
|
+
try {
|
|
176
|
+
this.#buffer = Buffer.concat([this.#buffer, data]);
|
|
177
|
+
if (this.#buffer.byteLength > MAX_BUFFER_SIZE) {
|
|
178
|
+
this.context.logger.error("[rtsp]", `Buffer exceeded max size (${this.#buffer.byteLength} bytes), resetting.`);
|
|
179
|
+
this.#buffer = Buffer.alloc(0);
|
|
180
|
+
this.emit("error", /* @__PURE__ */ new Error("Buffer overflow: exceeded maximum buffer size"));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const transformed = this.transformIncoming(this.#buffer);
|
|
184
|
+
if (transformed === false) return;
|
|
185
|
+
this.#buffer = transformed;
|
|
186
|
+
while (this.#buffer.byteLength > 0) {
|
|
187
|
+
const result = parseResponse(this.#buffer);
|
|
188
|
+
if (result === null) return;
|
|
189
|
+
this.#buffer = this.#buffer.subarray(result.responseLength);
|
|
190
|
+
const cseqHeader = result.response.headers.get("CSeq");
|
|
191
|
+
const cseq = cseqHeader ? parseInt(cseqHeader, 10) : -1;
|
|
192
|
+
if (this.#requests.has(cseq)) {
|
|
193
|
+
const { resolve } = this.#requests.get(cseq);
|
|
194
|
+
this.#requests.delete(cseq);
|
|
195
|
+
resolve(result.response);
|
|
196
|
+
} else this.context.logger.warn("[rtsp]", `Unexpected response for CSeq ${cseq}`);
|
|
197
|
+
}
|
|
198
|
+
} catch (err) {
|
|
199
|
+
this.context.logger.error("[rtsp]", "#onData()", err);
|
|
200
|
+
this.emit("error", err);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
#onError(err) {
|
|
204
|
+
for (const [cseq, { reject }] of this.#requests) {
|
|
205
|
+
reject(err);
|
|
206
|
+
this.#requests.delete(cseq);
|
|
207
|
+
}
|
|
208
|
+
this.context.logger.error("[rtsp]", "#onError()", err);
|
|
209
|
+
}
|
|
210
|
+
#onTimeout() {
|
|
211
|
+
const err = /* @__PURE__ */ new Error("Connection timed out");
|
|
212
|
+
for (const [cseq, { reject }] of this.#requests) {
|
|
213
|
+
reject(err);
|
|
214
|
+
this.#requests.delete(cseq);
|
|
215
|
+
}
|
|
216
|
+
this.context.logger.net("[rtsp]", "#onTimeout()");
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
//#endregion
|
|
221
|
+
export { RtspClient, buildResponse, parseRequest, parseResponse };
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@basmilius/apple-rtsp",
|
|
3
|
+
"description": "RTSP protocol implementation for Apple Protocols.",
|
|
4
|
+
"version": "0.9.17",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": {
|
|
8
|
+
"name": "Bas Milius",
|
|
9
|
+
"email": "bas@mili.us",
|
|
10
|
+
"url": "https://bas.dev"
|
|
11
|
+
},
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "https://github.com/basmilius/apple-protocols",
|
|
15
|
+
"directory": "packages/rtsp"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"apple",
|
|
19
|
+
"airplay",
|
|
20
|
+
"rtsp"
|
|
21
|
+
],
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public",
|
|
28
|
+
"provenance": false
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsgo --noEmit && tsdown",
|
|
32
|
+
"dev": "tsdown --watch"
|
|
33
|
+
},
|
|
34
|
+
"main": "./dist/index.mjs",
|
|
35
|
+
"types": "./dist/index.d.mts",
|
|
36
|
+
"typings": "./dist/index.d.mts",
|
|
37
|
+
"sideEffects": false,
|
|
38
|
+
"exports": {
|
|
39
|
+
".": {
|
|
40
|
+
"types": "./dist/index.d.mts",
|
|
41
|
+
"default": "./dist/index.mjs"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@basmilius/apple-common": "workspace:*",
|
|
46
|
+
"@basmilius/apple-encoding": "workspace:*"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/bun": "^1.3.11",
|
|
50
|
+
"@types/node": "^25.5.0",
|
|
51
|
+
"tsdown": "^0.21.4"
|
|
52
|
+
}
|
|
53
|
+
}
|