scrypted-kasa 1.3.1 → 1.3.3
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/plugin.zip +0 -0
- package/package.json +4 -1
- package/.github/dependabot.yml +0 -14
- package/.github/workflows/ci.yml +0 -40
- package/.github/workflows/publish.yml +0 -52
- package/.nvmrc +0 -1
- package/.prettierignore +0 -6
- package/.prettierrc.json +0 -11
- package/eslint.config.mjs +0 -33
- package/src/camera/api.ts +0 -354
- package/src/camera/intercom.ts +0 -186
- package/src/camera/linkie.ts +0 -185
- package/src/camera/rtp.ts +0 -238
- package/src/camera/rtsp.ts +0 -270
- package/src/camera/stream.ts +0 -346
- package/src/iot/bulb.ts +0 -223
- package/src/iot/device.ts +0 -250
- package/src/iot/dimmer.ts +0 -19
- package/src/iot/plug.ts +0 -5
- package/src/iot/protocol.ts +0 -182
- package/src/iot/switch.ts +0 -5
- package/src/main.ts +0 -1429
- package/src/shared/cipher.ts +0 -49
- package/src/shared/discovery.ts +0 -232
- package/src/shared/readme.ts +0 -20
- package/tsconfig.json +0 -15
package/dist/plugin.zip
ADDED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scrypted-kasa",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.3",
|
|
4
4
|
"description": "TP-Link Kasa plugin for Scrypted — cameras (video, two-way audio, spotlight, siren, status LED), plugs/outlets, wall switches, dimmers, and smart bulbs (on/off, brightness, color, color temperature).",
|
|
5
5
|
"author": "Sergey Vilgelm <sergey@vilgelm.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
"url": "https://github.com/sv-tools/scrypted-kasa-plugin/issues"
|
|
13
13
|
},
|
|
14
14
|
"homepage": "https://github.com/sv-tools/scrypted-kasa-plugin#readme",
|
|
15
|
+
"files": [
|
|
16
|
+
"dist/plugin.zip"
|
|
17
|
+
],
|
|
15
18
|
"scripts": {
|
|
16
19
|
"scrypted-setup-project": "scrypted-setup-project",
|
|
17
20
|
"prescrypted-setup-project": "scrypted-package-json",
|
package/.github/dependabot.yml
DELETED
package/.github/workflows/ci.yml
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
name: CI
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
pull_request:
|
|
5
|
-
branches: [main]
|
|
6
|
-
push:
|
|
7
|
-
branches: [main]
|
|
8
|
-
|
|
9
|
-
# Cancel in-progress runs for the same PR/branch when a new commit is pushed.
|
|
10
|
-
concurrency:
|
|
11
|
-
group: ci-${{ github.workflow }}-${{ github.ref }}
|
|
12
|
-
cancel-in-progress: true
|
|
13
|
-
|
|
14
|
-
# Read-only is enough — CI just builds and lints, doesn't push commits, comments, or
|
|
15
|
-
# release artifacts.
|
|
16
|
-
permissions:
|
|
17
|
-
contents: read
|
|
18
|
-
|
|
19
|
-
jobs:
|
|
20
|
-
check:
|
|
21
|
-
runs-on: ubuntu-latest
|
|
22
|
-
steps:
|
|
23
|
-
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
24
|
-
|
|
25
|
-
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
|
26
|
-
with:
|
|
27
|
-
node-version-file: '.nvmrc'
|
|
28
|
-
cache: 'npm'
|
|
29
|
-
|
|
30
|
-
- name: Install dependencies
|
|
31
|
-
run: npm ci
|
|
32
|
-
|
|
33
|
-
- name: Prettier check
|
|
34
|
-
run: npm run fmt:check
|
|
35
|
-
|
|
36
|
-
- name: ESLint
|
|
37
|
-
run: npm run lint
|
|
38
|
-
|
|
39
|
-
- name: Build (scrypted-webpack)
|
|
40
|
-
run: npm run build
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
name: Publish
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
# Trigger on semver tags like v1.0.7. Push the tag with `git tag v1.0.7 && git push --tags`
|
|
6
|
-
# after bumping the version in package.json.
|
|
7
|
-
tags: ['v*']
|
|
8
|
-
|
|
9
|
-
jobs:
|
|
10
|
-
publish:
|
|
11
|
-
runs-on: ubuntu-latest
|
|
12
|
-
permissions:
|
|
13
|
-
contents: read
|
|
14
|
-
# id-token is required for npm publish provenance — provenance signs the package
|
|
15
|
-
# via the GitHub OIDC token so consumers can verify it was built by this workflow.
|
|
16
|
-
id-token: write
|
|
17
|
-
steps:
|
|
18
|
-
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
19
|
-
|
|
20
|
-
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
|
21
|
-
with:
|
|
22
|
-
node-version-file: '.nvmrc'
|
|
23
|
-
cache: 'npm'
|
|
24
|
-
# Configures npm with the registry URL so NODE_AUTH_TOKEN is honored at publish.
|
|
25
|
-
registry-url: 'https://registry.npmjs.org'
|
|
26
|
-
|
|
27
|
-
- name: Verify package.json version matches git tag
|
|
28
|
-
run: |
|
|
29
|
-
PKG_VERSION=$(node -p "require('./package.json').version")
|
|
30
|
-
TAG_VERSION="${GITHUB_REF#refs/tags/v}"
|
|
31
|
-
if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then
|
|
32
|
-
echo "::error::package.json version ($PKG_VERSION) does not match git tag ($TAG_VERSION)"
|
|
33
|
-
exit 1
|
|
34
|
-
fi
|
|
35
|
-
echo "Publishing scrypted-kasa@$PKG_VERSION"
|
|
36
|
-
|
|
37
|
-
- name: Install dependencies
|
|
38
|
-
run: npm ci
|
|
39
|
-
|
|
40
|
-
- name: Prettier check
|
|
41
|
-
run: npm run fmt:check
|
|
42
|
-
|
|
43
|
-
- name: ESLint
|
|
44
|
-
run: npm run lint
|
|
45
|
-
|
|
46
|
-
- name: Build (scrypted-webpack)
|
|
47
|
-
run: npm run build
|
|
48
|
-
|
|
49
|
-
- name: Publish to npm
|
|
50
|
-
run: npm publish --access public --provenance
|
|
51
|
-
env:
|
|
52
|
-
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
package/.nvmrc
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
v25
|
package/.prettierignore
DELETED
package/.prettierrc.json
DELETED
package/eslint.config.mjs
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import js from '@eslint/js';
|
|
2
|
-
import tseslint from 'typescript-eslint';
|
|
3
|
-
|
|
4
|
-
// We deliberately don't use `eslint-config-prettier`: typescript-eslint's `recommended`
|
|
5
|
-
// preset focuses on code-quality rules, not style. Prettier already owns formatting via
|
|
6
|
-
// `npm run fmt` / `fmt:check`, and there's nothing in the rule set below that fights it.
|
|
7
|
-
// Skipping the dep also sidesteps the known eslint-config-prettier supply-chain compromise.
|
|
8
|
-
export default tseslint.config(
|
|
9
|
-
{
|
|
10
|
-
ignores: ['node_modules/', 'out/', 'build/', 'dist/'],
|
|
11
|
-
},
|
|
12
|
-
js.configs.recommended,
|
|
13
|
-
...tseslint.configs.recommended,
|
|
14
|
-
{
|
|
15
|
-
rules: {
|
|
16
|
-
// Allow `any` — used intentionally for kasa sysinfo (untyped JSON shape varies
|
|
17
|
-
// per device family) and for narrow casts where TypeScript can't see through
|
|
18
|
-
// the runtime tag (e.g. `kasaClass`).
|
|
19
|
-
'@typescript-eslint/no-explicit-any': 'off',
|
|
20
|
-
|
|
21
|
-
// Treat `_`-prefixed args/vars as intentionally unused (matches the convention
|
|
22
|
-
// we already use for ignored handler params like `_options`, `_id`, `_nativeId`).
|
|
23
|
-
'@typescript-eslint/no-unused-vars': [
|
|
24
|
-
'warn',
|
|
25
|
-
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
|
|
26
|
-
],
|
|
27
|
-
|
|
28
|
-
// Empty catch blocks are a deliberate teardown idiom in this codebase: e.g.
|
|
29
|
-
// `try { socket.close(); } catch {}` — best-effort, ignore failure. Don't nag.
|
|
30
|
-
'no-empty': ['warn', { allowEmptyCatch: true }],
|
|
31
|
-
},
|
|
32
|
-
},
|
|
33
|
-
);
|
package/src/camera/api.ts
DELETED
|
@@ -1,354 +0,0 @@
|
|
|
1
|
-
import { once } from 'events';
|
|
2
|
-
import https from 'https';
|
|
3
|
-
import type { IncomingMessage } from 'http';
|
|
4
|
-
import type { Readable } from 'stream';
|
|
5
|
-
|
|
6
|
-
// Kasa cameras serve a non-standard mixed multipart stream over HTTPS on this port/path.
|
|
7
|
-
// Reverse-engineered: https://medium.com/@hu3vjeen/reverse-engineering-tp-link-kc100-bac4641bf1cd
|
|
8
|
-
export const KASA_DEFAULT_PORT = 19443;
|
|
9
|
-
export const KASA_STREAM_PATH = '/https/stream/mixed';
|
|
10
|
-
|
|
11
|
-
// Real headers from this camera fit in well under 1 KB. Cap at 32 KB so a malformed framing
|
|
12
|
-
// (or an attacker who substitutes a non-Kasa endpoint) can't grow the queued chunks until
|
|
13
|
-
// the stream eventually ends. Throwing here surfaces the problem instead of silently
|
|
14
|
-
// retaining memory.
|
|
15
|
-
const MAX_HEADER_BYTES = 32 * 1024;
|
|
16
|
-
|
|
17
|
-
// Connect-phase idle timeout: while establishing the HTTPS request and waiting for response
|
|
18
|
-
// headers, fires only if the socket is idle for this long. Covers TLS handshake hangs and
|
|
19
|
-
// unresponsive-but-routable cameras — but it's not a strict wall-clock deadline if there
|
|
20
|
-
// is intermittent socket activity without headers arriving. Once headers arrive we clear
|
|
21
|
-
// the socket timeout because kasa cameras on marginal Wi-Fi can go briefly silent (channel
|
|
22
|
-
// scan, AP roam, transient congestion) and recover; killing the stream on every such gap
|
|
23
|
-
// causes more harm than good. Half-open TCP detection is delegated to OS keepalive (see
|
|
24
|
-
// setKeepAlive below) so a Wi-Fi drop without a clean FIN still gets cleaned up.
|
|
25
|
-
const KASA_STREAM_CONNECT_TIMEOUT_MS = 10000;
|
|
26
|
-
|
|
27
|
-
// Initial idle before the OS sends the first TCP keepalive probe on the streaming socket.
|
|
28
|
-
// During normal streaming, frame-level data activity resets this timer so probes never
|
|
29
|
-
// fire. If the connection dies (Wi-Fi drop, NAT timeout, switch reset), keepalive probes
|
|
30
|
-
// eventually cause the OS to close the socket and our `body.on('close')` triggers teardown.
|
|
31
|
-
// Note: `socket.setKeepAlive(true, initialDelay)` controls only this initial idle period
|
|
32
|
-
// (TCP_KEEPIDLE); total detection/teardown time still depends on OS-level keepalive
|
|
33
|
-
// interval (TCP_KEEPINTVL) and probe count (TCP_KEEPCNT). Linux defaults are often too
|
|
34
|
-
// slow for a live video stream (TCP_KEEPIDLE may default to 7200 s), so 30 s here just
|
|
35
|
-
// starts probing sooner after the stream goes idle.
|
|
36
|
-
const KASA_STREAM_KEEPALIVE_INITIAL_DELAY_MS = 30000;
|
|
37
|
-
|
|
38
|
-
export const KasaMimeVideo = 'video/x-h264';
|
|
39
|
-
export const KasaMimeG711U = 'audio/g711u';
|
|
40
|
-
|
|
41
|
-
const H264_NAL_TYPE_SPS = 7;
|
|
42
|
-
const H264_NAL_TYPE_PPS = 8;
|
|
43
|
-
|
|
44
|
-
export interface H264SpsPps {
|
|
45
|
-
sps?: Buffer;
|
|
46
|
-
pps?: Buffer;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Scans an annex-b H.264 buffer (NALs prefixed by 00 00 01 or 00 00 00 01) for SPS (type 7)
|
|
50
|
-
// and PPS (type 8). Mutates `found` so callers can accumulate across multiple parts; once both
|
|
51
|
-
// are set further calls are cheap and idempotent.
|
|
52
|
-
export function findSpsPps(annexb: Buffer, found: H264SpsPps = {}): H264SpsPps {
|
|
53
|
-
const len = annexb.length;
|
|
54
|
-
let i = 0;
|
|
55
|
-
while (i < len) {
|
|
56
|
-
let nalStart = -1;
|
|
57
|
-
if (i + 2 < len && annexb[i] === 0 && annexb[i + 1] === 0) {
|
|
58
|
-
if (annexb[i + 2] === 1) nalStart = i + 3;
|
|
59
|
-
else if (i + 3 < len && annexb[i + 2] === 0 && annexb[i + 3] === 1) nalStart = i + 4;
|
|
60
|
-
}
|
|
61
|
-
if (nalStart < 0) {
|
|
62
|
-
i++;
|
|
63
|
-
continue;
|
|
64
|
-
}
|
|
65
|
-
// Walk forward to the next start code; if none, the NAL runs to end-of-buffer.
|
|
66
|
-
let nalEnd = len;
|
|
67
|
-
for (let j = nalStart + 1; j + 2 < len; j++) {
|
|
68
|
-
if (
|
|
69
|
-
annexb[j] === 0 &&
|
|
70
|
-
annexb[j + 1] === 0 &&
|
|
71
|
-
(annexb[j + 2] === 1 || (j + 3 < len && annexb[j + 2] === 0 && annexb[j + 3] === 1))
|
|
72
|
-
) {
|
|
73
|
-
nalEnd = j;
|
|
74
|
-
break;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
const nalType = annexb[nalStart] & 0x1f;
|
|
78
|
-
if (nalType === H264_NAL_TYPE_SPS && !found.sps) found.sps = annexb.subarray(nalStart, nalEnd);
|
|
79
|
-
else if (nalType === H264_NAL_TYPE_PPS && !found.pps) found.pps = annexb.subarray(nalStart, nalEnd);
|
|
80
|
-
i = nalEnd;
|
|
81
|
-
}
|
|
82
|
-
return found;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export interface KasaConnectOptions {
|
|
86
|
-
ip: string;
|
|
87
|
-
port?: number;
|
|
88
|
-
username: string;
|
|
89
|
-
password: string;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export interface KasaPart {
|
|
93
|
-
contentType: string;
|
|
94
|
-
body: Buffer;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// Camera quirk: the Basic auth password must itself be base64(plaintext) before the standard
|
|
98
|
-
// Basic auth base64(user:pass) wrapping. Username is the plaintext Kasa account email.
|
|
99
|
-
function encodeKasaPassword(plaintext: string) {
|
|
100
|
-
return Buffer.from(plaintext, 'utf8').toString('base64');
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export class KasaClient {
|
|
104
|
-
body!: IncomingMessage;
|
|
105
|
-
boundary!: string;
|
|
106
|
-
|
|
107
|
-
static async connect(options: KasaConnectOptions): Promise<KasaClient> {
|
|
108
|
-
const port = options.port || KASA_DEFAULT_PORT;
|
|
109
|
-
// Pre-emptive Basic auth — the camera doesn't send a 401 challenge before serving
|
|
110
|
-
// the multipart stream; the very first request must already include credentials.
|
|
111
|
-
// (No need for the auth-fetch challenge/retry dance other endpoints use.)
|
|
112
|
-
const auth =
|
|
113
|
-
'Basic ' + Buffer.from(`${options.username}:${encodeKasaPassword(options.password)}`).toString('base64');
|
|
114
|
-
|
|
115
|
-
// Cameras present a self-signed cert. The body is consumed as a long-lived stream
|
|
116
|
-
// rather than buffered to completion, so we don't read the response body here.
|
|
117
|
-
const response = await new Promise<IncomingMessage>((resolve, reject) => {
|
|
118
|
-
const req = https.request(
|
|
119
|
-
{
|
|
120
|
-
host: options.ip,
|
|
121
|
-
port,
|
|
122
|
-
method: 'GET',
|
|
123
|
-
path: KASA_STREAM_PATH,
|
|
124
|
-
rejectUnauthorized: false,
|
|
125
|
-
headers: {
|
|
126
|
-
Authorization: auth,
|
|
127
|
-
Accept: '*/*',
|
|
128
|
-
Connection: 'keep-alive',
|
|
129
|
-
},
|
|
130
|
-
},
|
|
131
|
-
response => {
|
|
132
|
-
// Headers in — clear the connect-phase idle timeout so it doesn't
|
|
133
|
-
// keep firing during normal streaming gaps. setTimeout(0) on the
|
|
134
|
-
// underlying socket disables Node's idle-timeout machinery.
|
|
135
|
-
response.socket?.setTimeout(0);
|
|
136
|
-
// Enable OS-level TCP keepalive as a safety net for half-open
|
|
137
|
-
// detection. During normal streaming the data flow keeps this from
|
|
138
|
-
// firing, but if Wi-Fi drops / NAT times out without a clean FIN,
|
|
139
|
-
// keepalive probes will eventually fail and the kernel will close
|
|
140
|
-
// the socket — without keepalive we'd block forever in readPart()
|
|
141
|
-
// waiting for a multipart header that never arrives.
|
|
142
|
-
response.socket?.setKeepAlive(true, KASA_STREAM_KEEPALIVE_INITIAL_DELAY_MS);
|
|
143
|
-
resolve(response);
|
|
144
|
-
},
|
|
145
|
-
);
|
|
146
|
-
req.setTimeout(KASA_STREAM_CONNECT_TIMEOUT_MS, () => {
|
|
147
|
-
req.destroy(new Error(`kasa stream connect timeout after ${KASA_STREAM_CONNECT_TIMEOUT_MS}ms`));
|
|
148
|
-
});
|
|
149
|
-
req.on('error', reject);
|
|
150
|
-
req.end();
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
if (response.statusCode && response.statusCode >= 400) {
|
|
154
|
-
response.destroy();
|
|
155
|
-
throw new Error(`kasa stream HTTP ${response.statusCode}`);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
const contentType = (response.headers['content-type'] || '') as string;
|
|
159
|
-
if (!/multipart\/x-mixed-replace/i.test(contentType)) {
|
|
160
|
-
response.destroy();
|
|
161
|
-
throw new Error(`unexpected content-type: ${contentType}`);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const m = /boundary=([^;\s]+)/i.exec(contentType);
|
|
165
|
-
if (!m) {
|
|
166
|
-
response.destroy();
|
|
167
|
-
throw new Error(`missing boundary in content-type: ${contentType}`);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const client = new KasaClient();
|
|
171
|
-
client.body = response;
|
|
172
|
-
client.boundary = m[1];
|
|
173
|
-
return client;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
destroy() {
|
|
177
|
-
this.body?.destroy();
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
async readPart(): Promise<KasaPart> {
|
|
181
|
-
const expected = '--' + this.boundary;
|
|
182
|
-
|
|
183
|
-
// Read the boundary + header block as one buffer (terminated by a blank line) and
|
|
184
|
-
// walk it byte-wise to extract just content-type and content-length. At ~30 parts/s
|
|
185
|
-
// this avoids the regex split and ~6 string allocations per part that a per-line
|
|
186
|
-
// approach would do.
|
|
187
|
-
const headerBlock = await readUntilDoubleCrlf(this.body);
|
|
188
|
-
const len = headerBlock.length;
|
|
189
|
-
|
|
190
|
-
// Pull out the next \r\n- or \n-terminated line as a string. Returns the slice plus
|
|
191
|
-
// the index just after the line terminator. Skips leading blank lines so a stray
|
|
192
|
-
// CRLF before the boundary (some firmwares) doesn't break parsing.
|
|
193
|
-
const nextLine = (start: number): { line: string; next: number } => {
|
|
194
|
-
let i = start;
|
|
195
|
-
// Find next LF.
|
|
196
|
-
while (i < len && headerBlock[i] !== 0x0a) i++;
|
|
197
|
-
// Trim a trailing CR if present.
|
|
198
|
-
const end = i > start && headerBlock[i - 1] === 0x0d ? i - 1 : i;
|
|
199
|
-
return { line: headerBlock.toString('utf8', start, end), next: i + 1 };
|
|
200
|
-
};
|
|
201
|
-
|
|
202
|
-
let pos = 0;
|
|
203
|
-
// Tolerate leading empty lines.
|
|
204
|
-
while (pos < len) {
|
|
205
|
-
const { line, next } = nextLine(pos);
|
|
206
|
-
if (line) {
|
|
207
|
-
if (line !== expected) throw new Error(`unexpected boundary line: ${JSON.stringify(line)}`);
|
|
208
|
-
pos = next;
|
|
209
|
-
break;
|
|
210
|
-
}
|
|
211
|
-
pos = next;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
let contentType = '';
|
|
215
|
-
let length = -1;
|
|
216
|
-
while (pos < len) {
|
|
217
|
-
const { line, next } = nextLine(pos);
|
|
218
|
-
pos = next;
|
|
219
|
-
if (!line) break;
|
|
220
|
-
// Header names from this camera are stable (`Content-Type:`, `Content-Length:`),
|
|
221
|
-
// but match case-insensitively to tolerate firmware revisions.
|
|
222
|
-
if (line.length > 13 && line.charCodeAt(12) === 0x3a /* : */) {
|
|
223
|
-
const name = line.slice(0, 12);
|
|
224
|
-
if (name === 'Content-Type' || name.toLowerCase() === 'content-type') {
|
|
225
|
-
contentType = line.slice(13).trim();
|
|
226
|
-
continue;
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
if (line.length > 15 && line.charCodeAt(14) === 0x3a) {
|
|
230
|
-
const name = line.slice(0, 14);
|
|
231
|
-
if (name === 'Content-Length' || name.toLowerCase() === 'content-length') {
|
|
232
|
-
const n = parseInt(line.slice(15).trim(), 10);
|
|
233
|
-
if (Number.isFinite(n) && n >= 0) length = n;
|
|
234
|
-
continue;
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
if (length < 0) throw new Error('multipart: no/invalid content length');
|
|
240
|
-
|
|
241
|
-
const body = length === 0 ? Buffer.alloc(0) : await readLength(this.body, length);
|
|
242
|
-
return { contentType, body };
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// Read exactly `length` bytes from a Readable. Returns when the bytes are available;
|
|
247
|
-
// throws if the stream ends before that.
|
|
248
|
-
async function readLength(readable: Readable, length: number): Promise<Buffer> {
|
|
249
|
-
if (readable.readableEnded || readable.destroyed) throw new Error('kasa stream ended before readLength');
|
|
250
|
-
|
|
251
|
-
{
|
|
252
|
-
const ret = readable.read(length);
|
|
253
|
-
if (ret) return ret;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
return new Promise<Buffer>((resolve, reject) => {
|
|
257
|
-
const onReadable = () => {
|
|
258
|
-
const ret = readable.read(length);
|
|
259
|
-
if (ret) {
|
|
260
|
-
cleanup();
|
|
261
|
-
resolve(ret);
|
|
262
|
-
}
|
|
263
|
-
};
|
|
264
|
-
const onEnd = () => {
|
|
265
|
-
cleanup();
|
|
266
|
-
reject(new Error('kasa stream ended during readLength'));
|
|
267
|
-
};
|
|
268
|
-
const cleanup = () => {
|
|
269
|
-
readable.removeListener('readable', onReadable);
|
|
270
|
-
readable.removeListener('end', onEnd);
|
|
271
|
-
readable.removeListener('close', onEnd);
|
|
272
|
-
readable.removeListener('error', onError);
|
|
273
|
-
};
|
|
274
|
-
const onError = (e: Error) => {
|
|
275
|
-
cleanup();
|
|
276
|
-
reject(e);
|
|
277
|
-
};
|
|
278
|
-
readable.on('readable', onReadable);
|
|
279
|
-
readable.on('end', onEnd);
|
|
280
|
-
readable.on('close', onEnd);
|
|
281
|
-
readable.on('error', onError);
|
|
282
|
-
});
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// Read up to and including the first blank line (CRLF CRLF or LF LF) and return everything
|
|
286
|
-
// up to and including the terminator as a Buffer. Whatever bytes come after are unshifted
|
|
287
|
-
// back so the body that follows can be read intact. Enforces MAX_HEADER_BYTES so a malformed
|
|
288
|
-
// stream that never sends a terminator can't grow queued chunks unbounded.
|
|
289
|
-
async function readUntilDoubleCrlf(readable: Readable): Promise<Buffer> {
|
|
290
|
-
const queued: Buffer[] = [];
|
|
291
|
-
let queuedLen = 0;
|
|
292
|
-
// crlf encodes how many bytes of \r\n\r\n we've matched: 0,1=\r, 2=\r\n, 3=\r\n\r, 4=done.
|
|
293
|
-
// lfOnly counts consecutive \n bytes (some firmwares emit LF-only framing).
|
|
294
|
-
let crlf = 0;
|
|
295
|
-
let lfOnly = 0;
|
|
296
|
-
while (true) {
|
|
297
|
-
if (readable.readableEnded || readable.destroyed) throw new Error('kasa stream ended before headers');
|
|
298
|
-
const chunk: Buffer | null = readable.read();
|
|
299
|
-
if (!chunk) {
|
|
300
|
-
// Race 'readable' against 'end'/'close'/'error': if the stream has already ended
|
|
301
|
-
// (or terminates while we're waiting), 'readable' will never fire and the await
|
|
302
|
-
// would strand this promise forever. Re-checking readableEnded on the next loop
|
|
303
|
-
// turn lets us throw a real error instead of hanging.
|
|
304
|
-
//
|
|
305
|
-
// AbortController removes the losing once() listeners; without it each iteration
|
|
306
|
-
// would leave 2 dangling listeners attached for the lifetime of the stream and
|
|
307
|
-
// eventually trip MaxListenersExceededWarning on a long stream with many parts.
|
|
308
|
-
const ac = new AbortController();
|
|
309
|
-
try {
|
|
310
|
-
await Promise.race([
|
|
311
|
-
once(readable, 'readable', { signal: ac.signal }).catch(() => {
|
|
312
|
-
/* aborted or end */
|
|
313
|
-
}),
|
|
314
|
-
once(readable, 'end', { signal: ac.signal }).catch(() => {}),
|
|
315
|
-
once(readable, 'close', { signal: ac.signal }).catch(() => {}),
|
|
316
|
-
]);
|
|
317
|
-
} finally {
|
|
318
|
-
ac.abort();
|
|
319
|
-
}
|
|
320
|
-
continue;
|
|
321
|
-
}
|
|
322
|
-
let split = -1;
|
|
323
|
-
for (let i = 0; i < chunk.length; i++) {
|
|
324
|
-
const b = chunk[i];
|
|
325
|
-
if (crlf === 0 && b === 0x0d) crlf = 1;
|
|
326
|
-
else if (crlf === 1 && b === 0x0a) crlf = 2;
|
|
327
|
-
else if (crlf === 2 && b === 0x0d) crlf = 3;
|
|
328
|
-
else if (crlf === 3 && b === 0x0a) {
|
|
329
|
-
split = i + 1;
|
|
330
|
-
break;
|
|
331
|
-
} else crlf = b === 0x0d ? 1 : 0;
|
|
332
|
-
if (b === 0x0a) {
|
|
333
|
-
if (++lfOnly === 2) {
|
|
334
|
-
split = i + 1;
|
|
335
|
-
break;
|
|
336
|
-
}
|
|
337
|
-
} else lfOnly = 0;
|
|
338
|
-
}
|
|
339
|
-
if (split < 0) {
|
|
340
|
-
queuedLen += chunk.length;
|
|
341
|
-
if (queuedLen > MAX_HEADER_BYTES)
|
|
342
|
-
throw new Error(`multipart header exceeded ${MAX_HEADER_BYTES} bytes without terminator`);
|
|
343
|
-
queued.push(chunk);
|
|
344
|
-
continue;
|
|
345
|
-
}
|
|
346
|
-
const headerPart = chunk.subarray(0, split);
|
|
347
|
-
const rest = chunk.subarray(split);
|
|
348
|
-
if (queuedLen + headerPart.length > MAX_HEADER_BYTES)
|
|
349
|
-
throw new Error(`multipart header exceeded ${MAX_HEADER_BYTES} bytes`);
|
|
350
|
-
queued.push(headerPart);
|
|
351
|
-
if (rest.length) readable.unshift(rest);
|
|
352
|
-
return queued.length === 1 ? queued[0] : Buffer.concat(queued);
|
|
353
|
-
}
|
|
354
|
-
}
|