feat: add proactive delivery HTTP endpoint

This commit is contained in:
xmanrui 2026-08-30 11:52:01 +08:00
parent 31792d7bc8
commit b90cb805c7
11 changed files with 703 additions and 294 deletions

View file

@ -0,0 +1,132 @@
import { createDeliveryRpcHandler, DELIVERY_ENDPOINTS } from './delivery-rpc.mjs';
export const DELIVERY_HTTP_PATH = '/api/dsh-im/delivery/messages';
const MAX_BODY_BYTES = 1024 * 1024;
const DELIVERY_ERROR_STATUS = Object.freeze({
'bad-request': 400,
'unknown-bot': 404,
'unknown-target': 404,
'target-conflict': 409,
'invalid-target': 422,
'bot-not-connected': 503,
'target-rejected': 422,
'delivery-failed': 502,
cancelled: 408,
});
class HttpRequestError extends Error {
constructor(status, code) {
super(code);
this.status = status;
this.code = code;
}
}
function json(response, status, value, headers = {}) {
if (response.destroyed || response.writableEnded) return;
const body = JSON.stringify(value);
response.writeHead(status, {
'cache-control': 'no-store',
'content-type': 'application/json; charset=utf-8',
'content-length': String(Buffer.byteLength(body)),
...headers,
});
response.end(body);
}
function isJsonContentType(value) {
return typeof value === 'string'
&& value.split(';', 1)[0].trim().toLowerCase() === 'application/json';
}
async function readJsonBody(request) {
const declaredLength = Number(request.headers['content-length']);
if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES) {
throw new HttpRequestError(413, 'payload-too-large');
}
const chunks = [];
let length = 0;
for await (const chunk of request) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
length += buffer.length;
if (length > MAX_BODY_BYTES) {
throw new HttpRequestError(413, 'payload-too-large');
}
chunks.push(buffer);
}
try {
return JSON.parse(Buffer.concat(chunks, length).toString('utf8'));
} catch {
throw new HttpRequestError(400, 'bad-request');
}
}
export function createDeliveryHttpHandler(service) {
const dispatch = createDeliveryRpcHandler(service);
return async (request, response) => {
if (request.method !== 'POST') {
json(response, 405, {
error: { code: 'method-not-allowed', message: 'method-not-allowed', details: {} },
}, { allow: 'POST' });
return;
}
if (!isJsonContentType(request.headers['content-type'])) {
json(response, 415, {
error: { code: 'unsupported-media-type', message: 'unsupported-media-type', details: {} },
});
return;
}
const abort = new AbortController();
const cancel = () => abort.abort();
const cancelClosedResponse = () => {
if (!response.writableEnded) cancel();
};
request.once('aborted', cancel);
response.once('close', cancelClosedResponse);
try {
const payload = await readJsonBody(request);
const result = await dispatch(DELIVERY_ENDPOINTS.send, payload, abort.signal);
if (result.ok) {
json(response, 200, result.value);
return;
}
json(response, DELIVERY_ERROR_STATUS[result.error.code] ?? 500, {
error: result.error,
});
} catch (error) {
if (error instanceof HttpRequestError) {
json(response, error.status, {
error: { code: error.code, message: error.code, details: {} },
});
return;
}
json(response, 500, {
error: { code: 'delivery-failed', message: 'delivery-failed', details: {} },
});
} finally {
request.off('aborted', cancel);
response.off('close', cancelClosedResponse);
}
};
}
export function installDeliveryHttp(ctx, service) {
if (!ctx?.webServer || typeof ctx.webServer.register !== 'function'
|| typeof ctx.effect !== 'function') {
throw new TypeError('DSH Host WebServer is required');
}
const route = {
kind: 'exact',
path: DELIVERY_HTTP_PATH,
handler: createDeliveryHttpHandler(service),
};
return ctx.effect(
() => ctx.webServer.register(route),
`dsh-im: ${DELIVERY_HTTP_PATH}`,
);
}