mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
feat(diagnostics): harden ingestion and delivery
This commit is contained in:
593
services/diagnostics-api/src/input.ts
Normal file
593
services/diagnostics-api/src/input.ts
Normal file
@@ -0,0 +1,593 @@
|
||||
export type JsonObject = Record<string, unknown>;
|
||||
|
||||
export type InputFailure = {
|
||||
ok: false;
|
||||
status: 400 | 413 | 415;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export type InputResult<T> = { ok: true; value: T } | InputFailure;
|
||||
|
||||
export type NormalizedProperties = Record<string, string>;
|
||||
|
||||
export interface NormalizedEvent {
|
||||
name: string;
|
||||
timestampMillis: number;
|
||||
properties: NormalizedProperties;
|
||||
schemaVersion: 1;
|
||||
}
|
||||
|
||||
export interface NormalizedBreadcrumb {
|
||||
name: string;
|
||||
timestampMillis: number;
|
||||
properties: NormalizedProperties;
|
||||
}
|
||||
|
||||
export interface NormalizedDevice {
|
||||
deviceName: string;
|
||||
deviceModel: string;
|
||||
operatingSystem: string;
|
||||
network: string;
|
||||
batteryLevel: string;
|
||||
}
|
||||
|
||||
export interface NormalizedEventsPayload {
|
||||
batchId: string;
|
||||
installId: string;
|
||||
appVersion: string;
|
||||
platform: string;
|
||||
events: NormalizedEvent[];
|
||||
}
|
||||
|
||||
export interface NormalizedCrashPayload {
|
||||
id: string;
|
||||
installId: string;
|
||||
appVersion: string;
|
||||
platform: string;
|
||||
exceptionType: string;
|
||||
exceptionMessage: string;
|
||||
stackTrace: string;
|
||||
occurredAt: number;
|
||||
diagnosticsEnabledAtCapture: boolean;
|
||||
breadcrumbs: NormalizedBreadcrumb[];
|
||||
schemaVersion: 1;
|
||||
}
|
||||
|
||||
export interface NormalizedBugPayload {
|
||||
id: string;
|
||||
installId: string;
|
||||
appVersion: string;
|
||||
platform: string;
|
||||
occurredAt: number;
|
||||
whatHappened: string;
|
||||
expected: string;
|
||||
steps: string;
|
||||
contact: string;
|
||||
logs: string;
|
||||
device: NormalizedDevice;
|
||||
breadcrumbs: NormalizedBreadcrumb[];
|
||||
schemaVersion: 1;
|
||||
}
|
||||
|
||||
export const MAX_LOG_BYTES = 192 * 1024;
|
||||
export const MAX_BREADCRUMBS_JSON_BYTES = 16_000;
|
||||
export const MAX_DEVICE_JSON_BYTES = 4_000;
|
||||
|
||||
const MAX_PROPERTIES = 12;
|
||||
const MAX_BREADCRUMBS = 40;
|
||||
const MISSING = Symbol("missing");
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const UTF8_ENCODER = new TextEncoder();
|
||||
|
||||
export async function readJsonObject(
|
||||
request: Request,
|
||||
maxBytes: number,
|
||||
): Promise<InputResult<JsonObject>> {
|
||||
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
|
||||
throw new RangeError("maxBytes must be a positive safe integer");
|
||||
}
|
||||
|
||||
if (!isApplicationJson(request.headers.get("content-type"))) {
|
||||
return failure(415, "unsupported_media_type");
|
||||
}
|
||||
|
||||
const contentLength = request.headers.get("content-length");
|
||||
if (contentLength != null) {
|
||||
const trimmed = contentLength.trim();
|
||||
if (!/^\d+$/.test(trimmed)) {
|
||||
return failure(400, "invalid_content_length");
|
||||
}
|
||||
const normalizedLength = trimmed.replace(/^0+(?=\d)/, "");
|
||||
const maxLength = String(maxBytes);
|
||||
if (
|
||||
normalizedLength.length > maxLength.length ||
|
||||
(normalizedLength.length === maxLength.length && normalizedLength > maxLength)
|
||||
) {
|
||||
return failure(413, "payload_too_large");
|
||||
}
|
||||
const declaredBytes = Number(trimmed);
|
||||
if (!Number.isSafeInteger(declaredBytes)) {
|
||||
return failure(400, "invalid_content_length");
|
||||
}
|
||||
}
|
||||
|
||||
if (request.body == null) {
|
||||
return failure(400, "invalid_json");
|
||||
}
|
||||
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array>;
|
||||
try {
|
||||
reader = request.body.getReader();
|
||||
} catch {
|
||||
return failure(400, "invalid_body");
|
||||
}
|
||||
const chunks: Uint8Array[] = [];
|
||||
let totalBytes = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
if (chunk.value.byteLength > maxBytes - totalBytes) {
|
||||
try {
|
||||
await reader.cancel("payload_too_large");
|
||||
} catch {
|
||||
// The size failure remains authoritative if cancellation also fails.
|
||||
}
|
||||
return failure(413, "payload_too_large");
|
||||
}
|
||||
totalBytes += chunk.value.byteLength;
|
||||
chunks.push(chunk.value);
|
||||
}
|
||||
} catch {
|
||||
return failure(400, "invalid_body");
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
const bytes = joinChunks(chunks, totalBytes);
|
||||
let raw: string;
|
||||
try {
|
||||
raw = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes);
|
||||
} catch {
|
||||
return failure(400, "invalid_utf8");
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return failure(400, "invalid_json");
|
||||
}
|
||||
if (!isPlainObject(parsed)) {
|
||||
return failure(400, "invalid_body");
|
||||
}
|
||||
return success(parsed);
|
||||
}
|
||||
|
||||
export function normalizeEvents(
|
||||
body: JsonObject,
|
||||
maxEvents = 50,
|
||||
): InputResult<NormalizedEventsPayload> {
|
||||
if (!isPlainObject(body)) return failure(400, "invalid_body");
|
||||
if (!Number.isSafeInteger(maxEvents) || maxEvents <= 0) {
|
||||
throw new RangeError("maxEvents must be a positive safe integer");
|
||||
}
|
||||
|
||||
const batchId = idField(body, ["batchId", "batch_id"], "invalid_batch_id");
|
||||
if (!batchId.ok) return batchId;
|
||||
const installId = installIdField(body);
|
||||
if (!installId.ok) return installId;
|
||||
const appVersion = stringField(body, ["appVersion", "app_version"], 40, "invalid_app_version");
|
||||
if (!appVersion.ok) return appVersion;
|
||||
const platform = stringField(body, ["platform"], 40, "invalid_platform");
|
||||
if (!platform.ok) return platform;
|
||||
|
||||
const batchSchema = schemaVersion(body);
|
||||
if (!batchSchema.ok) return batchSchema;
|
||||
const rawEvents = pick(body, ["events"]);
|
||||
if (!Array.isArray(rawEvents)) return failure(400, "invalid_events");
|
||||
if (rawEvents.length === 0) return failure(400, "empty_batch");
|
||||
if (rawEvents.length > maxEvents) return failure(400, "batch_too_large");
|
||||
|
||||
const events: NormalizedEvent[] = [];
|
||||
for (const rawEvent of rawEvents) {
|
||||
const event = normalizeEvent(rawEvent);
|
||||
if (!event.ok) return event;
|
||||
events.push(event.value);
|
||||
}
|
||||
|
||||
return success({
|
||||
batchId: batchId.value,
|
||||
installId: installId.value,
|
||||
appVersion: appVersion.value,
|
||||
platform: platform.value,
|
||||
events,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeCrash(body: JsonObject): InputResult<NormalizedCrashPayload> {
|
||||
if (!isPlainObject(body)) return failure(400, "invalid_body");
|
||||
|
||||
const id = idField(body, ["id"], "invalid_id");
|
||||
if (!id.ok) return id;
|
||||
const installId = installIdField(body);
|
||||
if (!installId.ok) return installId;
|
||||
const appVersion = stringField(body, ["appVersion", "app_version"], 40, "invalid_app_version");
|
||||
if (!appVersion.ok) return appVersion;
|
||||
const platform = stringField(body, ["platform"], 40, "invalid_platform");
|
||||
if (!platform.ok) return platform;
|
||||
const exceptionType = stringField(
|
||||
body,
|
||||
["exceptionType", "exception_type"],
|
||||
120,
|
||||
"invalid_exception_type",
|
||||
true,
|
||||
true,
|
||||
);
|
||||
if (!exceptionType.ok) return exceptionType;
|
||||
const exceptionMessage = stringField(
|
||||
body,
|
||||
["exceptionMessage", "exception_message"],
|
||||
2_000,
|
||||
"invalid_exception_message",
|
||||
true,
|
||||
);
|
||||
if (!exceptionMessage.ok) return exceptionMessage;
|
||||
const stackTrace = stringField(
|
||||
body,
|
||||
["stackTrace", "stack_trace"],
|
||||
32_000,
|
||||
"invalid_stack_trace",
|
||||
true,
|
||||
);
|
||||
if (!stackTrace.ok) return stackTrace;
|
||||
const occurredAt = timestampField(
|
||||
body,
|
||||
["timestampMillis", "timestamp_millis", "occurredAt", "occurred_at"],
|
||||
);
|
||||
if (!occurredAt.ok) return occurredAt;
|
||||
const diagnosticsEnabled = booleanField(
|
||||
body,
|
||||
[
|
||||
"diagnosticsEnabledAtCapture",
|
||||
"diagnostics_enabled_at_capture",
|
||||
"diagnostics_enabled",
|
||||
],
|
||||
"invalid_diagnostics_enabled",
|
||||
true,
|
||||
);
|
||||
if (!diagnosticsEnabled.ok) return diagnosticsEnabled;
|
||||
const version = schemaVersion(body);
|
||||
if (!version.ok) return version;
|
||||
const breadcrumbs = normalizeBreadcrumbs(pick(body, ["breadcrumbs"]));
|
||||
if (!breadcrumbs.ok) return breadcrumbs;
|
||||
|
||||
return success({
|
||||
id: id.value,
|
||||
installId: installId.value,
|
||||
appVersion: appVersion.value,
|
||||
platform: platform.value,
|
||||
exceptionType: exceptionType.value,
|
||||
exceptionMessage: exceptionMessage.value,
|
||||
stackTrace: stackTrace.value,
|
||||
occurredAt: occurredAt.value,
|
||||
diagnosticsEnabledAtCapture: diagnosticsEnabled.value,
|
||||
breadcrumbs: breadcrumbs.value,
|
||||
schemaVersion: version.value,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeBug(body: JsonObject): InputResult<NormalizedBugPayload> {
|
||||
if (!isPlainObject(body)) return failure(400, "invalid_body");
|
||||
|
||||
const id = idField(body, ["id"], "invalid_id");
|
||||
if (!id.ok) return id;
|
||||
const installId = installIdField(body);
|
||||
if (!installId.ok) return installId;
|
||||
const appVersion = stringField(body, ["appVersion", "app_version"], 40, "invalid_app_version");
|
||||
if (!appVersion.ok) return appVersion;
|
||||
const platform = stringField(body, ["platform"], 40, "invalid_platform");
|
||||
if (!platform.ok) return platform;
|
||||
const occurredAt = timestampField(
|
||||
body,
|
||||
["timestampMillis", "timestamp_millis", "occurredAt", "occurred_at"],
|
||||
);
|
||||
if (!occurredAt.ok) return occurredAt;
|
||||
const whatHappened = stringField(
|
||||
body,
|
||||
["whatHappened", "what_happened"],
|
||||
4_000,
|
||||
"missing_fields",
|
||||
true,
|
||||
true,
|
||||
);
|
||||
if (!whatHappened.ok) return whatHappened;
|
||||
const expected = stringField(body, ["expected"], 4_000, "missing_fields", true, true);
|
||||
if (!expected.ok) return expected;
|
||||
const steps = stringField(body, ["steps"], 4_000, "invalid_steps");
|
||||
if (!steps.ok) return steps;
|
||||
const contact = stringField(body, ["contact"], 320, "invalid_contact");
|
||||
if (!contact.ok) return contact;
|
||||
const includeLogs = booleanField(
|
||||
body,
|
||||
["includeLogs", "include_logs"],
|
||||
"invalid_include_logs",
|
||||
false,
|
||||
);
|
||||
if (!includeLogs.ok) return includeLogs;
|
||||
const logs = stringField(body, ["logs"], MAX_LOG_BYTES, "invalid_logs");
|
||||
if (!logs.ok) return logs;
|
||||
const device = normalizeDevice(pick(body, ["device"]));
|
||||
if (!device.ok) return device;
|
||||
const breadcrumbs = normalizeBreadcrumbs(pick(body, ["breadcrumbs"]));
|
||||
if (!breadcrumbs.ok) return breadcrumbs;
|
||||
const version = schemaVersion(body);
|
||||
if (!version.ok) return version;
|
||||
|
||||
return success({
|
||||
id: id.value,
|
||||
installId: installId.value,
|
||||
appVersion: appVersion.value,
|
||||
platform: platform.value,
|
||||
occurredAt: occurredAt.value,
|
||||
whatHappened: whatHappened.value,
|
||||
expected: expected.value,
|
||||
steps: steps.value,
|
||||
contact: contact.value,
|
||||
logs: includeLogs.value === true ? logs.value : "",
|
||||
device: device.value,
|
||||
breadcrumbs: breadcrumbs.value,
|
||||
schemaVersion: version.value,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeEvent(raw: unknown): InputResult<NormalizedEvent> {
|
||||
if (!isPlainObject(raw)) return failure(400, "invalid_event");
|
||||
const name = stringField(raw, ["name"], 64, "invalid_event", true, true);
|
||||
if (!name.ok) return name;
|
||||
const timestamp = timestampField(raw, ["timestampMillis", "timestamp_millis", "ts"]);
|
||||
if (!timestamp.ok) return failure(400, "invalid_event");
|
||||
const properties = normalizeProperties(pick(raw, ["properties", "props"]), "invalid_event");
|
||||
if (!properties.ok) return properties;
|
||||
const version = schemaVersion(raw);
|
||||
if (!version.ok) return version;
|
||||
return success({
|
||||
name: name.value,
|
||||
timestampMillis: timestamp.value,
|
||||
properties: properties.value,
|
||||
schemaVersion: version.value,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeBreadcrumbs(raw: unknown | typeof MISSING): InputResult<NormalizedBreadcrumb[]> {
|
||||
if (raw === MISSING) return success([]);
|
||||
if (!Array.isArray(raw)) return failure(400, "invalid_breadcrumbs");
|
||||
|
||||
const breadcrumbs: NormalizedBreadcrumb[] = [];
|
||||
for (const item of raw.slice(0, MAX_BREADCRUMBS)) {
|
||||
if (!isPlainObject(item)) return failure(400, "invalid_breadcrumbs");
|
||||
const name = stringField(item, ["name"], 64, "invalid_breadcrumbs", true, true);
|
||||
if (!name.ok) return name;
|
||||
const timestamp = timestampField(item, ["timestampMillis", "timestamp_millis", "ts"]);
|
||||
if (!timestamp.ok) return failure(400, "invalid_breadcrumbs");
|
||||
const properties = normalizeProperties(
|
||||
pick(item, ["properties", "props"]),
|
||||
"invalid_breadcrumbs",
|
||||
);
|
||||
if (!properties.ok) return properties;
|
||||
|
||||
breadcrumbs.push({
|
||||
name: name.value,
|
||||
timestampMillis: timestamp.value,
|
||||
properties: properties.value,
|
||||
});
|
||||
if (jsonBytes(breadcrumbs) > MAX_BREADCRUMBS_JSON_BYTES) {
|
||||
breadcrumbs.pop();
|
||||
break;
|
||||
}
|
||||
}
|
||||
return success(breadcrumbs);
|
||||
}
|
||||
|
||||
function normalizeProperties(
|
||||
raw: unknown | typeof MISSING,
|
||||
error: string,
|
||||
): InputResult<NormalizedProperties> {
|
||||
if (raw === MISSING) return success({});
|
||||
if (!isPlainObject(raw)) return failure(400, error);
|
||||
|
||||
const entries: Array<[string, string]> = [];
|
||||
const normalizedKeys = new Set<string>();
|
||||
for (const [key, value] of Object.entries(raw).slice(0, MAX_PROPERTIES)) {
|
||||
if (typeof value !== "string") return failure(400, error);
|
||||
const normalizedKey = truncateUtf8(key, 40);
|
||||
if (normalizedKey.length === 0 || normalizedKeys.has(normalizedKey)) {
|
||||
return failure(400, error);
|
||||
}
|
||||
normalizedKeys.add(normalizedKey);
|
||||
entries.push([normalizedKey, truncateUtf8(value, 128)]);
|
||||
}
|
||||
return success(Object.fromEntries(entries));
|
||||
}
|
||||
|
||||
function normalizeDevice(raw: unknown | typeof MISSING): InputResult<NormalizedDevice> {
|
||||
if (raw === MISSING) raw = {};
|
||||
if (!isPlainObject(raw)) return failure(400, "invalid_device");
|
||||
|
||||
const deviceName = stringField(raw, ["deviceName", "device_name"], 128, "invalid_device");
|
||||
if (!deviceName.ok) return deviceName;
|
||||
const deviceModel = stringField(raw, ["deviceModel", "device_model"], 128, "invalid_device");
|
||||
if (!deviceModel.ok) return deviceModel;
|
||||
const operatingSystem = stringField(
|
||||
raw,
|
||||
["operatingSystem", "operating_system"],
|
||||
192,
|
||||
"invalid_device",
|
||||
);
|
||||
if (!operatingSystem.ok) return operatingSystem;
|
||||
const network = stringField(raw, ["network"], 96, "invalid_device");
|
||||
if (!network.ok) return network;
|
||||
const batteryLevel = stringField(raw, ["batteryLevel", "battery_level"], 64, "invalid_device");
|
||||
if (!batteryLevel.ok) return batteryLevel;
|
||||
|
||||
const device: NormalizedDevice = {
|
||||
deviceName: deviceName.value,
|
||||
deviceModel: deviceModel.value,
|
||||
operatingSystem: operatingSystem.value,
|
||||
network: network.value,
|
||||
batteryLevel: batteryLevel.value,
|
||||
};
|
||||
if (jsonBytes(device) > MAX_DEVICE_JSON_BYTES) {
|
||||
return failure(400, "invalid_device");
|
||||
}
|
||||
return success(device);
|
||||
}
|
||||
|
||||
function stringField(
|
||||
object: JsonObject,
|
||||
keys: readonly string[],
|
||||
maxBytes: number,
|
||||
error: string,
|
||||
required = false,
|
||||
nonEmpty = false,
|
||||
): InputResult<string> {
|
||||
const raw = pick(object, keys);
|
||||
if (raw === MISSING) {
|
||||
return required ? failure(400, error) : success("");
|
||||
}
|
||||
if (typeof raw !== "string") return failure(400, error);
|
||||
const value = truncateUtf8(raw, maxBytes);
|
||||
if (nonEmpty && value.trim().length === 0) return failure(400, error);
|
||||
return success(value);
|
||||
}
|
||||
|
||||
function idField(
|
||||
object: JsonObject,
|
||||
keys: readonly string[],
|
||||
error: string,
|
||||
): InputResult<string> {
|
||||
const raw = pick(object, keys);
|
||||
if (typeof raw !== "string" || !UUID_PATTERN.test(raw)) return failure(400, error);
|
||||
return success(raw.toLowerCase());
|
||||
}
|
||||
|
||||
function installIdField(object: JsonObject): InputResult<string> {
|
||||
const raw = pick(object, ["installId", "install_id"]);
|
||||
if (raw === MISSING || raw === "") return success("unknown");
|
||||
if (
|
||||
typeof raw !== "string" ||
|
||||
raw.trim().length === 0 ||
|
||||
/[\u0000-\u001f\u007f]/.test(raw)
|
||||
) {
|
||||
return failure(400, "invalid_install_id");
|
||||
}
|
||||
return success(truncateUtf8(raw, 80));
|
||||
}
|
||||
|
||||
function timestampField(object: JsonObject, keys: readonly string[]): InputResult<number> {
|
||||
const raw = pick(object, keys);
|
||||
if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw < 0) {
|
||||
return failure(400, "invalid_timestamp");
|
||||
}
|
||||
return success(raw);
|
||||
}
|
||||
|
||||
function booleanField(
|
||||
object: JsonObject,
|
||||
keys: readonly string[],
|
||||
error: string,
|
||||
required: true,
|
||||
): InputResult<boolean>;
|
||||
function booleanField(
|
||||
object: JsonObject,
|
||||
keys: readonly string[],
|
||||
error: string,
|
||||
required: false,
|
||||
): InputResult<boolean | undefined>;
|
||||
function booleanField(
|
||||
object: JsonObject,
|
||||
keys: readonly string[],
|
||||
error: string,
|
||||
required: boolean,
|
||||
): InputResult<boolean | undefined> {
|
||||
const raw = pick(object, keys);
|
||||
if (raw === MISSING) {
|
||||
return required ? failure(400, error) : success(undefined);
|
||||
}
|
||||
return typeof raw === "boolean" ? success(raw) : failure(400, error);
|
||||
}
|
||||
|
||||
function schemaVersion(object: JsonObject): InputResult<1> {
|
||||
const raw = pick(object, ["schemaVersion", "schema_version"]);
|
||||
if (raw === MISSING || raw === 1) return success(1);
|
||||
return failure(400, "unsupported_schema_version");
|
||||
}
|
||||
|
||||
function pick(object: JsonObject, keys: readonly string[]): unknown | typeof MISSING {
|
||||
for (const key of keys) {
|
||||
if (Object.prototype.hasOwnProperty.call(object, key)) return object[key];
|
||||
}
|
||||
return MISSING;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is JsonObject {
|
||||
if (value == null || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
function isApplicationJson(contentType: string | null): boolean {
|
||||
if (contentType == null) return false;
|
||||
const [mediaType, ...parameters] = contentType.split(";");
|
||||
if (mediaType?.trim().toLowerCase() !== "application/json") return false;
|
||||
for (const parameter of parameters) {
|
||||
const separator = parameter.indexOf("=");
|
||||
if (separator < 0) continue;
|
||||
if (parameter.slice(0, separator).trim().toLowerCase() !== "charset") continue;
|
||||
const charset = parameter
|
||||
.slice(separator + 1)
|
||||
.trim()
|
||||
.replace(/^"(.*)"$/, "$1")
|
||||
.toLowerCase();
|
||||
if (charset !== "utf-8" && charset !== "utf8") return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function joinChunks(chunks: readonly Uint8Array[], totalBytes: number): Uint8Array {
|
||||
if (chunks.length === 1) return chunks[0] ?? new Uint8Array();
|
||||
const bytes = new Uint8Array(totalBytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function truncateUtf8(value: string, maxBytes: number): string {
|
||||
const bytes = UTF8_ENCODER.encode(value);
|
||||
if (bytes.byteLength <= maxBytes) return value;
|
||||
for (let end = maxBytes; end >= Math.max(0, maxBytes - 3); end -= 1) {
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
|
||||
bytes.subarray(0, end),
|
||||
);
|
||||
} catch {
|
||||
// A UTF-8 boundary is at most three bytes behind the byte cap.
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function jsonBytes(value: unknown): number {
|
||||
return UTF8_ENCODER.encode(JSON.stringify(value)).byteLength;
|
||||
}
|
||||
|
||||
function success<T>(value: T): InputResult<T> {
|
||||
return { ok: true, value };
|
||||
}
|
||||
|
||||
function failure(status: 400 | 413 | 415, error: string): InputFailure {
|
||||
return { ok: false, status, error };
|
||||
}
|
||||
Reference in New Issue
Block a user