@oxy.so/federation 2.5.0 → 2.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/dist/cjs/node/actorResolver.js +10 -21
- package/dist/cjs/node/boundedBody.js +77 -0
- package/dist/cjs/node/delivery.js +15 -11
- package/dist/esm/node/actorResolver.js +10 -21
- package/dist/esm/node/boundedBody.js +71 -0
- package/dist/esm/node/delivery.js +15 -11
- package/dist/types/node/boundedBody.d.ts +50 -0
- package/package.json +1 -1
- package/src/__tests__/boundedBody.test.ts +57 -0
- package/src/node/actorResolver.ts +14 -19
- package/src/node/boundedBody.ts +96 -0
- package/src/node/delivery.ts +18 -12
|
@@ -24,6 +24,7 @@ exports.ActorResolver = void 0;
|
|
|
24
24
|
exports.createActorResolver = createActorResolver;
|
|
25
25
|
const apUri_1 = require("../apUri");
|
|
26
26
|
const networkIdentity_1 = require("../networkIdentity");
|
|
27
|
+
const boundedBody_1 = require("./boundedBody");
|
|
27
28
|
/**
|
|
28
29
|
* Minimum interval between background actor refreshes for the same actor.
|
|
29
30
|
* Prevents refresh storms when a profile is viewed repeatedly in a short window.
|
|
@@ -43,38 +44,26 @@ const COLLECTION_BODY_MAX_BYTES = 64 * 1024;
|
|
|
43
44
|
*/
|
|
44
45
|
const COLLECTION_WITHHELD_STATUSES = new Set([401, 403, 404, 410]);
|
|
45
46
|
const ERROR_BODY_MAX_BYTES = 4 * 1024;
|
|
47
|
+
/**
|
|
48
|
+
* Deadline for reading a whole actor/collection/error body once its headers
|
|
49
|
+
* arrived. `signedFetch`'s timeout ends at the headers, so without this a remote
|
|
50
|
+
* that stalls mid-body would hold the resolver open.
|
|
51
|
+
*/
|
|
52
|
+
const BODY_TIMEOUT_MS = 10000;
|
|
46
53
|
async function readBoundedResponseBody(res, maxBytes) {
|
|
47
54
|
const contentLength = res.headers.get('content-length');
|
|
48
55
|
if (contentLength) {
|
|
49
56
|
const declaredBytes = Number(contentLength);
|
|
50
57
|
if (Number.isFinite(declaredBytes) && declaredBytes > maxBytes) {
|
|
51
58
|
await res.body?.cancel().catch(() => { });
|
|
52
|
-
throw new
|
|
59
|
+
throw new boundedBody_1.ResponseBodyTooLargeError(maxBytes);
|
|
53
60
|
}
|
|
54
61
|
}
|
|
55
62
|
if (!res.body)
|
|
56
63
|
return '';
|
|
57
64
|
const reader = res.body.getReader();
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
let text = '';
|
|
61
|
-
try {
|
|
62
|
-
while (true) {
|
|
63
|
-
const { done, value } = await reader.read();
|
|
64
|
-
if (done)
|
|
65
|
-
break;
|
|
66
|
-
bytesRead += value.byteLength;
|
|
67
|
-
if (bytesRead > maxBytes) {
|
|
68
|
-
await reader.cancel().catch(() => { });
|
|
69
|
-
throw new Error(`Remote response exceeds ${maxBytes} byte limit`);
|
|
70
|
-
}
|
|
71
|
-
text += decoder.decode(value, { stream: true });
|
|
72
|
-
}
|
|
73
|
-
return text + decoder.decode();
|
|
74
|
-
}
|
|
75
|
-
finally {
|
|
76
|
-
reader.releaseLock();
|
|
77
|
-
}
|
|
65
|
+
const body = await (0, boundedBody_1.readBodyChunks)({ read: () => reader.read(), cancel: () => void reader.cancel().catch(() => { }) }, { maxBytes, timeoutMs: BODY_TIMEOUT_MS });
|
|
66
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(body);
|
|
78
67
|
}
|
|
79
68
|
async function readBoundedJson(res, maxBytes) {
|
|
80
69
|
const body = await readBoundedResponseBody(res, maxBytes);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The one bounded, deadlined body read behind every federation response body
|
|
4
|
+
* (an actor or collection `Response`, a failed delivery's preview).
|
|
5
|
+
*
|
|
6
|
+
* The transports bound a request up to its response headers; from there the
|
|
7
|
+
* reader owns the stream, and a remote that sends headers and then stalls (or
|
|
8
|
+
* trickles) the body would hold a plain read loop forever. This caps the bytes
|
|
9
|
+
* AND gives the whole body one deadline, cancelling the source on every early
|
|
10
|
+
* exit. It reads through a {@link BodyChunkSource} so a WHATWG reader and a
|
|
11
|
+
* Node async iterator share it.
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.ResponseBodyTimeoutError = exports.ResponseBodyTooLargeError = void 0;
|
|
15
|
+
exports.readBodyChunks = readBodyChunks;
|
|
16
|
+
/** Thrown when a body exceeds its byte cap under `overflow: 'reject'`. */
|
|
17
|
+
class ResponseBodyTooLargeError extends Error {
|
|
18
|
+
constructor(maxBytes) {
|
|
19
|
+
super(`Remote response exceeds ${maxBytes} byte limit`);
|
|
20
|
+
this.maxBytes = maxBytes;
|
|
21
|
+
this.name = 'ResponseBodyTooLargeError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
exports.ResponseBodyTooLargeError = ResponseBodyTooLargeError;
|
|
25
|
+
/** Thrown when a body does not end within its deadline; this attempt failed, a retry may not. */
|
|
26
|
+
class ResponseBodyTimeoutError extends Error {
|
|
27
|
+
constructor(timeoutMs) {
|
|
28
|
+
super(`Remote response body did not end within ${timeoutMs} ms`);
|
|
29
|
+
this.timeoutMs = timeoutMs;
|
|
30
|
+
this.name = 'ResponseBodyTimeoutError';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
exports.ResponseBodyTimeoutError = ResponseBodyTimeoutError;
|
|
34
|
+
/**
|
|
35
|
+
* Read `source` into one Buffer, at most `maxBytes` and within `timeoutMs`.
|
|
36
|
+
* The deadline timer is cleared on every path; the source is cancelled on a
|
|
37
|
+
* cap, a deadline, a read error, or a truncated preview.
|
|
38
|
+
*/
|
|
39
|
+
async function readBodyChunks(source, limits) {
|
|
40
|
+
const { maxBytes, timeoutMs, overflow = 'reject' } = limits;
|
|
41
|
+
const chunks = [];
|
|
42
|
+
let total = 0;
|
|
43
|
+
let timer;
|
|
44
|
+
const deadline = new Promise((_, reject) => {
|
|
45
|
+
timer = setTimeout(() => reject(new ResponseBodyTimeoutError(timeoutMs)), timeoutMs);
|
|
46
|
+
});
|
|
47
|
+
try {
|
|
48
|
+
for (;;) {
|
|
49
|
+
// `Promise.race` subscribes to both, so neither the deadline nor a read
|
|
50
|
+
// that rejects once the source is cancelled goes unhandled.
|
|
51
|
+
const { done, value } = await Promise.race([source.read(), deadline]);
|
|
52
|
+
if (done || !value)
|
|
53
|
+
break;
|
|
54
|
+
const buffer = Buffer.isBuffer(value)
|
|
55
|
+
? value
|
|
56
|
+
: Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
|
57
|
+
if (total + buffer.length > maxBytes) {
|
|
58
|
+
if (overflow === 'reject')
|
|
59
|
+
throw new ResponseBodyTooLargeError(maxBytes);
|
|
60
|
+
chunks.push(buffer.subarray(0, maxBytes - total));
|
|
61
|
+
total = maxBytes;
|
|
62
|
+
source.cancel();
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
total += buffer.length;
|
|
66
|
+
chunks.push(buffer);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
source.cancel();
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
}
|
|
76
|
+
return Buffer.concat(chunks, total);
|
|
77
|
+
}
|
|
@@ -28,11 +28,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
28
28
|
exports.DeliveryAuthorizationDeniedError = void 0;
|
|
29
29
|
exports.createDeliveryService = createDeliveryService;
|
|
30
30
|
const apContext_1 = require("../apContext");
|
|
31
|
+
const boundedBody_1 = require("./boundedBody");
|
|
31
32
|
const httpSignature_1 = require("../httpSignature");
|
|
32
33
|
/** Total time budget for a single delivery POST (connect + response headers). */
|
|
33
34
|
const DELIVER_ACTIVITY_TIMEOUT_MS = 15000;
|
|
34
35
|
/** How many bytes of a failed-delivery response body are read for the debug log. */
|
|
35
36
|
const DELIVERY_RESPONSE_PREVIEW_MAX_BYTES = 1024;
|
|
37
|
+
/** Deadline for reading that preview once the response headers arrived. */
|
|
38
|
+
const DELIVERY_RESPONSE_PREVIEW_TIMEOUT_MS = 5000;
|
|
36
39
|
/** The ActivityStreams public collection — the `to` addressee of a public activity. */
|
|
37
40
|
const AP_PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
|
|
38
41
|
/** A definitive refusal: workers must discard, not retry or count a delivery. */
|
|
@@ -44,18 +47,20 @@ class DeliveryAuthorizationDeniedError extends Error {
|
|
|
44
47
|
}
|
|
45
48
|
}
|
|
46
49
|
exports.DeliveryAuthorizationDeniedError = DeliveryAuthorizationDeniedError;
|
|
47
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* Read a bounded prefix of a failed-delivery response body for the debug log,
|
|
52
|
+
* within {@link DELIVERY_RESPONSE_PREVIEW_TIMEOUT_MS}: an inbox that stalls its
|
|
53
|
+
* body after the headers costs the worker that long, not forever.
|
|
54
|
+
*/
|
|
48
55
|
async function readResponsePreview(response) {
|
|
49
|
-
const
|
|
50
|
-
let totalBytes = 0;
|
|
56
|
+
const iterator = response[Symbol.asyncIterator]();
|
|
51
57
|
try {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
58
|
+
const preview = await (0, boundedBody_1.readBodyChunks)({ read: () => iterator.next(), cancel: () => response.destroy() }, {
|
|
59
|
+
maxBytes: DELIVERY_RESPONSE_PREVIEW_MAX_BYTES,
|
|
60
|
+
timeoutMs: DELIVERY_RESPONSE_PREVIEW_TIMEOUT_MS,
|
|
61
|
+
overflow: 'truncate',
|
|
62
|
+
});
|
|
63
|
+
return preview.toString('utf8');
|
|
59
64
|
}
|
|
60
65
|
catch {
|
|
61
66
|
return '';
|
|
@@ -63,7 +68,6 @@ async function readResponsePreview(response) {
|
|
|
63
68
|
finally {
|
|
64
69
|
response.destroy();
|
|
65
70
|
}
|
|
66
|
-
return Buffer.concat(chunks).toString('utf8', 0, DELIVERY_RESPONSE_PREVIEW_MAX_BYTES);
|
|
67
71
|
}
|
|
68
72
|
/** Build the outbound delivery + follow-lifecycle service from an app's adapters. */
|
|
69
73
|
function createDeliveryService(config) {
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { isSameFederationHost } from '../apUri.js';
|
|
22
22
|
import { readProxyDeclarations, } from '../networkIdentity.js';
|
|
23
|
+
import { readBodyChunks, ResponseBodyTooLargeError } from './boundedBody.js';
|
|
23
24
|
/**
|
|
24
25
|
* Minimum interval between background actor refreshes for the same actor.
|
|
25
26
|
* Prevents refresh storms when a profile is viewed repeatedly in a short window.
|
|
@@ -39,38 +40,26 @@ const COLLECTION_BODY_MAX_BYTES = 64 * 1024;
|
|
|
39
40
|
*/
|
|
40
41
|
const COLLECTION_WITHHELD_STATUSES = new Set([401, 403, 404, 410]);
|
|
41
42
|
const ERROR_BODY_MAX_BYTES = 4 * 1024;
|
|
43
|
+
/**
|
|
44
|
+
* Deadline for reading a whole actor/collection/error body once its headers
|
|
45
|
+
* arrived. `signedFetch`'s timeout ends at the headers, so without this a remote
|
|
46
|
+
* that stalls mid-body would hold the resolver open.
|
|
47
|
+
*/
|
|
48
|
+
const BODY_TIMEOUT_MS = 10000;
|
|
42
49
|
async function readBoundedResponseBody(res, maxBytes) {
|
|
43
50
|
const contentLength = res.headers.get('content-length');
|
|
44
51
|
if (contentLength) {
|
|
45
52
|
const declaredBytes = Number(contentLength);
|
|
46
53
|
if (Number.isFinite(declaredBytes) && declaredBytes > maxBytes) {
|
|
47
54
|
await res.body?.cancel().catch(() => { });
|
|
48
|
-
throw new
|
|
55
|
+
throw new ResponseBodyTooLargeError(maxBytes);
|
|
49
56
|
}
|
|
50
57
|
}
|
|
51
58
|
if (!res.body)
|
|
52
59
|
return '';
|
|
53
60
|
const reader = res.body.getReader();
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
let text = '';
|
|
57
|
-
try {
|
|
58
|
-
while (true) {
|
|
59
|
-
const { done, value } = await reader.read();
|
|
60
|
-
if (done)
|
|
61
|
-
break;
|
|
62
|
-
bytesRead += value.byteLength;
|
|
63
|
-
if (bytesRead > maxBytes) {
|
|
64
|
-
await reader.cancel().catch(() => { });
|
|
65
|
-
throw new Error(`Remote response exceeds ${maxBytes} byte limit`);
|
|
66
|
-
}
|
|
67
|
-
text += decoder.decode(value, { stream: true });
|
|
68
|
-
}
|
|
69
|
-
return text + decoder.decode();
|
|
70
|
-
}
|
|
71
|
-
finally {
|
|
72
|
-
reader.releaseLock();
|
|
73
|
-
}
|
|
61
|
+
const body = await readBodyChunks({ read: () => reader.read(), cancel: () => void reader.cancel().catch(() => { }) }, { maxBytes, timeoutMs: BODY_TIMEOUT_MS });
|
|
62
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(body);
|
|
74
63
|
}
|
|
75
64
|
async function readBoundedJson(res, maxBytes) {
|
|
76
65
|
const body = await readBoundedResponseBody(res, maxBytes);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one bounded, deadlined body read behind every federation response body
|
|
3
|
+
* (an actor or collection `Response`, a failed delivery's preview).
|
|
4
|
+
*
|
|
5
|
+
* The transports bound a request up to its response headers; from there the
|
|
6
|
+
* reader owns the stream, and a remote that sends headers and then stalls (or
|
|
7
|
+
* trickles) the body would hold a plain read loop forever. This caps the bytes
|
|
8
|
+
* AND gives the whole body one deadline, cancelling the source on every early
|
|
9
|
+
* exit. It reads through a {@link BodyChunkSource} so a WHATWG reader and a
|
|
10
|
+
* Node async iterator share it.
|
|
11
|
+
*/
|
|
12
|
+
/** Thrown when a body exceeds its byte cap under `overflow: 'reject'`. */
|
|
13
|
+
export class ResponseBodyTooLargeError extends Error {
|
|
14
|
+
constructor(maxBytes) {
|
|
15
|
+
super(`Remote response exceeds ${maxBytes} byte limit`);
|
|
16
|
+
this.maxBytes = maxBytes;
|
|
17
|
+
this.name = 'ResponseBodyTooLargeError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Thrown when a body does not end within its deadline; this attempt failed, a retry may not. */
|
|
21
|
+
export class ResponseBodyTimeoutError extends Error {
|
|
22
|
+
constructor(timeoutMs) {
|
|
23
|
+
super(`Remote response body did not end within ${timeoutMs} ms`);
|
|
24
|
+
this.timeoutMs = timeoutMs;
|
|
25
|
+
this.name = 'ResponseBodyTimeoutError';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Read `source` into one Buffer, at most `maxBytes` and within `timeoutMs`.
|
|
30
|
+
* The deadline timer is cleared on every path; the source is cancelled on a
|
|
31
|
+
* cap, a deadline, a read error, or a truncated preview.
|
|
32
|
+
*/
|
|
33
|
+
export async function readBodyChunks(source, limits) {
|
|
34
|
+
const { maxBytes, timeoutMs, overflow = 'reject' } = limits;
|
|
35
|
+
const chunks = [];
|
|
36
|
+
let total = 0;
|
|
37
|
+
let timer;
|
|
38
|
+
const deadline = new Promise((_, reject) => {
|
|
39
|
+
timer = setTimeout(() => reject(new ResponseBodyTimeoutError(timeoutMs)), timeoutMs);
|
|
40
|
+
});
|
|
41
|
+
try {
|
|
42
|
+
for (;;) {
|
|
43
|
+
// `Promise.race` subscribes to both, so neither the deadline nor a read
|
|
44
|
+
// that rejects once the source is cancelled goes unhandled.
|
|
45
|
+
const { done, value } = await Promise.race([source.read(), deadline]);
|
|
46
|
+
if (done || !value)
|
|
47
|
+
break;
|
|
48
|
+
const buffer = Buffer.isBuffer(value)
|
|
49
|
+
? value
|
|
50
|
+
: Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
|
51
|
+
if (total + buffer.length > maxBytes) {
|
|
52
|
+
if (overflow === 'reject')
|
|
53
|
+
throw new ResponseBodyTooLargeError(maxBytes);
|
|
54
|
+
chunks.push(buffer.subarray(0, maxBytes - total));
|
|
55
|
+
total = maxBytes;
|
|
56
|
+
source.cancel();
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
total += buffer.length;
|
|
60
|
+
chunks.push(buffer);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
source.cancel();
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
}
|
|
70
|
+
return Buffer.concat(chunks, total);
|
|
71
|
+
}
|
|
@@ -24,11 +24,14 @@
|
|
|
24
24
|
* call `deliverToFollowers` / `deliverActivity` / `queueDelivery` here.
|
|
25
25
|
*/
|
|
26
26
|
import { AP_CONTEXT } from '../apContext.js';
|
|
27
|
+
import { readBodyChunks } from './boundedBody.js';
|
|
27
28
|
import { signRequest } from '../httpSignature.js';
|
|
28
29
|
/** Total time budget for a single delivery POST (connect + response headers). */
|
|
29
30
|
const DELIVER_ACTIVITY_TIMEOUT_MS = 15000;
|
|
30
31
|
/** How many bytes of a failed-delivery response body are read for the debug log. */
|
|
31
32
|
const DELIVERY_RESPONSE_PREVIEW_MAX_BYTES = 1024;
|
|
33
|
+
/** Deadline for reading that preview once the response headers arrived. */
|
|
34
|
+
const DELIVERY_RESPONSE_PREVIEW_TIMEOUT_MS = 5000;
|
|
32
35
|
/** The ActivityStreams public collection — the `to` addressee of a public activity. */
|
|
33
36
|
const AP_PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
|
|
34
37
|
/** A definitive refusal: workers must discard, not retry or count a delivery. */
|
|
@@ -39,18 +42,20 @@ export class DeliveryAuthorizationDeniedError extends Error {
|
|
|
39
42
|
this.name = 'DeliveryAuthorizationDeniedError';
|
|
40
43
|
}
|
|
41
44
|
}
|
|
42
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* Read a bounded prefix of a failed-delivery response body for the debug log,
|
|
47
|
+
* within {@link DELIVERY_RESPONSE_PREVIEW_TIMEOUT_MS}: an inbox that stalls its
|
|
48
|
+
* body after the headers costs the worker that long, not forever.
|
|
49
|
+
*/
|
|
43
50
|
async function readResponsePreview(response) {
|
|
44
|
-
const
|
|
45
|
-
let totalBytes = 0;
|
|
51
|
+
const iterator = response[Symbol.asyncIterator]();
|
|
46
52
|
try {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
53
|
+
const preview = await readBodyChunks({ read: () => iterator.next(), cancel: () => response.destroy() }, {
|
|
54
|
+
maxBytes: DELIVERY_RESPONSE_PREVIEW_MAX_BYTES,
|
|
55
|
+
timeoutMs: DELIVERY_RESPONSE_PREVIEW_TIMEOUT_MS,
|
|
56
|
+
overflow: 'truncate',
|
|
57
|
+
});
|
|
58
|
+
return preview.toString('utf8');
|
|
54
59
|
}
|
|
55
60
|
catch {
|
|
56
61
|
return '';
|
|
@@ -58,7 +63,6 @@ async function readResponsePreview(response) {
|
|
|
58
63
|
finally {
|
|
59
64
|
response.destroy();
|
|
60
65
|
}
|
|
61
|
-
return Buffer.concat(chunks).toString('utf8', 0, DELIVERY_RESPONSE_PREVIEW_MAX_BYTES);
|
|
62
66
|
}
|
|
63
67
|
/** Build the outbound delivery + follow-lifecycle service from an app's adapters. */
|
|
64
68
|
export function createDeliveryService(config) {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one bounded, deadlined body read behind every federation response body
|
|
3
|
+
* (an actor or collection `Response`, a failed delivery's preview).
|
|
4
|
+
*
|
|
5
|
+
* The transports bound a request up to its response headers; from there the
|
|
6
|
+
* reader owns the stream, and a remote that sends headers and then stalls (or
|
|
7
|
+
* trickles) the body would hold a plain read loop forever. This caps the bytes
|
|
8
|
+
* AND gives the whole body one deadline, cancelling the source on every early
|
|
9
|
+
* exit. It reads through a {@link BodyChunkSource} so a WHATWG reader and a
|
|
10
|
+
* Node async iterator share it.
|
|
11
|
+
*/
|
|
12
|
+
/** One `read()` step: a WHATWG `ReadableStreamDefaultReader` and an async iterator both return it. */
|
|
13
|
+
interface BodyChunk {
|
|
14
|
+
done?: boolean;
|
|
15
|
+
value?: Uint8Array;
|
|
16
|
+
}
|
|
17
|
+
/** Where the chunks come from, and how to release the stream when a read ends early. */
|
|
18
|
+
export interface BodyChunkSource {
|
|
19
|
+
read(): Promise<BodyChunk>;
|
|
20
|
+
cancel(): void;
|
|
21
|
+
}
|
|
22
|
+
/** Limits for {@link readBodyChunks}. */
|
|
23
|
+
export interface BodyReadLimits {
|
|
24
|
+
/** Largest body accepted, in bytes. */
|
|
25
|
+
maxBytes: number;
|
|
26
|
+
/** Deadline for the WHOLE body, from the call until the source ends, in milliseconds. */
|
|
27
|
+
timeoutMs: number;
|
|
28
|
+
/**
|
|
29
|
+
* `reject` (the default) throws {@link ResponseBodyTooLargeError} past the cap;
|
|
30
|
+
* `truncate` stops there and returns the first `maxBytes` (a preview).
|
|
31
|
+
*/
|
|
32
|
+
overflow?: 'reject' | 'truncate';
|
|
33
|
+
}
|
|
34
|
+
/** Thrown when a body exceeds its byte cap under `overflow: 'reject'`. */
|
|
35
|
+
export declare class ResponseBodyTooLargeError extends Error {
|
|
36
|
+
readonly maxBytes: number;
|
|
37
|
+
constructor(maxBytes: number);
|
|
38
|
+
}
|
|
39
|
+
/** Thrown when a body does not end within its deadline; this attempt failed, a retry may not. */
|
|
40
|
+
export declare class ResponseBodyTimeoutError extends Error {
|
|
41
|
+
readonly timeoutMs: number;
|
|
42
|
+
constructor(timeoutMs: number);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Read `source` into one Buffer, at most `maxBytes` and within `timeoutMs`.
|
|
46
|
+
* The deadline timer is cleared on every path; the source is cancelled on a
|
|
47
|
+
* cap, a deadline, a read error, or a truncated preview.
|
|
48
|
+
*/
|
|
49
|
+
export declare function readBodyChunks(source: BodyChunkSource, limits: BodyReadLimits): Promise<Buffer>;
|
|
50
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxy.so/federation",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.1",
|
|
4
4
|
"description": "Oxy Federation — the app-agnostic ActivityPub identity + follow engine substrate: the network-connector contract, normalized cross-network DTOs, HTTP signatures, actor resolution, the outbound delivery transport + follow lifecycle, the inbound dispatcher, and the webfinger/actor/inbox Express routers. Domain-parameterized so every Oxy app backend federates under its own domain.",
|
|
5
5
|
"main": "dist/cjs/index.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { PassThrough } from 'node:stream';
|
|
2
|
+
import {
|
|
3
|
+
type BodyChunkSource,
|
|
4
|
+
readBodyChunks,
|
|
5
|
+
ResponseBodyTimeoutError,
|
|
6
|
+
ResponseBodyTooLargeError,
|
|
7
|
+
} from '../node/boundedBody';
|
|
8
|
+
|
|
9
|
+
function streamSource(): { stream: PassThrough; source: BodyChunkSource } {
|
|
10
|
+
const stream = new PassThrough();
|
|
11
|
+
const iterator = stream[Symbol.asyncIterator]();
|
|
12
|
+
return { stream, source: { read: () => iterator.next(), cancel: () => stream.destroy() } };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('readBodyChunks', () => {
|
|
16
|
+
it('reads a body that ends within its limits', async () => {
|
|
17
|
+
const { stream, source } = streamSource();
|
|
18
|
+
const pending = readBodyChunks(source, { maxBytes: 8, timeoutMs: 1_000 });
|
|
19
|
+
stream.write('ab');
|
|
20
|
+
stream.end('cd');
|
|
21
|
+
expect((await pending).toString()).toBe('abcd');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('rejects one byte past the cap (or truncates a preview) and destroys the stream', async () => {
|
|
25
|
+
const rejected = streamSource();
|
|
26
|
+
const pending = readBodyChunks(rejected.source, { maxBytes: 3, timeoutMs: 1_000 });
|
|
27
|
+
rejected.stream.write('abcd');
|
|
28
|
+
await expect(pending).rejects.toBeInstanceOf(ResponseBodyTooLargeError);
|
|
29
|
+
expect(rejected.stream.destroyed).toBe(true);
|
|
30
|
+
|
|
31
|
+
const truncated = streamSource();
|
|
32
|
+
const preview = readBodyChunks(truncated.source, {
|
|
33
|
+
maxBytes: 3,
|
|
34
|
+
timeoutMs: 1_000,
|
|
35
|
+
overflow: 'truncate',
|
|
36
|
+
});
|
|
37
|
+
truncated.stream.write('abcd');
|
|
38
|
+
expect((await preview).toString()).toBe('abc');
|
|
39
|
+
expect(truncated.stream.destroyed).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('throws ResponseBodyTimeoutError for a body that stalls and destroys the stream', async () => {
|
|
43
|
+
jest.useFakeTimers();
|
|
44
|
+
try {
|
|
45
|
+
const { stream, source } = streamSource();
|
|
46
|
+
const pending = readBodyChunks(source, { maxBytes: 1_024, timeoutMs: 5_000 });
|
|
47
|
+
stream.write('{"partial":');
|
|
48
|
+
const settled = expect(pending).rejects.toBeInstanceOf(ResponseBodyTimeoutError);
|
|
49
|
+
await jest.advanceTimersByTimeAsync(5_000);
|
|
50
|
+
await settled;
|
|
51
|
+
expect(stream.destroyed).toBe(true);
|
|
52
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
53
|
+
} finally {
|
|
54
|
+
jest.useRealTimers();
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
type NetworkIdentityCandidate,
|
|
28
28
|
} from '../networkIdentity';
|
|
29
29
|
import type { NormalizedExternalActor } from '../index';
|
|
30
|
+
import { readBodyChunks, ResponseBodyTooLargeError } from './boundedBody';
|
|
30
31
|
import type { SignedFetch } from './signedFetch';
|
|
31
32
|
import type { ReportActorGoneOutcome } from './identityBridge';
|
|
32
33
|
|
|
@@ -53,37 +54,31 @@ const COLLECTION_BODY_MAX_BYTES = 64 * 1024;
|
|
|
53
54
|
const COLLECTION_WITHHELD_STATUSES: ReadonlySet<number> = new Set([401, 403, 404, 410]);
|
|
54
55
|
const ERROR_BODY_MAX_BYTES = 4 * 1024;
|
|
55
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Deadline for reading a whole actor/collection/error body once its headers
|
|
59
|
+
* arrived. `signedFetch`'s timeout ends at the headers, so without this a remote
|
|
60
|
+
* that stalls mid-body would hold the resolver open.
|
|
61
|
+
*/
|
|
62
|
+
const BODY_TIMEOUT_MS = 10_000;
|
|
63
|
+
|
|
56
64
|
async function readBoundedResponseBody(res: Response, maxBytes: number): Promise<string> {
|
|
57
65
|
const contentLength = res.headers.get('content-length');
|
|
58
66
|
if (contentLength) {
|
|
59
67
|
const declaredBytes = Number(contentLength);
|
|
60
68
|
if (Number.isFinite(declaredBytes) && declaredBytes > maxBytes) {
|
|
61
69
|
await res.body?.cancel().catch(() => {});
|
|
62
|
-
throw new
|
|
70
|
+
throw new ResponseBodyTooLargeError(maxBytes);
|
|
63
71
|
}
|
|
64
72
|
}
|
|
65
73
|
|
|
66
74
|
if (!res.body) return '';
|
|
67
75
|
|
|
68
76
|
const reader = res.body.getReader();
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const { done, value } = await reader.read();
|
|
75
|
-
if (done) break;
|
|
76
|
-
bytesRead += value.byteLength;
|
|
77
|
-
if (bytesRead > maxBytes) {
|
|
78
|
-
await reader.cancel().catch(() => {});
|
|
79
|
-
throw new Error(`Remote response exceeds ${maxBytes} byte limit`);
|
|
80
|
-
}
|
|
81
|
-
text += decoder.decode(value, { stream: true });
|
|
82
|
-
}
|
|
83
|
-
return text + decoder.decode();
|
|
84
|
-
} finally {
|
|
85
|
-
reader.releaseLock();
|
|
86
|
-
}
|
|
77
|
+
const body = await readBodyChunks(
|
|
78
|
+
{ read: () => reader.read(), cancel: () => void reader.cancel().catch(() => {}) },
|
|
79
|
+
{ maxBytes, timeoutMs: BODY_TIMEOUT_MS },
|
|
80
|
+
);
|
|
81
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(body);
|
|
87
82
|
}
|
|
88
83
|
|
|
89
84
|
async function readBoundedJson(res: Response, maxBytes: number): Promise<Record<string, unknown>> {
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one bounded, deadlined body read behind every federation response body
|
|
3
|
+
* (an actor or collection `Response`, a failed delivery's preview).
|
|
4
|
+
*
|
|
5
|
+
* The transports bound a request up to its response headers; from there the
|
|
6
|
+
* reader owns the stream, and a remote that sends headers and then stalls (or
|
|
7
|
+
* trickles) the body would hold a plain read loop forever. This caps the bytes
|
|
8
|
+
* AND gives the whole body one deadline, cancelling the source on every early
|
|
9
|
+
* exit. It reads through a {@link BodyChunkSource} so a WHATWG reader and a
|
|
10
|
+
* Node async iterator share it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** One `read()` step: a WHATWG `ReadableStreamDefaultReader` and an async iterator both return it. */
|
|
14
|
+
interface BodyChunk {
|
|
15
|
+
done?: boolean;
|
|
16
|
+
value?: Uint8Array;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Where the chunks come from, and how to release the stream when a read ends early. */
|
|
20
|
+
export interface BodyChunkSource {
|
|
21
|
+
read(): Promise<BodyChunk>;
|
|
22
|
+
cancel(): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Limits for {@link readBodyChunks}. */
|
|
26
|
+
export interface BodyReadLimits {
|
|
27
|
+
/** Largest body accepted, in bytes. */
|
|
28
|
+
maxBytes: number;
|
|
29
|
+
/** Deadline for the WHOLE body, from the call until the source ends, in milliseconds. */
|
|
30
|
+
timeoutMs: number;
|
|
31
|
+
/**
|
|
32
|
+
* `reject` (the default) throws {@link ResponseBodyTooLargeError} past the cap;
|
|
33
|
+
* `truncate` stops there and returns the first `maxBytes` (a preview).
|
|
34
|
+
*/
|
|
35
|
+
overflow?: 'reject' | 'truncate';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Thrown when a body exceeds its byte cap under `overflow: 'reject'`. */
|
|
39
|
+
export class ResponseBodyTooLargeError extends Error {
|
|
40
|
+
constructor(public readonly maxBytes: number) {
|
|
41
|
+
super(`Remote response exceeds ${maxBytes} byte limit`);
|
|
42
|
+
this.name = 'ResponseBodyTooLargeError';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Thrown when a body does not end within its deadline; this attempt failed, a retry may not. */
|
|
47
|
+
export class ResponseBodyTimeoutError extends Error {
|
|
48
|
+
constructor(public readonly timeoutMs: number) {
|
|
49
|
+
super(`Remote response body did not end within ${timeoutMs} ms`);
|
|
50
|
+
this.name = 'ResponseBodyTimeoutError';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Read `source` into one Buffer, at most `maxBytes` and within `timeoutMs`.
|
|
56
|
+
* The deadline timer is cleared on every path; the source is cancelled on a
|
|
57
|
+
* cap, a deadline, a read error, or a truncated preview.
|
|
58
|
+
*/
|
|
59
|
+
export async function readBodyChunks(
|
|
60
|
+
source: BodyChunkSource,
|
|
61
|
+
limits: BodyReadLimits,
|
|
62
|
+
): Promise<Buffer> {
|
|
63
|
+
const { maxBytes, timeoutMs, overflow = 'reject' } = limits;
|
|
64
|
+
const chunks: Buffer[] = [];
|
|
65
|
+
let total = 0;
|
|
66
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
67
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
68
|
+
timer = setTimeout(() => reject(new ResponseBodyTimeoutError(timeoutMs)), timeoutMs);
|
|
69
|
+
});
|
|
70
|
+
try {
|
|
71
|
+
for (;;) {
|
|
72
|
+
// `Promise.race` subscribes to both, so neither the deadline nor a read
|
|
73
|
+
// that rejects once the source is cancelled goes unhandled.
|
|
74
|
+
const { done, value } = await Promise.race([source.read(), deadline]);
|
|
75
|
+
if (done || !value) break;
|
|
76
|
+
const buffer = Buffer.isBuffer(value)
|
|
77
|
+
? value
|
|
78
|
+
: Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
|
79
|
+
if (total + buffer.length > maxBytes) {
|
|
80
|
+
if (overflow === 'reject') throw new ResponseBodyTooLargeError(maxBytes);
|
|
81
|
+
chunks.push(buffer.subarray(0, maxBytes - total));
|
|
82
|
+
total = maxBytes;
|
|
83
|
+
source.cancel();
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
total += buffer.length;
|
|
87
|
+
chunks.push(buffer);
|
|
88
|
+
}
|
|
89
|
+
} catch (err) {
|
|
90
|
+
source.cancel();
|
|
91
|
+
throw err;
|
|
92
|
+
} finally {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
}
|
|
95
|
+
return Buffer.concat(chunks, total);
|
|
96
|
+
}
|
package/src/node/delivery.ts
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
import type { AccountKind } from '@oxy.so/contracts';
|
|
28
28
|
import { AP_CONTEXT } from '../apContext';
|
|
29
|
+
import { readBodyChunks } from './boundedBody';
|
|
29
30
|
import { signRequest, type HttpSignatureSigner } from '../httpSignature';
|
|
30
31
|
import type { UrlBuilders } from '../urls';
|
|
31
32
|
import type { LocalActorBuilder } from '../actorObject';
|
|
@@ -34,6 +35,8 @@ import type { LocalActorBuilder } from '../actorObject';
|
|
|
34
35
|
const DELIVER_ACTIVITY_TIMEOUT_MS = 15000;
|
|
35
36
|
/** How many bytes of a failed-delivery response body are read for the debug log. */
|
|
36
37
|
const DELIVERY_RESPONSE_PREVIEW_MAX_BYTES = 1024;
|
|
38
|
+
/** Deadline for reading that preview once the response headers arrived. */
|
|
39
|
+
const DELIVERY_RESPONSE_PREVIEW_TIMEOUT_MS = 5_000;
|
|
37
40
|
|
|
38
41
|
/** The ActivityStreams public collection — the `to` addressee of a public activity. */
|
|
39
42
|
const AP_PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
|
|
@@ -278,25 +281,28 @@ export interface DeliveryServiceConfig<TActor extends DeliveryActorFields> {
|
|
|
278
281
|
isBlockedDomain(host: string): boolean;
|
|
279
282
|
}
|
|
280
283
|
|
|
281
|
-
/**
|
|
284
|
+
/**
|
|
285
|
+
* Read a bounded prefix of a failed-delivery response body for the debug log,
|
|
286
|
+
* within {@link DELIVERY_RESPONSE_PREVIEW_TIMEOUT_MS}: an inbox that stalls its
|
|
287
|
+
* body after the headers costs the worker that long, not forever.
|
|
288
|
+
*/
|
|
282
289
|
async function readResponsePreview(response: DeliveryResponseStream): Promise<string> {
|
|
283
|
-
const
|
|
284
|
-
let totalBytes = 0;
|
|
285
|
-
|
|
290
|
+
const iterator = response[Symbol.asyncIterator]();
|
|
286
291
|
try {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
292
|
+
const preview = await readBodyChunks(
|
|
293
|
+
{ read: () => iterator.next(), cancel: () => response.destroy() },
|
|
294
|
+
{
|
|
295
|
+
maxBytes: DELIVERY_RESPONSE_PREVIEW_MAX_BYTES,
|
|
296
|
+
timeoutMs: DELIVERY_RESPONSE_PREVIEW_TIMEOUT_MS,
|
|
297
|
+
overflow: 'truncate',
|
|
298
|
+
},
|
|
299
|
+
);
|
|
300
|
+
return preview.toString('utf8');
|
|
293
301
|
} catch {
|
|
294
302
|
return '';
|
|
295
303
|
} finally {
|
|
296
304
|
response.destroy();
|
|
297
305
|
}
|
|
298
|
-
|
|
299
|
-
return Buffer.concat(chunks).toString('utf8', 0, DELIVERY_RESPONSE_PREVIEW_MAX_BYTES);
|
|
300
306
|
}
|
|
301
307
|
|
|
302
308
|
/** The outbound delivery + follow-lifecycle service. */
|