camstreamerlib 4.0.32 → 4.0.33-018c17e
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 +9 -0
- package/cjs/PlaneTrackerAPI.js +2 -1
- package/cjs/browser.index.d.ts +2 -0
- package/cjs/browser.index.js +18 -0
- package/cjs/node/HttpRequestSender.js +8 -5
- package/cjs/node/HttpRequestSender.test.d.ts +1 -0
- package/cjs/node/HttpRequestSender.test.js +161 -0
- package/cjs/types/ws/PlaneTrackerEvents.d.ts +47 -0
- package/cjs/types/ws/PlaneTrackerEvents.js +15 -1
- package/cjs/ws/PlaneTrackerEvents.d.ts +1 -1
- package/cjs/ws/PlaneTrackerEvents.js +2 -2
- package/cjs/ws/PlaneTrackerEvents.test.d.ts +1 -0
- package/cjs/ws/PlaneTrackerEvents.test.js +27 -0
- package/esm/PlaneTrackerAPI.js +2 -1
- package/esm/browser.index.js +2 -0
- package/esm/node/HttpRequestSender.js +9 -6
- package/esm/node/HttpRequestSender.test.js +136 -0
- package/esm/types/ws/PlaneTrackerEvents.js +14 -0
- package/esm/ws/PlaneTrackerEvents.js +3 -3
- package/esm/ws/PlaneTrackerEvents.test.js +25 -0
- package/examples/README.md +43 -0
- package/examples/camstreamerlib.js +13201 -0
- package/examples/camswitcher-events.html +137 -0
- package/examples/planetracker-events.html +186 -0
- package/examples/utils/example.css +102 -0
- package/package.json +107 -103
- package/types/browser.index.d.ts +2 -0
- package/types/node/HttpRequestSender.test.d.ts +1 -0
- package/types/types/ws/PlaneTrackerEvents.d.ts +47 -0
- package/types/ws/PlaneTrackerEvents.d.ts +1 -1
- package/types/ws/PlaneTrackerEvents.test.d.ts +1 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import * as http from 'http';
|
|
2
|
+
import { FormData } from 'undici';
|
|
3
|
+
import { describe, test, expect, beforeEach, afterEach } from '@jest/globals';
|
|
4
|
+
import { HttpRequestSender } from './HttpRequestSender';
|
|
5
|
+
const DIGEST_CHALLENGE = 'Digest realm="test", qop="auth", nonce="abc123", opaque="xyz"';
|
|
6
|
+
const okHandler = (_req, res) => {
|
|
7
|
+
res.end('ok');
|
|
8
|
+
};
|
|
9
|
+
const digestHandler = (req, res) => {
|
|
10
|
+
if (req.headers.authorization?.startsWith('Digest ') !== true) {
|
|
11
|
+
res.writeHead(401, { 'WWW-Authenticate': DIGEST_CHALLENGE });
|
|
12
|
+
res.end();
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
res.end('ok');
|
|
16
|
+
};
|
|
17
|
+
describe('HttpRequestSender', () => {
|
|
18
|
+
let server;
|
|
19
|
+
let received;
|
|
20
|
+
let handler;
|
|
21
|
+
const options = (overrides = {}) => ({
|
|
22
|
+
protocol: 'http:',
|
|
23
|
+
host: '127.0.0.1',
|
|
24
|
+
port: server.address().port,
|
|
25
|
+
path: '/test.cgi',
|
|
26
|
+
user: 'root',
|
|
27
|
+
pass: 'pass',
|
|
28
|
+
...overrides,
|
|
29
|
+
});
|
|
30
|
+
beforeEach(async () => {
|
|
31
|
+
received = [];
|
|
32
|
+
handler = okHandler;
|
|
33
|
+
server = http.createServer((req, res) => {
|
|
34
|
+
let body = '';
|
|
35
|
+
req.on('data', (chunk) => {
|
|
36
|
+
body += chunk.toString();
|
|
37
|
+
});
|
|
38
|
+
req.on('end', () => {
|
|
39
|
+
received.push({
|
|
40
|
+
method: req.method,
|
|
41
|
+
url: req.url,
|
|
42
|
+
authorization: req.headers.authorization,
|
|
43
|
+
contentType: req.headers['content-type'],
|
|
44
|
+
body,
|
|
45
|
+
});
|
|
46
|
+
handler(req, res);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
await new Promise((resolve) => {
|
|
50
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
afterEach(async () => {
|
|
54
|
+
server.closeAllConnections();
|
|
55
|
+
await new Promise((resolve) => {
|
|
56
|
+
server.close(() => resolve());
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
test('sends basic auth when the server accepts it', async () => {
|
|
60
|
+
const res = await new HttpRequestSender().sendRequest(options());
|
|
61
|
+
expect(res.status).toBe(200);
|
|
62
|
+
expect(await res.text()).toBe('ok');
|
|
63
|
+
expect(received).toHaveLength(1);
|
|
64
|
+
expect(received[0]?.authorization).toBe(`Basic ${Buffer.from('root:pass').toString('base64')}`);
|
|
65
|
+
});
|
|
66
|
+
test('sends no authorization without credentials', async () => {
|
|
67
|
+
await new HttpRequestSender().sendRequest(options({ user: undefined, pass: undefined }));
|
|
68
|
+
expect(received[0]?.authorization).toBeUndefined();
|
|
69
|
+
});
|
|
70
|
+
test('retries with digest auth on a digest 401 and reuses it for later requests', async () => {
|
|
71
|
+
handler = digestHandler;
|
|
72
|
+
const sender = new HttpRequestSender();
|
|
73
|
+
const first = await sender.sendRequest(options());
|
|
74
|
+
const second = await sender.sendRequest(options());
|
|
75
|
+
expect(first.status).toBe(200);
|
|
76
|
+
expect(second.status).toBe(200);
|
|
77
|
+
expect(received.map((r) => r.authorization?.split(' ')[0])).toEqual(['Basic', 'Digest', 'Digest']);
|
|
78
|
+
});
|
|
79
|
+
test('keeps digest auth after a non-401 error response', async () => {
|
|
80
|
+
const sender = new HttpRequestSender();
|
|
81
|
+
handler = digestHandler;
|
|
82
|
+
await sender.sendRequest(options());
|
|
83
|
+
handler = (_req, res) => {
|
|
84
|
+
res.writeHead(500);
|
|
85
|
+
res.end();
|
|
86
|
+
};
|
|
87
|
+
await sender.sendRequest(options());
|
|
88
|
+
handler = digestHandler;
|
|
89
|
+
const res = await sender.sendRequest(options());
|
|
90
|
+
expect(res.status).toBe(200);
|
|
91
|
+
expect(received.map((r) => r.authorization?.split(' ')[0])).toEqual(['Basic', 'Digest', 'Digest', 'Digest']);
|
|
92
|
+
});
|
|
93
|
+
test('retries a digest 401 only once', async () => {
|
|
94
|
+
handler = (_req, res) => {
|
|
95
|
+
res.writeHead(401, { 'WWW-Authenticate': DIGEST_CHALLENGE });
|
|
96
|
+
res.end();
|
|
97
|
+
};
|
|
98
|
+
const res = await new HttpRequestSender().sendRequest(options());
|
|
99
|
+
expect(res.status).toBe(401);
|
|
100
|
+
expect(received).toHaveLength(2);
|
|
101
|
+
});
|
|
102
|
+
test('rejects when the response does not arrive within the timeout', async () => {
|
|
103
|
+
handler = () => undefined;
|
|
104
|
+
await expect(new HttpRequestSender().sendRequest(options({ timeout: 200 }))).rejects.toThrow();
|
|
105
|
+
});
|
|
106
|
+
test('sends a url-encoded POST body', async () => {
|
|
107
|
+
const res = await new HttpRequestSender().sendRequest(options({ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }), 'action=settext&text=hello');
|
|
108
|
+
expect(res.ok).toBe(true);
|
|
109
|
+
expect(received[0]).toMatchObject({
|
|
110
|
+
method: 'POST',
|
|
111
|
+
contentType: 'application/x-www-form-urlencoded',
|
|
112
|
+
body: 'action=settext&text=hello',
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
test('sends a JSON POST body', async () => {
|
|
116
|
+
const payload = JSON.stringify({ text: 'hello', index: 1 });
|
|
117
|
+
await new HttpRequestSender().sendRequest(options({ method: 'POST', headers: { 'Content-Type': 'application/json' } }), payload);
|
|
118
|
+
expect(received[0]).toMatchObject({ method: 'POST', contentType: 'application/json', body: payload });
|
|
119
|
+
});
|
|
120
|
+
test('sends a Buffer POST body', async () => {
|
|
121
|
+
await new HttpRequestSender().sendRequest(options({ method: 'POST' }), Buffer.from('binary-data'));
|
|
122
|
+
expect(received[0]?.body).toBe('binary-data');
|
|
123
|
+
});
|
|
124
|
+
test('sends a FormData POST body as multipart', async () => {
|
|
125
|
+
const formData = new FormData();
|
|
126
|
+
formData.append('field', 'value');
|
|
127
|
+
await new HttpRequestSender().sendRequest(options({ method: 'POST' }), formData);
|
|
128
|
+
expect(received[0]?.contentType).toMatch(/^multipart\/form-data; boundary=/);
|
|
129
|
+
expect(received[0]?.body).toContain('value');
|
|
130
|
+
});
|
|
131
|
+
test('resends the POST body on the digest retry', async () => {
|
|
132
|
+
handler = digestHandler;
|
|
133
|
+
await new HttpRequestSender().sendRequest(options({ method: 'POST' }), 'payload');
|
|
134
|
+
expect(received.map((r) => r.body)).toEqual(['payload', 'payload']);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -3,6 +3,7 @@ import { blackListSchema, friendlyListSchema, priorityListSchema, trackingModeSc
|
|
|
3
3
|
const wsApiFlightDataSchema = z.object({
|
|
4
4
|
targetId: z.string(),
|
|
5
5
|
icao: z.string(),
|
|
6
|
+
callsign: z.string().optional(),
|
|
6
7
|
domain: domainIdSchema,
|
|
7
8
|
categoryId: z.string(),
|
|
8
9
|
groupId: z.string().optional(),
|
|
@@ -44,6 +45,18 @@ const apiUserSchema = userSchema.extend({
|
|
|
44
45
|
ip: z.string(),
|
|
45
46
|
userPriority: z.number(),
|
|
46
47
|
});
|
|
48
|
+
export const USER_PRIORITY_MIN = 1;
|
|
49
|
+
export const USER_PRIORITY_MAX = 255;
|
|
50
|
+
const USER_PRIORITY_ERROR = `userPriority must be an integer between ${USER_PRIORITY_MIN} and ${USER_PRIORITY_MAX}, where ${USER_PRIORITY_MIN} is the highest priority`;
|
|
51
|
+
export const apiUserInputSchema = apiUserSchema.omit({ ip: true }).extend({
|
|
52
|
+
userId: z.string().nonempty(),
|
|
53
|
+
userName: z.string().nonempty(),
|
|
54
|
+
userPriority: z
|
|
55
|
+
.number()
|
|
56
|
+
.int(USER_PRIORITY_ERROR)
|
|
57
|
+
.min(USER_PRIORITY_MIN, USER_PRIORITY_ERROR)
|
|
58
|
+
.max(USER_PRIORITY_MAX, USER_PRIORITY_ERROR),
|
|
59
|
+
});
|
|
47
60
|
export var EUserActions;
|
|
48
61
|
(function (EUserActions) {
|
|
49
62
|
EUserActions["TRACK_ICAO"] = "trackIcao.cgi";
|
|
@@ -67,6 +80,7 @@ const eventsDataSchema = z.union([
|
|
|
67
80
|
type: z.literal('TRACKING_START'),
|
|
68
81
|
icao: z.string(),
|
|
69
82
|
targetId: z.string(),
|
|
83
|
+
callsign: z.string().optional(),
|
|
70
84
|
domain: domainIdSchema,
|
|
71
85
|
categoryId: z.string(),
|
|
72
86
|
}),
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { WsEvents } from '../internal/WsEvents';
|
|
2
|
-
import { ptrEventsSchema } from '../types/ws/PlaneTrackerEvents';
|
|
2
|
+
import { ptrEventsSchema, apiUserInputSchema, } from '../types/ws/PlaneTrackerEvents';
|
|
3
3
|
export class PlaneTrackerEvents extends WsEvents {
|
|
4
4
|
_apiUser;
|
|
5
|
-
constructor(ws,
|
|
5
|
+
constructor(ws, apiUser) {
|
|
6
6
|
super((data) => ptrEventsSchema.parse(data), ws);
|
|
7
|
-
this._apiUser =
|
|
7
|
+
this._apiUser = apiUserInputSchema.parse(apiUser);
|
|
8
8
|
this.ws.onOpen = this.sendInitMsg;
|
|
9
9
|
}
|
|
10
10
|
sendInitMsg = () => {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { PlaneTrackerEvents } from './PlaneTrackerEvents';
|
|
2
|
+
import { USER_PRIORITY_MAX, USER_PRIORITY_MIN } from '../types/ws/PlaneTrackerEvents';
|
|
3
|
+
const createWsClient = () => ({
|
|
4
|
+
send: jest.fn(),
|
|
5
|
+
onOpen: undefined,
|
|
6
|
+
onMessage: undefined,
|
|
7
|
+
});
|
|
8
|
+
const VALID_USER = { userId: 'asd', userName: 'Asd', userPriority: 1 };
|
|
9
|
+
describe('PlaneTrackerEvents', () => {
|
|
10
|
+
it.each([0, -1, USER_PRIORITY_MAX + 1, 1.5])('rejects userPriority %p', (userPriority) => {
|
|
11
|
+
expect(() => new PlaneTrackerEvents(createWsClient(), { ...VALID_USER, userPriority })).toThrow(/userPriority must be an integer/);
|
|
12
|
+
});
|
|
13
|
+
it.each([USER_PRIORITY_MIN, USER_PRIORITY_MAX])('accepts userPriority %p', (userPriority) => {
|
|
14
|
+
expect(() => new PlaneTrackerEvents(createWsClient(), { ...VALID_USER, userPriority })).not.toThrow();
|
|
15
|
+
});
|
|
16
|
+
it('rejects an empty userId', () => {
|
|
17
|
+
expect(() => new PlaneTrackerEvents(createWsClient(), { ...VALID_USER, userId: '' })).toThrow();
|
|
18
|
+
});
|
|
19
|
+
it('sends USER_INFO once the socket opens', () => {
|
|
20
|
+
const ws = createWsClient();
|
|
21
|
+
new PlaneTrackerEvents(ws, VALID_USER);
|
|
22
|
+
ws.onOpen();
|
|
23
|
+
expect(ws.send).toHaveBeenCalledWith(JSON.stringify({ type: 'USER_INFO', ...VALID_USER }));
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Examples
|
|
2
|
+
|
|
3
|
+
Standalone browser examples that use CamStreamerLib directly from a `<script type="module">`,
|
|
4
|
+
without a bundler or a framework.
|
|
5
|
+
|
|
6
|
+
## Build the browser bundle
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install
|
|
10
|
+
npm run build:browser
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Run
|
|
14
|
+
|
|
15
|
+
Modules can't be loaded over `file://`, so serve the folder over HTTP:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx serve examples
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Both pages connect to a camera through the Device Connect proxy. Fill in the device host
|
|
22
|
+
(`<MAC_ADDRESS>.device-connect.net`) and the device access token in the form, or hardcode them in the
|
|
23
|
+
`DEVICE_HOST` / `DEVICE_ACCESS_TOKEN` constants at the top of the page's script to have the form
|
|
24
|
+
prefilled. See [doc/Client.md](../doc/Client.md).
|
|
25
|
+
|
|
26
|
+
## camswitcher-events.html
|
|
27
|
+
|
|
28
|
+
Logs every CamSwitcher event. `DeviceConnectClient` (HTTP) backs `CamSwitcherAPI` to obtain the
|
|
29
|
+
websocket authorization token; `DeviceConnectWsClient` (websocket) backs `CamSwitcherEvents`, which
|
|
30
|
+
validates and dispatches the events. Connection state is taken from the `authorization` event.
|
|
31
|
+
|
|
32
|
+
See [doc/ws/CamSwitcherEvents.md](../doc/ws/CamSwitcherEvents.md).
|
|
33
|
+
|
|
34
|
+
## planetracker-events.html
|
|
35
|
+
|
|
36
|
+
Logs every PlaneTracker event. PlaneTracker needs no authorization token — `PlaneTrackerEvents`
|
|
37
|
+
announces the connecting user (`userId` / `userName` / `userPriority`, editable in the form) as the
|
|
38
|
+
init message instead, so only the websocket client is needed.
|
|
39
|
+
|
|
40
|
+
`CAMERA_POSITION` and `FLIGHT_LIST` arrive continuously, so by default they only update the summary
|
|
41
|
+
line above the log; tick the checkbox to log them as well.
|
|
42
|
+
|
|
43
|
+
See [doc/ws/PlaneTrackerEvents.md](../doc/ws/PlaneTrackerEvents.md).
|