viveworker 0.7.0 → 0.8.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/.agents/plugins/marketplace.json +20 -0
- package/README.md +115 -4
- package/package.json +13 -3
- package/plugins/viveworker-control-plane/.codex-plugin/plugin.json +49 -0
- package/plugins/viveworker-control-plane/.mcp.json +11 -0
- package/plugins/viveworker-control-plane/assets/viveworker-logo-v2.png +0 -0
- package/plugins/viveworker-control-plane/assets/viveworker-logo-v2.svg +39 -0
- package/plugins/viveworker-control-plane/skills/viveworker-control-plane/SKILL.md +83 -0
- package/scripts/lib/markdown-render.mjs +128 -1
- package/scripts/lib/remote-pairing/README.md +164 -0
- package/scripts/lib/remote-pairing/_browser-bundle-entry.mjs +86 -0
- package/scripts/lib/remote-pairing/audit.mjs +122 -0
- package/scripts/lib/remote-pairing/bridge-relay-client.mjs +737 -0
- package/scripts/lib/remote-pairing/control.mjs +156 -0
- package/scripts/lib/remote-pairing/envelope.mjs +224 -0
- package/scripts/lib/remote-pairing/http-dispatch.mjs +530 -0
- package/scripts/lib/remote-pairing/keys-core.mjs +110 -0
- package/scripts/lib/remote-pairing/keys.mjs +181 -0
- package/scripts/lib/remote-pairing/noise.mjs +436 -0
- package/scripts/lib/remote-pairing/orchestrator.mjs +356 -0
- package/scripts/lib/remote-pairing/pairings.mjs +446 -0
- package/scripts/lib/remote-pairing/rpc.mjs +381 -0
- package/scripts/mcp-server.mjs +891 -0
- package/scripts/moltbook-scout-auto.sh +16 -8
- package/scripts/share-cli.mjs +14 -130
- package/scripts/stats-cli.mjs +683 -0
- package/scripts/viveworker-bridge.mjs +1676 -127
- package/scripts/viveworker.mjs +289 -7
- package/skills/viveworker-control-plane/SKILL.md +104 -0
- package/skills/viveworker-control-plane/agents/openai.yaml +12 -0
- package/templates/CLAUDE.viveworker.md +67 -0
- package/web/app.css +683 -9
- package/web/app.js +1780 -187
- package/web/build-id.js +1 -0
- package/web/i18n.js +187 -9
- package/web/icons/apple-touch-icon.png +0 -0
- package/web/icons/viveworker-icon-192.png +0 -0
- package/web/icons/viveworker-icon-512.png +0 -0
- package/web/icons/viveworker-v-pulse.svg +16 -1
- package/web/index.html +32 -2
- package/web/remote-pairing/api-router.js +873 -0
- package/web/remote-pairing/keys.js +237 -0
- package/web/remote-pairing/pairing-state.js +313 -0
- package/web/remote-pairing/rpc-client.js +765 -0
- package/web/remote-pairing/transport.js +804 -0
- package/web/remote-pairing/wake.js +149 -0
- package/web/remote-pairing-test.html +400 -0
- package/web/remote-pairing.bundle.js +3 -0
- package/web/remote-pairing.bundle.js.LEGAL.txt +15 -0
- package/web/remote-pairing.bundle.js.map +7 -0
- package/web/sw.js +190 -20
- package/web/icons/viveworker-beacon-v.svg +0 -19
- package/web/icons/viveworker-icon-1024.png +0 -0
- package/web/icons/viveworker-v-check.svg +0 -19
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rpc.mjs — Application-level RPC framing carried inside the Noise channel.
|
|
3
|
+
*
|
|
4
|
+
* The transport (Noise + envelope) gives us an authenticated, encrypted,
|
|
5
|
+
* ordered byte stream between phone and bridge. On top of it we need an
|
|
6
|
+
* HTTP-shaped request/response abstraction so the bridge can keep using
|
|
7
|
+
* its existing `(req, res)` handlers and the phone PWA can keep doing
|
|
8
|
+
* `fetch()` whether it's on LAN or going through the relay.
|
|
9
|
+
*
|
|
10
|
+
* Wire format (UTF-8 JSON, one frame per Noise transport message):
|
|
11
|
+
*
|
|
12
|
+
* // phone → bridge: HTTP-like request
|
|
13
|
+
* { "t":"req", "id":"r1", "method":"GET", "path":"/api/state",
|
|
14
|
+
* "headers":{"cookie":"viveworker_session=..."},
|
|
15
|
+
* "body":"" }
|
|
16
|
+
*
|
|
17
|
+
* // bridge → phone: full response (single frame; chunked variant TBD)
|
|
18
|
+
* { "t":"res", "id":"r1", "status":200,
|
|
19
|
+
* "headers":{"content-type":"application/json"},
|
|
20
|
+
* "body":"{...}" }
|
|
21
|
+
*
|
|
22
|
+
* // phone → bridge: cancel an in-flight request (e.g. user navigated
|
|
23
|
+
* // away during a long-poll). The bridge MAY abort and skip sending res.
|
|
24
|
+
* { "t":"cancel", "id":"r1" }
|
|
25
|
+
*
|
|
26
|
+
* // bridge → phone: server-pushed event, no client request to correlate
|
|
27
|
+
* // against (e.g., "new approval item arrived; refresh inbox").
|
|
28
|
+
* { "t":"evt", "topic":"inbox-changed", "data":{...} }
|
|
29
|
+
*
|
|
30
|
+
* Why JSON (not CBOR / Protobuf):
|
|
31
|
+
* The Noise channel already gave us the cheap transport. Most viveworker
|
|
32
|
+
* API payloads are JSON to begin with, so the only thing we save by
|
|
33
|
+
* going binary is a few characters per request — not worth the parser
|
|
34
|
+
* surface or the loss of ad-hoc debuggability. Bodies are always
|
|
35
|
+
* strings; binary bodies (rare, e.g. /uploads) base64 themselves and
|
|
36
|
+
* set `bodyEncoding: "base64"`.
|
|
37
|
+
*
|
|
38
|
+
* Validation philosophy:
|
|
39
|
+
* We enforce types and a few bounds (max id length, max headers count,
|
|
40
|
+
* max body size) so a misbehaving peer can't DoS the dispatcher. Past
|
|
41
|
+
* those bounds we trust the peer because the Noise handshake already
|
|
42
|
+
* gave us mutual auth — there's no value in being defensive against a
|
|
43
|
+
* peer that's already inside the encrypted channel.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
const TEXT_ENCODER = new TextEncoder();
|
|
47
|
+
const TEXT_DECODER = new TextDecoder("utf-8", { fatal: true });
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Limits — defensive caps. Generous enough to never bite real traffic but
|
|
51
|
+
// tight enough to protect the dispatcher from a runaway peer.
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
/** Max ID length (UUIDs are 36 chars; 64 leaves room for prefixes). */
|
|
55
|
+
export const MAX_RPC_ID_LEN = 64;
|
|
56
|
+
|
|
57
|
+
/** Max number of header entries we accept. */
|
|
58
|
+
export const MAX_HEADERS = 64;
|
|
59
|
+
|
|
60
|
+
/** Max length of any single header name OR value. */
|
|
61
|
+
export const MAX_HEADER_LEN = 8 * 1024;
|
|
62
|
+
|
|
63
|
+
/** Max method string length (`GET`, `OPTIONS`, etc. — 16 covers everything reasonable). */
|
|
64
|
+
export const MAX_METHOD_LEN = 16;
|
|
65
|
+
|
|
66
|
+
/** Max URL path length. */
|
|
67
|
+
export const MAX_PATH_LEN = 4 * 1024;
|
|
68
|
+
|
|
69
|
+
/** Max body size — 4 MiB. Fits long-poll JSONs, inbox dumps, x402 payloads. */
|
|
70
|
+
export const MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
71
|
+
|
|
72
|
+
/** Max topic name length (server-pushed events). */
|
|
73
|
+
export const MAX_TOPIC_LEN = 128;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Frame type discriminators. Frozen so application code can compare with
|
|
77
|
+
* `===` and not worry about typos surviving as silently-different strings.
|
|
78
|
+
*/
|
|
79
|
+
export const RPC = Object.freeze({
|
|
80
|
+
REQUEST: "req",
|
|
81
|
+
RESPONSE: "res",
|
|
82
|
+
CANCEL: "cancel",
|
|
83
|
+
EVENT: "evt",
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const VALID_TYPES = new Set([RPC.REQUEST, RPC.RESPONSE, RPC.CANCEL, RPC.EVENT]);
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Encoders
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @typedef {Object} RpcRequest
|
|
94
|
+
* @property {string} id client-chosen correlation id
|
|
95
|
+
* @property {string} method "GET" | "POST" | …
|
|
96
|
+
* @property {string} path URL path including query, e.g. "/api/x?foo=1"
|
|
97
|
+
* @property {Record<string,string>} [headers]
|
|
98
|
+
* @property {string} [body] UTF-8 string; binary callers must base64 + set bodyEncoding
|
|
99
|
+
* @property {"utf8" | "base64"} [bodyEncoding]
|
|
100
|
+
*/
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @typedef {Object} RpcResponse
|
|
104
|
+
* @property {string} id
|
|
105
|
+
* @property {number} status HTTP status code 100..599
|
|
106
|
+
* @property {Record<string,string>} [headers]
|
|
107
|
+
* @property {string} [body]
|
|
108
|
+
* @property {"utf8" | "base64"} [bodyEncoding]
|
|
109
|
+
*/
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @typedef {Object} RpcCancel
|
|
113
|
+
* @property {string} id
|
|
114
|
+
*/
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @typedef {Object} RpcEvent
|
|
118
|
+
* @property {string} topic
|
|
119
|
+
* @property {unknown} [data]
|
|
120
|
+
*/
|
|
121
|
+
|
|
122
|
+
/** @param {RpcRequest} req @returns {Uint8Array} */
|
|
123
|
+
export function encodeRequest(req) {
|
|
124
|
+
if (typeof req?.id !== "string") throw new TypeError("request.id required (string)");
|
|
125
|
+
if (typeof req.method !== "string") throw new TypeError("request.method required (string)");
|
|
126
|
+
if (typeof req.path !== "string") throw new TypeError("request.path required (string)");
|
|
127
|
+
assertIdLen(req.id);
|
|
128
|
+
assertMethod(req.method);
|
|
129
|
+
assertPath(req.path);
|
|
130
|
+
const headers = normalizeHeaders(req.headers);
|
|
131
|
+
const { body, bodyEncoding } = normalizeBody(req.body, req.bodyEncoding);
|
|
132
|
+
|
|
133
|
+
const obj = { t: RPC.REQUEST, id: req.id, method: req.method, path: req.path };
|
|
134
|
+
if (headers) obj.headers = headers;
|
|
135
|
+
if (body !== undefined) {
|
|
136
|
+
obj.body = body;
|
|
137
|
+
if (bodyEncoding && bodyEncoding !== "utf8") obj.bodyEncoding = bodyEncoding;
|
|
138
|
+
}
|
|
139
|
+
return encodeJson(obj);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** @param {RpcResponse} res @returns {Uint8Array} */
|
|
143
|
+
export function encodeResponse(res) {
|
|
144
|
+
if (typeof res?.id !== "string") throw new TypeError("response.id required (string)");
|
|
145
|
+
if (!Number.isInteger(res.status) || res.status < 100 || res.status > 599) {
|
|
146
|
+
throw new RangeError("response.status must be an integer in [100, 599]");
|
|
147
|
+
}
|
|
148
|
+
assertIdLen(res.id);
|
|
149
|
+
const headers = normalizeHeaders(res.headers);
|
|
150
|
+
const { body, bodyEncoding } = normalizeBody(res.body, res.bodyEncoding);
|
|
151
|
+
|
|
152
|
+
const obj = { t: RPC.RESPONSE, id: res.id, status: res.status };
|
|
153
|
+
if (headers) obj.headers = headers;
|
|
154
|
+
if (body !== undefined) {
|
|
155
|
+
obj.body = body;
|
|
156
|
+
if (bodyEncoding && bodyEncoding !== "utf8") obj.bodyEncoding = bodyEncoding;
|
|
157
|
+
}
|
|
158
|
+
return encodeJson(obj);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** @param {string} id @returns {Uint8Array} */
|
|
162
|
+
export function encodeCancel(id) {
|
|
163
|
+
if (typeof id !== "string") throw new TypeError("cancel.id required (string)");
|
|
164
|
+
assertIdLen(id);
|
|
165
|
+
return encodeJson({ t: RPC.CANCEL, id });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** @param {RpcEvent} ev @returns {Uint8Array} */
|
|
169
|
+
export function encodeEvent(ev) {
|
|
170
|
+
if (typeof ev?.topic !== "string") throw new TypeError("event.topic required (string)");
|
|
171
|
+
if (ev.topic.length > MAX_TOPIC_LEN) {
|
|
172
|
+
throw new RangeError(`event.topic exceeds ${MAX_TOPIC_LEN} chars`);
|
|
173
|
+
}
|
|
174
|
+
const obj = { t: RPC.EVENT, topic: ev.topic };
|
|
175
|
+
if (ev.data !== undefined) obj.data = ev.data;
|
|
176
|
+
return encodeJson(obj);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Decoder
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Decode a Noise plaintext frame into a typed RPC object. Throws on
|
|
185
|
+
* malformed JSON, missing required fields, or out-of-bounds values.
|
|
186
|
+
*
|
|
187
|
+
* @param {Uint8Array} bytes
|
|
188
|
+
* @returns {{type: "req"} & RpcRequest
|
|
189
|
+
* | {type: "res"} & RpcResponse
|
|
190
|
+
* | {type: "cancel"} & RpcCancel
|
|
191
|
+
* | {type: "evt"} & RpcEvent}
|
|
192
|
+
*/
|
|
193
|
+
export function decode(bytes) {
|
|
194
|
+
if (!(bytes instanceof Uint8Array)) {
|
|
195
|
+
throw new TypeError("decode requires Uint8Array");
|
|
196
|
+
}
|
|
197
|
+
if (bytes.length > MAX_BODY_BYTES + 64 * 1024) {
|
|
198
|
+
// 64 KiB headroom for envelope JSON beyond the body itself.
|
|
199
|
+
throw new RangeError(`rpc frame too large: ${bytes.length} bytes`);
|
|
200
|
+
}
|
|
201
|
+
let str;
|
|
202
|
+
try {
|
|
203
|
+
str = TEXT_DECODER.decode(bytes);
|
|
204
|
+
} catch (err) {
|
|
205
|
+
throw new Error(`rpc frame: invalid UTF-8 (${err.message})`);
|
|
206
|
+
}
|
|
207
|
+
let obj;
|
|
208
|
+
try {
|
|
209
|
+
obj = JSON.parse(str);
|
|
210
|
+
} catch (err) {
|
|
211
|
+
throw new Error(`rpc frame: invalid JSON (${err.message})`);
|
|
212
|
+
}
|
|
213
|
+
if (obj == null || typeof obj !== "object" || Array.isArray(obj)) {
|
|
214
|
+
throw new Error("rpc frame: not a JSON object");
|
|
215
|
+
}
|
|
216
|
+
const type = obj.t;
|
|
217
|
+
if (typeof type !== "string" || !VALID_TYPES.has(type)) {
|
|
218
|
+
throw new Error(`rpc frame: unknown type ${JSON.stringify(type)}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
switch (type) {
|
|
222
|
+
case RPC.REQUEST: return decodeRequest(obj);
|
|
223
|
+
case RPC.RESPONSE: return decodeResponse(obj);
|
|
224
|
+
case RPC.CANCEL: return decodeCancel(obj);
|
|
225
|
+
case RPC.EVENT: return decodeEvent(obj);
|
|
226
|
+
}
|
|
227
|
+
// Unreachable — switch above is exhaustive over VALID_TYPES.
|
|
228
|
+
throw new Error("unreachable");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function decodeRequest(obj) {
|
|
232
|
+
const id = requireStr(obj.id, "id");
|
|
233
|
+
const method = requireStr(obj.method, "method");
|
|
234
|
+
const path = requireStr(obj.path, "path");
|
|
235
|
+
assertIdLen(id);
|
|
236
|
+
assertMethod(method);
|
|
237
|
+
assertPath(path);
|
|
238
|
+
const headers = decodeHeaders(obj.headers);
|
|
239
|
+
const { body, bodyEncoding } = decodeBody(obj);
|
|
240
|
+
return { type: RPC.REQUEST, id, method, path, headers, body, bodyEncoding };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function decodeResponse(obj) {
|
|
244
|
+
const id = requireStr(obj.id, "id");
|
|
245
|
+
assertIdLen(id);
|
|
246
|
+
const status = obj.status;
|
|
247
|
+
if (!Number.isInteger(status) || status < 100 || status > 599) {
|
|
248
|
+
throw new RangeError("response.status must be an integer in [100, 599]");
|
|
249
|
+
}
|
|
250
|
+
const headers = decodeHeaders(obj.headers);
|
|
251
|
+
const { body, bodyEncoding } = decodeBody(obj);
|
|
252
|
+
return { type: RPC.RESPONSE, id, status, headers, body, bodyEncoding };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function decodeCancel(obj) {
|
|
256
|
+
const id = requireStr(obj.id, "id");
|
|
257
|
+
assertIdLen(id);
|
|
258
|
+
return { type: RPC.CANCEL, id };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function decodeEvent(obj) {
|
|
262
|
+
const topic = requireStr(obj.topic, "topic");
|
|
263
|
+
if (topic.length > MAX_TOPIC_LEN) {
|
|
264
|
+
throw new RangeError(`event.topic exceeds ${MAX_TOPIC_LEN} chars`);
|
|
265
|
+
}
|
|
266
|
+
return { type: RPC.EVENT, topic, data: obj.data };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
// Helpers
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
function requireStr(v, name) {
|
|
274
|
+
if (typeof v !== "string") throw new TypeError(`${name} required (string)`);
|
|
275
|
+
return v;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function assertIdLen(id) {
|
|
279
|
+
if (id.length === 0) throw new RangeError("id must be non-empty");
|
|
280
|
+
if (id.length > MAX_RPC_ID_LEN) throw new RangeError(`id exceeds ${MAX_RPC_ID_LEN} chars`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function assertMethod(m) {
|
|
284
|
+
if (m.length === 0) throw new RangeError("method must be non-empty");
|
|
285
|
+
if (m.length > MAX_METHOD_LEN) throw new RangeError(`method exceeds ${MAX_METHOD_LEN} chars`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function assertPath(p) {
|
|
289
|
+
if (!p.startsWith("/")) throw new RangeError(`path must start with "/" (got ${JSON.stringify(p.slice(0, 32))})`);
|
|
290
|
+
if (p.length > MAX_PATH_LEN) throw new RangeError(`path exceeds ${MAX_PATH_LEN} chars`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Normalize headers for encoding. Returns a plain object or undefined.
|
|
295
|
+
* Lowercases keys (matches Node's IncomingMessage convention) and rejects
|
|
296
|
+
* non-string values.
|
|
297
|
+
*/
|
|
298
|
+
function normalizeHeaders(headers) {
|
|
299
|
+
if (headers == null) return undefined;
|
|
300
|
+
if (typeof headers !== "object" || Array.isArray(headers)) {
|
|
301
|
+
throw new TypeError("headers must be a plain object");
|
|
302
|
+
}
|
|
303
|
+
const entries = Object.entries(headers);
|
|
304
|
+
if (entries.length === 0) return undefined;
|
|
305
|
+
if (entries.length > MAX_HEADERS) {
|
|
306
|
+
throw new RangeError(`headers exceeds ${MAX_HEADERS} entries`);
|
|
307
|
+
}
|
|
308
|
+
const out = {};
|
|
309
|
+
for (const [k, v] of entries) {
|
|
310
|
+
if (typeof k !== "string" || typeof v !== "string") {
|
|
311
|
+
throw new TypeError(`headers must be string→string (got ${k}=${typeof v})`);
|
|
312
|
+
}
|
|
313
|
+
if (k.length > MAX_HEADER_LEN || v.length > MAX_HEADER_LEN) {
|
|
314
|
+
throw new RangeError(`header ${JSON.stringify(k)} exceeds ${MAX_HEADER_LEN} chars`);
|
|
315
|
+
}
|
|
316
|
+
out[k.toLowerCase()] = v;
|
|
317
|
+
}
|
|
318
|
+
return out;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function decodeHeaders(raw) {
|
|
322
|
+
if (raw == null) return {};
|
|
323
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
324
|
+
throw new Error("headers must be a JSON object");
|
|
325
|
+
}
|
|
326
|
+
const entries = Object.entries(raw);
|
|
327
|
+
if (entries.length > MAX_HEADERS) {
|
|
328
|
+
throw new RangeError(`headers exceeds ${MAX_HEADERS} entries`);
|
|
329
|
+
}
|
|
330
|
+
const out = {};
|
|
331
|
+
for (const [k, v] of entries) {
|
|
332
|
+
if (typeof k !== "string" || typeof v !== "string") {
|
|
333
|
+
throw new Error(`headers must be string→string (got ${k}=${typeof v})`);
|
|
334
|
+
}
|
|
335
|
+
if (k.length > MAX_HEADER_LEN || v.length > MAX_HEADER_LEN) {
|
|
336
|
+
throw new RangeError(`header ${JSON.stringify(k)} exceeds ${MAX_HEADER_LEN} chars`);
|
|
337
|
+
}
|
|
338
|
+
out[k.toLowerCase()] = v;
|
|
339
|
+
}
|
|
340
|
+
return out;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function normalizeBody(body, bodyEncoding) {
|
|
344
|
+
if (body === undefined || body === null || body === "") return { body: undefined };
|
|
345
|
+
if (typeof body !== "string") {
|
|
346
|
+
throw new TypeError("body must be a string (binary callers: base64-encode and set bodyEncoding=\"base64\")");
|
|
347
|
+
}
|
|
348
|
+
if (bodyEncoding != null && bodyEncoding !== "utf8" && bodyEncoding !== "base64") {
|
|
349
|
+
throw new RangeError(`bodyEncoding must be "utf8" or "base64" (got ${JSON.stringify(bodyEncoding)})`);
|
|
350
|
+
}
|
|
351
|
+
// Approximate size check on the encoded JSON. We allow the body string
|
|
352
|
+
// to be up to MAX_BODY_BYTES — JSON.stringify's escaping can add a few
|
|
353
|
+
// bytes, but the outer bytes-length check in `decode` is the real cap.
|
|
354
|
+
if (body.length > MAX_BODY_BYTES) {
|
|
355
|
+
throw new RangeError(`body exceeds ${MAX_BODY_BYTES} chars`);
|
|
356
|
+
}
|
|
357
|
+
return { body, bodyEncoding: bodyEncoding ?? "utf8" };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function decodeBody(obj) {
|
|
361
|
+
if (obj.body === undefined) return { body: undefined };
|
|
362
|
+
if (typeof obj.body !== "string") throw new Error("body must be a string");
|
|
363
|
+
if (obj.body.length > MAX_BODY_BYTES) {
|
|
364
|
+
throw new RangeError(`body exceeds ${MAX_BODY_BYTES} chars`);
|
|
365
|
+
}
|
|
366
|
+
let bodyEncoding = "utf8";
|
|
367
|
+
if (obj.bodyEncoding !== undefined) {
|
|
368
|
+
if (obj.bodyEncoding !== "utf8" && obj.bodyEncoding !== "base64") {
|
|
369
|
+
throw new Error(`bodyEncoding must be "utf8" or "base64"`);
|
|
370
|
+
}
|
|
371
|
+
bodyEncoding = obj.bodyEncoding;
|
|
372
|
+
}
|
|
373
|
+
return { body: obj.body, bodyEncoding };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function encodeJson(obj) {
|
|
377
|
+
// JSON.stringify rejects undefined values automatically; we explicitly
|
|
378
|
+
// omit them above. Use TextEncoder so the wire is canonical UTF-8 (no
|
|
379
|
+
// BOM, no surprise locale handling).
|
|
380
|
+
return TEXT_ENCODER.encode(JSON.stringify(obj));
|
|
381
|
+
}
|