@thuzjq/meteorcloud-device-sdk-node 0.5.1
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 +45 -0
- package/docs/NODE_INTEGRATION_GUIDE.zh-CN.md +414 -0
- package/index.d.ts +930 -0
- package/index.js +945 -0
- package/lib/artifacts.js +279 -0
- package/lib/camera.js +316 -0
- package/lib/config.js +257 -0
- package/lib/connect.js +718 -0
- package/lib/durable.js +85 -0
- package/lib/errors.js +70 -0
- package/lib/http.js +188 -0
- package/lib/jobs.js +54 -0
- package/lib/jose.js +146 -0
- package/lib/journal.js +116 -0
- package/lib/keystore.js +585 -0
- package/lib/resources.js +408 -0
- package/lib/tokens.js +311 -0
- package/lib/upload.js +188 -0
- package/package.json +44 -0
- package/tools/migrate-key-to-dpapi.js +386 -0
package/lib/tokens.js
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
|
|
5
|
+
const { MeteorCloudError, fail, isObject } = require('./errors');
|
|
6
|
+
const { defaultTransport, headerValue, backoffSeconds, retryAfterSeconds, RETRYABLE_HTTP } = require('./http');
|
|
7
|
+
const {
|
|
8
|
+
ES256,
|
|
9
|
+
randomToken,
|
|
10
|
+
signCompactJws,
|
|
11
|
+
buildAssertionClaims,
|
|
12
|
+
buildDpopClaims,
|
|
13
|
+
dpopHtu
|
|
14
|
+
} = require('./jose');
|
|
15
|
+
const { tokenEndpoint } = require('./config');
|
|
16
|
+
|
|
17
|
+
const CLIENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Exactly the scopes an installation client is registered for. Asking for
|
|
21
|
+
* anything outside this set is refused by the authorization server, so the list
|
|
22
|
+
* is a constant rather than a parameter.
|
|
23
|
+
*
|
|
24
|
+
* 0.5.0 replaced the 0.4.x group of four with the eight independent capabilities
|
|
25
|
+
* of contract §7.1 plus `camera:write` (plan §0.5). They are still issued as one
|
|
26
|
+
* group, but the server checks them per endpoint, so narrowing a client's grant
|
|
27
|
+
* later costs no second breaking change.
|
|
28
|
+
*
|
|
29
|
+
* `device-context:read` is gone from the group and is not an alias for anything.
|
|
30
|
+
* There are no released clients to carry (plan §0.9), so `/device-context` and
|
|
31
|
+
* the bound-camera set it reported were deleted rather than deprecated; the
|
|
32
|
+
* server does not recognise the old scope name at all.
|
|
33
|
+
*
|
|
34
|
+
* This is the single source of truth: `index.js` re-exports it rather than
|
|
35
|
+
* declaring a second copy, so the wire and the public constant cannot drift.
|
|
36
|
+
*/
|
|
37
|
+
const DEVICE_SCOPES = Object.freeze([
|
|
38
|
+
'account:read',
|
|
39
|
+
'installation:read',
|
|
40
|
+
'station:read',
|
|
41
|
+
'camera:read',
|
|
42
|
+
'camera:write',
|
|
43
|
+
'ingest:create',
|
|
44
|
+
'ingest:finalize',
|
|
45
|
+
'job:read'
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
/** Contract value: the server rejects an assertion whose `exp - iat` exceeds this. */
|
|
49
|
+
const ASSERTION_LIFETIME_SECONDS = 60;
|
|
50
|
+
const MAX_TOKEN_RESPONSE_BYTES = 64 * 1024;
|
|
51
|
+
const ACCESS_TOKEN_RE = /^[\x21\x23-\x5B\x5D-\x7E]{16,4096}$/;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Turns the local P-256 key into short-lived opaque device tokens.
|
|
55
|
+
*
|
|
56
|
+
* There is no refresh token anywhere in this design: every renewal is a fresh
|
|
57
|
+
* RFC 7523 assertion signed on the spot. That is why a client that has not run
|
|
58
|
+
* for a year behaves exactly like one that ran a minute ago, and why a token
|
|
59
|
+
* expiring in the middle of a 40 GiB upload costs one extra round trip rather
|
|
60
|
+
* than a restart.
|
|
61
|
+
*/
|
|
62
|
+
class InstallationTokenSource {
|
|
63
|
+
constructor(options = {}) {
|
|
64
|
+
if (!options.installationUid) fail('installationUid is required');
|
|
65
|
+
if (!options.keyStore) fail('keyStore is required');
|
|
66
|
+
if (!options.keyReference) fail('keyReference is required');
|
|
67
|
+
|
|
68
|
+
this.issuer = options.issuer;
|
|
69
|
+
this.tokenEndpoint = tokenEndpoint(options.issuer);
|
|
70
|
+
this.installationUid = options.installationUid;
|
|
71
|
+
this.keyStore = options.keyStore;
|
|
72
|
+
this.keyReference = options.keyReference;
|
|
73
|
+
this.scopes = options.scopes ? [...options.scopes] : [...DEVICE_SCOPES];
|
|
74
|
+
this.dpopEnabled = options.dpop !== false;
|
|
75
|
+
this.authScheme = options.authScheme || 'auto';
|
|
76
|
+
if (!['auto', 'bearer', 'dpop'].includes(this.authScheme)) fail('authScheme is invalid');
|
|
77
|
+
|
|
78
|
+
this.transport = options.transport || defaultTransport;
|
|
79
|
+
this.timeoutMs = options.timeoutMs ?? 30000;
|
|
80
|
+
this.attempts = options.attempts ?? 3;
|
|
81
|
+
/**
|
|
82
|
+
* Renew this many seconds before nominal expiry. A 600s token with a 60s
|
|
83
|
+
* margin still leaves 540s of useful life, and no request is ever issued
|
|
84
|
+
* with a token that is about to die in the server's clock frame.
|
|
85
|
+
*/
|
|
86
|
+
this.marginSeconds = options.marginSeconds ?? 60;
|
|
87
|
+
this.now = options.now || (() => Math.floor(Date.now() / 1000));
|
|
88
|
+
this.random = options.random || Math.random;
|
|
89
|
+
this.jti = options.jti || (() => randomToken(16));
|
|
90
|
+
this.sleep = options.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
91
|
+
|
|
92
|
+
this._token = undefined;
|
|
93
|
+
this._inflight = undefined;
|
|
94
|
+
this._asNonce = undefined;
|
|
95
|
+
this._rsNonce = undefined;
|
|
96
|
+
this._publicJwk = undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async publicJwk() {
|
|
100
|
+
if (!this._publicJwk) this._publicJwk = await this.keyStore.publicJwk(this.keyReference);
|
|
101
|
+
return this._publicJwk;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Drops the cached token. Called after a 401 so the next call re-asserts. */
|
|
105
|
+
invalidate() {
|
|
106
|
+
this._token = undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
noteResourceNonce(nonce) {
|
|
110
|
+
if (typeof nonce === 'string' && nonce) this._rsNonce = nonce;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
_isFresh() {
|
|
114
|
+
return Boolean(this._token) && this._token.expiresAt - this.marginSeconds > this.now();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Returns a live token, renewing if the cached one is inside its margin.
|
|
119
|
+
* Concurrent callers share one in-flight renewal: four artifacts uploading in
|
|
120
|
+
* sequence must not burn four assertions (and four `jti` budget entries) for
|
|
121
|
+
* the same expiry.
|
|
122
|
+
*/
|
|
123
|
+
async getToken() {
|
|
124
|
+
if (this._isFresh()) return this._token;
|
|
125
|
+
if (!this._inflight) {
|
|
126
|
+
this._inflight = this._requestToken().finally(() => {
|
|
127
|
+
this._inflight = undefined;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return this._inflight;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Builds the headers that authenticate one resource request. When a proof is
|
|
135
|
+
* attached the scheme becomes `DPoP` — RFC 9449 §7.1 forbids presenting a
|
|
136
|
+
* DPoP-bound token under the `Bearer` scheme, and the server accepts both.
|
|
137
|
+
*/
|
|
138
|
+
async authorizationHeaders(method, url) {
|
|
139
|
+
const token = await this.getToken();
|
|
140
|
+
const scheme = this._schemeFor(token);
|
|
141
|
+
const headers = { Authorization: `${scheme} ${token.accessToken}` };
|
|
142
|
+
if (scheme === 'DPoP') {
|
|
143
|
+
headers.DPoP = await this.createProof(method, url, {
|
|
144
|
+
accessToken: token.accessToken,
|
|
145
|
+
nonce: this._rsNonce
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return headers;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
_schemeFor(token) {
|
|
152
|
+
if (this.authScheme === 'bearer') return 'Bearer';
|
|
153
|
+
if (this.authScheme === 'dpop') return 'DPoP';
|
|
154
|
+
if (!this.dpopEnabled) return 'Bearer';
|
|
155
|
+
return String(token.tokenType || '').toLowerCase() === 'dpop' ? 'DPoP' : 'Bearer';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async createProof(method, url, options = {}) {
|
|
159
|
+
const header = { alg: ES256, typ: 'dpop+jwt', jwk: await this.publicJwk() };
|
|
160
|
+
const claims = buildDpopClaims({
|
|
161
|
+
method,
|
|
162
|
+
url,
|
|
163
|
+
now: this.now(),
|
|
164
|
+
jti: this.jti(),
|
|
165
|
+
accessToken: options.accessToken
|
|
166
|
+
});
|
|
167
|
+
if (options.nonce) claims.nonce = options.nonce;
|
|
168
|
+
return signCompactJws(header, claims, (data) => this.keyStore.sign(this.keyReference, data));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* RFC 7523 assertion. The header carries no `jwk`: the server resolves the
|
|
173
|
+
* verification key from `mscloud_installation_key` by `client_id` and compares
|
|
174
|
+
* its recomputed RFC 7638 thumbprint, so an embedded key would at best be
|
|
175
|
+
* ignored and at worst invite confusion about which key is authoritative.
|
|
176
|
+
*/
|
|
177
|
+
async createAssertion() {
|
|
178
|
+
const header = { alg: ES256, typ: 'JWT' };
|
|
179
|
+
const claims = buildAssertionClaims({
|
|
180
|
+
installationUid: this.installationUid,
|
|
181
|
+
tokenEndpoint: this.tokenEndpoint,
|
|
182
|
+
now: this.now(),
|
|
183
|
+
lifetimeSeconds: ASSERTION_LIFETIME_SECONDS,
|
|
184
|
+
jti: this.jti()
|
|
185
|
+
});
|
|
186
|
+
return signCompactJws(header, claims, (data) => this.keyStore.sign(this.keyReference, data));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async _requestToken() {
|
|
190
|
+
let lastError;
|
|
191
|
+
for (let attempt = 1; attempt <= this.attempts; attempt += 1) {
|
|
192
|
+
const body = new URLSearchParams({
|
|
193
|
+
grant_type: 'client_credentials',
|
|
194
|
+
// Mandatory beyond RFC 7523: it is what selects the registered client,
|
|
195
|
+
// and therefore which public key the assertion is verified against.
|
|
196
|
+
client_id: this.installationUid,
|
|
197
|
+
client_assertion_type: CLIENT_ASSERTION_TYPE,
|
|
198
|
+
client_assertion: await this.createAssertion(),
|
|
199
|
+
scope: this.scopes.join(' ')
|
|
200
|
+
}).toString();
|
|
201
|
+
|
|
202
|
+
const headers = {
|
|
203
|
+
Accept: 'application/json',
|
|
204
|
+
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
|
|
205
|
+
};
|
|
206
|
+
if (this.dpopEnabled) {
|
|
207
|
+
headers.DPoP = await this.createProof('POST', this.tokenEndpoint, { nonce: this._asNonce });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let response;
|
|
211
|
+
try {
|
|
212
|
+
response = await this.transport({
|
|
213
|
+
method: 'POST',
|
|
214
|
+
url: this.tokenEndpoint,
|
|
215
|
+
headers,
|
|
216
|
+
body: Buffer.from(body, 'utf8'),
|
|
217
|
+
timeoutMs: this.timeoutMs,
|
|
218
|
+
maxResponseBytes: MAX_TOKEN_RESPONSE_BYTES
|
|
219
|
+
});
|
|
220
|
+
} catch (error) {
|
|
221
|
+
lastError = error instanceof MeteorCloudError
|
|
222
|
+
? error
|
|
223
|
+
: new MeteorCloudError('token endpoint transport failed', {
|
|
224
|
+
kind: 'transport',
|
|
225
|
+
retryable: true,
|
|
226
|
+
cause: error
|
|
227
|
+
});
|
|
228
|
+
if (!lastError.retryable || attempt === this.attempts) throw lastError;
|
|
229
|
+
await this.sleep(backoffSeconds(attempt, lastError.retryAfterSeconds, this.random) * 1000);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const nonce = headerValue(response.headers, 'dpop-nonce');
|
|
234
|
+
if (nonce) this._asNonce = nonce;
|
|
235
|
+
|
|
236
|
+
let decoded;
|
|
237
|
+
try {
|
|
238
|
+
decoded = JSON.parse(Buffer.from(response.body || '').toString('utf8'));
|
|
239
|
+
} catch (cause) {
|
|
240
|
+
lastError = new MeteorCloudError('token endpoint returned invalid JSON', {
|
|
241
|
+
kind: 'auth',
|
|
242
|
+
httpStatus: response.status,
|
|
243
|
+
retryable: RETRYABLE_HTTP.has(response.status),
|
|
244
|
+
retryAfterSeconds: retryAfterSeconds(response.headers),
|
|
245
|
+
cause
|
|
246
|
+
});
|
|
247
|
+
if (!lastError.retryable || attempt === this.attempts) throw lastError;
|
|
248
|
+
await this.sleep(backoffSeconds(attempt, lastError.retryAfterSeconds, this.random) * 1000);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (response.status !== 200) {
|
|
253
|
+
const oauthError = isObject(decoded) && typeof decoded.error === 'string' ? decoded.error : undefined;
|
|
254
|
+
// RFC 9449 §8: the AS may demand a nonce it has just supplied. Retry once
|
|
255
|
+
// with it rather than surfacing a failure the client can fix itself.
|
|
256
|
+
if (oauthError === 'use_dpop_nonce' && nonce && attempt < this.attempts) {
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const retryable = RETRYABLE_HTTP.has(response.status);
|
|
260
|
+
const error = new MeteorCloudError(
|
|
261
|
+
oauthError ? `token endpoint rejected the assertion: ${oauthError}` : 'token endpoint rejected the assertion',
|
|
262
|
+
{
|
|
263
|
+
kind: 'auth',
|
|
264
|
+
httpStatus: response.status,
|
|
265
|
+
oauthError,
|
|
266
|
+
retryable,
|
|
267
|
+
retryAfterSeconds: retryAfterSeconds(response.headers)
|
|
268
|
+
}
|
|
269
|
+
);
|
|
270
|
+
if (!retryable || attempt === this.attempts) throw error;
|
|
271
|
+
lastError = error;
|
|
272
|
+
await this.sleep(backoffSeconds(attempt, error.retryAfterSeconds, this.random) * 1000);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
this._token = parseTokenResponse(decoded, this.now());
|
|
277
|
+
return this._token;
|
|
278
|
+
}
|
|
279
|
+
throw lastError;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function parseTokenResponse(decoded, nowSeconds) {
|
|
284
|
+
if (!isObject(decoded)) fail('token response is not an object', { kind: 'auth' });
|
|
285
|
+
const accessToken = decoded.access_token;
|
|
286
|
+
if (typeof accessToken !== 'string' || !ACCESS_TOKEN_RE.test(accessToken)) {
|
|
287
|
+
fail('token response carries an implausible access_token', { kind: 'auth' });
|
|
288
|
+
}
|
|
289
|
+
const expiresIn = Number(decoded.expires_in);
|
|
290
|
+
if (!Number.isInteger(expiresIn) || expiresIn < 30 || expiresIn > 86400) {
|
|
291
|
+
fail('token response carries an implausible expires_in', { kind: 'auth' });
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
accessToken,
|
|
295
|
+
tokenType: typeof decoded.token_type === 'string' ? decoded.token_type : 'Bearer',
|
|
296
|
+
scope: typeof decoded.scope === 'string' ? decoded.scope : '',
|
|
297
|
+
expiresAt: nowSeconds + expiresIn,
|
|
298
|
+
// Never the token itself: this is what progress callbacks and evidence files
|
|
299
|
+
// are allowed to see.
|
|
300
|
+
fingerprint: crypto.createHash('sha256').update(accessToken, 'utf8').digest('hex').slice(0, 16)
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
module.exports = {
|
|
305
|
+
InstallationTokenSource,
|
|
306
|
+
DEVICE_SCOPES,
|
|
307
|
+
CLIENT_ASSERTION_TYPE,
|
|
308
|
+
ASSERTION_LIFETIME_SECONDS,
|
|
309
|
+
parseTokenResponse,
|
|
310
|
+
dpopHtu
|
|
311
|
+
};
|
package/lib/upload.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { MeteorCloudError, fail } = require('./errors');
|
|
4
|
+
const { sha256Path } = require('./artifacts');
|
|
5
|
+
const { backoffSeconds } = require('./http');
|
|
6
|
+
|
|
7
|
+
/** Production contract: one direct-PUT request carries at most 32 MiB. */
|
|
8
|
+
const DEFAULT_CHUNK_SIZE = 32 * 1024 * 1024;
|
|
9
|
+
/** Frozen server ceiling (`MsCloudProperties.DirectPut.MAX_CHUNK_BYTES_CEILING`). */
|
|
10
|
+
const MAX_CHUNK_SIZE = 32 * 1024 * 1024;
|
|
11
|
+
const MIN_CHUNK_SIZE = 64 * 1024;
|
|
12
|
+
|
|
13
|
+
const API_CODE = Object.freeze({
|
|
14
|
+
DIGEST_MISMATCH: 1030002005,
|
|
15
|
+
REQUEST_TOO_LARGE: 1030003010,
|
|
16
|
+
CHUNK_OUT_OF_ORDER: 1030003030,
|
|
17
|
+
CHUNK_RANGE_INVALID: 1030003031,
|
|
18
|
+
ARTIFACT_ALREADY_COMPLETE: 1030003032,
|
|
19
|
+
DIRECT_PUT_DISABLED: 1030003034,
|
|
20
|
+
CAMERA_REQUIRED: 1030003035
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function normalizeChunkSize(requested, advertisedMax) {
|
|
24
|
+
const ceiling = Number.isSafeInteger(advertisedMax) && advertisedMax >= MIN_CHUNK_SIZE
|
|
25
|
+
? Math.min(advertisedMax, MAX_CHUNK_SIZE)
|
|
26
|
+
: MAX_CHUNK_SIZE;
|
|
27
|
+
const size = Number.isSafeInteger(requested) && requested > 0 ? requested : DEFAULT_CHUNK_SIZE;
|
|
28
|
+
if (size < MIN_CHUNK_SIZE) fail(`chunkSize must be at least ${MIN_CHUNK_SIZE} bytes`);
|
|
29
|
+
return Math.min(size, ceiling);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Streams one artifact to the local direct plane, resuming from wherever the
|
|
34
|
+
* server says it already is.
|
|
35
|
+
*
|
|
36
|
+
* The governing rule, and the reason this loop is shaped the way it is: the
|
|
37
|
+
* server accepts a chunk **only** when `Content-Range` starts at exactly its own
|
|
38
|
+
* durable `receivedBytes`. It will not fill a hole and it will not overwrite. So
|
|
39
|
+
* the client never guesses an offset — after any failure whatsoever, including a
|
|
40
|
+
* transport error where the response was simply lost, it re-reads the authority
|
|
41
|
+
* before touching the wire again. A chunk that arrived intact but whose 200 went
|
|
42
|
+
* missing is thereby detected as progress rather than retried into a 409.
|
|
43
|
+
*
|
|
44
|
+
* @param deps.putChunk performs one PUT, resolves to {receivedBytes, sizeBytes, complete}
|
|
45
|
+
* @param deps.readReceivedBytes re-reads the server's durable offset for a role
|
|
46
|
+
* @param plan {role, filePath, sizeBytes, targetPath, sha256, receivedBytes}
|
|
47
|
+
*/
|
|
48
|
+
async function uploadArtifactChunks(deps, plan, journal, emit) {
|
|
49
|
+
const chunkSize = deps.chunkSize;
|
|
50
|
+
const attempts = deps.attempts ?? 5;
|
|
51
|
+
const sleep = deps.sleep;
|
|
52
|
+
const random = deps.random;
|
|
53
|
+
const total = Number(plan.sizeBytes);
|
|
54
|
+
if (!Number.isSafeInteger(total) || total < 1) fail(`authorized size is invalid: ${plan.role}`);
|
|
55
|
+
|
|
56
|
+
const state = journal.reconcile(plan.role, {
|
|
57
|
+
key: plan.targetPath,
|
|
58
|
+
size: total,
|
|
59
|
+
sha256: plan.sha256
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
let offset = Number(plan.receivedBytes) || 0;
|
|
63
|
+
if (offset > total) {
|
|
64
|
+
// The server holds more bytes than the artifact has. The session was
|
|
65
|
+
// authorized for different content; resuming would corrupt it.
|
|
66
|
+
fail(`server holds more bytes than the authorized size: ${plan.role}`, { kind: 'device_api' });
|
|
67
|
+
}
|
|
68
|
+
await noteProgress(journal, state, offset);
|
|
69
|
+
|
|
70
|
+
await emit({ stage: 'uploading', role: plan.role, transferredBytes: offset, totalBytes: total });
|
|
71
|
+
|
|
72
|
+
let attempt = 0;
|
|
73
|
+
while (offset < total) {
|
|
74
|
+
const length = Math.min(chunkSize, total - offset);
|
|
75
|
+
const start = offset;
|
|
76
|
+
const endInclusive = start + length - 1;
|
|
77
|
+
|
|
78
|
+
// Two passes over the slice: one to digest, one to send. Buffering a 32 MiB
|
|
79
|
+
// chunk to do it in one would trade a bounded amount of disk read for an
|
|
80
|
+
// unbounded amount of RSS on a machine that is also capturing video.
|
|
81
|
+
const digest = await sha256Path(plan.filePath, { start, end: endInclusive });
|
|
82
|
+
|
|
83
|
+
let result;
|
|
84
|
+
try {
|
|
85
|
+
result = await deps.putChunk({
|
|
86
|
+
role: plan.role,
|
|
87
|
+
targetPath: plan.targetPath,
|
|
88
|
+
filePath: plan.filePath,
|
|
89
|
+
start,
|
|
90
|
+
endInclusive,
|
|
91
|
+
total,
|
|
92
|
+
length,
|
|
93
|
+
sha256: digest
|
|
94
|
+
});
|
|
95
|
+
attempt = 0;
|
|
96
|
+
} catch (error) {
|
|
97
|
+
attempt += 1;
|
|
98
|
+
const fatal = isFatalChunkError(error);
|
|
99
|
+
if (fatal || attempt >= attempts) throw error;
|
|
100
|
+
await sleep(backoffSeconds(attempt, error.retryAfterSeconds, random) * 1000);
|
|
101
|
+
// Authority re-read: covers the lost-200 case, a competing uploader, and
|
|
102
|
+
// an out-of-order rejection caused by a stale local offset.
|
|
103
|
+
const authoritative = await deps.readReceivedBytes(plan.role);
|
|
104
|
+
if (!Number.isSafeInteger(authoritative) || authoritative < 0 || authoritative > total) {
|
|
105
|
+
fail(`server reported an implausible receivedBytes for ${plan.role}`, { kind: 'device_api' });
|
|
106
|
+
}
|
|
107
|
+
offset = authoritative;
|
|
108
|
+
await noteProgress(journal, state, offset);
|
|
109
|
+
await emit({ stage: 'uploading', role: plan.role, transferredBytes: offset, totalBytes: total });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const received = Number(result.receivedBytes);
|
|
114
|
+
// The server neither fills holes nor overwrites, so the only arithmetically
|
|
115
|
+
// possible answer to a chunk that was accepted is `start + length`. Merely
|
|
116
|
+
// requiring it to advance lets a confused server make the client skip bytes
|
|
117
|
+
// it never sent; finalize's full-file digest would still catch that, but
|
|
118
|
+
// only after the whole remaining file had been uploaded for nothing.
|
|
119
|
+
if (!Number.isSafeInteger(received) || received !== start + length) {
|
|
120
|
+
fail(
|
|
121
|
+
`server acknowledged ${received} bytes for ${plan.role}, expected ${start + length}`,
|
|
122
|
+
{ kind: 'device_api' }
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
offset = received;
|
|
126
|
+
await noteProgress(journal, state, offset);
|
|
127
|
+
await emit({ stage: 'uploading', role: plan.role, transferredBytes: offset, totalBytes: total });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return offset;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Records the offset, best-effort.
|
|
135
|
+
*
|
|
136
|
+
* The journal is an advisory hint, not a participant in the data path: resume
|
|
137
|
+
* is driven entirely by the server's `receivedBytes`, which is re-read on every
|
|
138
|
+
* failure. So a journal that cannot be written must not be able to stop an
|
|
139
|
+
* upload that needs no local disk at all — and a full disk is precisely the
|
|
140
|
+
* situation in which an operator is anxiously watching one. Before this, an
|
|
141
|
+
* `ENOSPC` here aborted the transfer before a single byte moved, with
|
|
142
|
+
* `cannot save upload journal`, on every retry forever.
|
|
143
|
+
*
|
|
144
|
+
* The failure is not swallowed silently: the last one is left on
|
|
145
|
+
* `journal.lastSaveError` for a caller that wants to warn about a station whose
|
|
146
|
+
* disk is failing. It just no longer decides whether the upload proceeds. Only
|
|
147
|
+
* `io` is absorbed — a `validation` failure from the secret scan in `save()`
|
|
148
|
+
* means the journal was about to write something it must never write, and that
|
|
149
|
+
* must still stop everything.
|
|
150
|
+
*/
|
|
151
|
+
async function noteProgress(journal, state, offset) {
|
|
152
|
+
state.offset = offset;
|
|
153
|
+
try {
|
|
154
|
+
await journal.save();
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (!(error instanceof MeteorCloudError) || error.kind !== 'io') throw error;
|
|
157
|
+
journal.lastSaveError = error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Errors that no amount of retrying will fix, because the request itself is
|
|
163
|
+
* wrong rather than unlucky. Everything else earns a backoff and a re-read.
|
|
164
|
+
*
|
|
165
|
+
* `CHUNK_OUT_OF_ORDER` is deliberately absent: it is the expected answer when
|
|
166
|
+
* the local offset is stale, and the re-read on the retry path is exactly the
|
|
167
|
+
* cure for it.
|
|
168
|
+
*/
|
|
169
|
+
function isFatalChunkError(error) {
|
|
170
|
+
if (!(error instanceof MeteorCloudError)) return false;
|
|
171
|
+
if (['RESOURCE_DISABLED', 'RESOURCE_DELETED', 'RESOURCE_RECREATION_REQUIRED', 'UPLOAD_GENERATION_STALE'].includes(error.action)) return true;
|
|
172
|
+
if (error.kind === 'cancelled' || error.kind === 'validation' || error.kind === 'io') return true;
|
|
173
|
+
return [
|
|
174
|
+
API_CODE.CHUNK_RANGE_INVALID,
|
|
175
|
+
API_CODE.REQUEST_TOO_LARGE,
|
|
176
|
+
API_CODE.DIRECT_PUT_DISABLED
|
|
177
|
+
].includes(error.apiCode);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
module.exports = {
|
|
181
|
+
DEFAULT_CHUNK_SIZE,
|
|
182
|
+
MAX_CHUNK_SIZE,
|
|
183
|
+
MIN_CHUNK_SIZE,
|
|
184
|
+
API_CODE,
|
|
185
|
+
normalizeChunkSize,
|
|
186
|
+
uploadArtifactChunks,
|
|
187
|
+
isFatalChunkError
|
|
188
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@thuzjq/meteorcloud-device-sdk-node",
|
|
3
|
+
"version": "0.5.1",
|
|
4
|
+
"description": "MeteorCloud device SDK for Node.js: account authorization (OAuth 2 + PKCE browser bind, private_key_jwt renewal, DPoP), account-scoped queries, and chunked direct-PUT uploads that declare their own camera",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"types": "index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./index.d.ts",
|
|
10
|
+
"require": "./index.js",
|
|
11
|
+
"import": "./index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"index.js",
|
|
16
|
+
"index.d.ts",
|
|
17
|
+
"lib/",
|
|
18
|
+
"README.md",
|
|
19
|
+
"docs/NODE_INTEGRATION_GUIDE.zh-CN.md",
|
|
20
|
+
"tools/migrate-key-to-dpapi.js"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "node --test",
|
|
24
|
+
"pack:check": "npm pack --dry-run",
|
|
25
|
+
"test:browser:windows": "node tools/verify-windows-browser-launch.js"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=20.10"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"@meteorlive/dpapi": "^0.1.0"
|
|
33
|
+
},
|
|
34
|
+
"peerDependenciesMeta": {
|
|
35
|
+
"@meteorlive/dpapi": {
|
|
36
|
+
"optional": true
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"license": "UNLICENSED",
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public",
|
|
42
|
+
"registry": "https://registry.npmjs.org/"
|
|
43
|
+
}
|
|
44
|
+
}
|