akanjs 3.0.0-alpha.63 → 3.0.0-alpha.64
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/base/primitiveRegistry.ts +45 -2
- package/common/clientAddress.ts +40 -0
- package/common/index.ts +7 -0
- package/common/mcpExposure.ts +2 -0
- package/common/websocketBinaryFrame.ts +46 -0
- package/constant/via.ts +4 -1
- package/fetch/client/wsClient.ts +9 -2
- package/fetch/fetchType/appliedReturn.type.ts +7 -0
- package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
- package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
- package/package.json +1 -1
- package/server/akanApp.ts +6 -6
- package/server/akanAppHeaders.ts +14 -2
- package/server/akanServer.ts +19 -10
- package/server/binaryPubsub.ts +55 -0
- package/server/resolver/signal.resolver.ts +25 -5
- package/server/routing/apiRouter.ts +4 -0
- package/server/routing/appWsData.ts +16 -1
- package/server/types.tsx +2 -0
- package/service/ipcTypes.ts +1 -0
- package/service/predefinedAdaptor/compress.adaptor.ts +5 -1
- package/signal/internalArg.ts +13 -0
- package/signal/openapi/openapi.ts +1 -0
- package/signal/schema/JsonSchemaBuilder.ts +2 -0
- package/signal/signalContext.ts +22 -0
- package/signal/types.ts +7 -0
- package/test/sampleOf.ts +2 -0
- package/types/base/primitiveRegistry.d.ts +14 -2
- package/types/common/clientAddress.d.ts +19 -0
- package/types/common/index.d.ts +2 -0
- package/types/common/websocketBinaryFrame.d.ts +17 -0
- package/types/fetch/fetchType/appliedReturn.type.d.ts +7 -0
- package/types/server/akanAppHeaders.d.ts +7 -1
- package/types/server/binaryPubsub.d.ts +18 -0
- package/types/server/resolver/signal.resolver.d.ts +3 -2
- package/types/server/routing/apiRouter.d.ts +2 -1
- package/types/server/routing/appWsData.d.ts +6 -0
- package/types/server/types.d.ts +1 -0
- package/types/service/ipcTypes.d.ts +1 -0
- package/types/service/predefinedAdaptor/compress.adaptor.d.ts +1 -1
- package/types/signal/internalArg.d.ts +10 -0
- package/types/signal/signalContext.d.ts +12 -0
- package/types/signal/types.d.ts +7 -0
- package/ui/Data/Dashboard.tsx +2 -1
- package/ui/Data/QueryMaker.tsx +4 -4
- package/ui/Signal/Listener.tsx +16 -1
|
@@ -44,7 +44,7 @@ export class PrimitiveRegistry {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
export type PrimitiveValue = string | number | boolean | Dayjs | Date | null | undefined;
|
|
47
|
+
export type PrimitiveValue = string | number | boolean | Dayjs | Date | Uint8Array | null | undefined;
|
|
48
48
|
export class PrimitiveScalar {
|
|
49
49
|
static refName: string;
|
|
50
50
|
static [SERVER_VALUE]: unknown;
|
|
@@ -176,6 +176,49 @@ export class Any extends PrimitiveScalar {
|
|
|
176
176
|
}
|
|
177
177
|
PrimitiveRegistry.register(Any);
|
|
178
178
|
|
|
179
|
+
type NodeBuffer = Uint8Array & { toString(encoding: string): string };
|
|
180
|
+
interface BufferCtor {
|
|
181
|
+
from(buffer: ArrayBufferLike, byteOffset: number, length: number): NodeBuffer;
|
|
182
|
+
from(text: string, encoding: string): NodeBuffer;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export class Binary extends PrimitiveScalar {
|
|
186
|
+
static override refName: "Binary" = "Binary";
|
|
187
|
+
static override [SERVER_VALUE]: Uint8Array;
|
|
188
|
+
static override [CLIENT_VALUE]: Uint8Array;
|
|
189
|
+
static override [DEFAULT_VALUE]: Uint8Array | null = null;
|
|
190
|
+
static override [PURIFIED_VALUE]: Uint8Array | null = null;
|
|
191
|
+
static override [EXAMPLE_VALUE]: string = "AAEC";
|
|
192
|
+
|
|
193
|
+
static override validate(value: Uint8Array | string): boolean {
|
|
194
|
+
return value instanceof Uint8Array || typeof value === "string";
|
|
195
|
+
}
|
|
196
|
+
static override parseValue(input: Uint8Array | string): Uint8Array {
|
|
197
|
+
return typeof input === "string" ? Binary.#fromBase64(input) : input;
|
|
198
|
+
}
|
|
199
|
+
static override serializeValue(value: Uint8Array | string): string {
|
|
200
|
+
return typeof value === "string" ? value : Binary.#toBase64(value);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
static #toBase64(bytes: Uint8Array): string {
|
|
204
|
+
const buffer = (globalThis as { Buffer?: BufferCtor }).Buffer;
|
|
205
|
+
if (buffer) return buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64");
|
|
206
|
+
let binary = "";
|
|
207
|
+
for (let idx = 0; idx < bytes.length; idx += 0x8000)
|
|
208
|
+
binary += String.fromCharCode(...bytes.subarray(idx, idx + 0x8000));
|
|
209
|
+
return btoa(binary);
|
|
210
|
+
}
|
|
211
|
+
static #fromBase64(text: string): Uint8Array {
|
|
212
|
+
const buffer = (globalThis as { Buffer?: BufferCtor }).Buffer;
|
|
213
|
+
if (buffer) return buffer.from(text, "base64");
|
|
214
|
+
const binary = atob(text);
|
|
215
|
+
const bytes = new Uint8Array(binary.length);
|
|
216
|
+
for (let idx = 0; idx < binary.length; idx += 1) bytes[idx] = binary.charCodeAt(idx);
|
|
217
|
+
return bytes;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
PrimitiveRegistry.register(Binary);
|
|
221
|
+
|
|
179
222
|
export class Upload extends PrimitiveScalar {
|
|
180
223
|
static override refName: "Upload" = "Upload";
|
|
181
224
|
static override [SERVER_VALUE]: File;
|
|
@@ -354,4 +397,4 @@ Object.assign(Date, {
|
|
|
354
397
|
});
|
|
355
398
|
PrimitiveRegistry.register(Date);
|
|
356
399
|
|
|
357
|
-
export type DefaultPrimitiveName = "String" | "Boolean" | "Date" | "Int" | "Float" | "ID" | "Any" | "Upload";
|
|
400
|
+
export type DefaultPrimitiveName = "String" | "Boolean" | "Date" | "Int" | "Float" | "ID" | "Any" | "Binary" | "Upload";
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Headers a proxy hop writes so the next hop can still name the original caller. */
|
|
2
|
+
export const forwardedHeaders = [
|
|
3
|
+
"x-real-ip",
|
|
4
|
+
"x-forwarded-for",
|
|
5
|
+
"x-forwarded-port",
|
|
6
|
+
"x-forwarded-host",
|
|
7
|
+
"x-forwarded-proto",
|
|
8
|
+
] as const;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* An IPv4 client reaching a dual-stack listener is reported as `::ffff:203.0.113.10`. That form is not a
|
|
12
|
+
* valid destination for a `udp4` socket and does not compare equal to the same address written plainly, so
|
|
13
|
+
* it is unwrapped at every boundary rather than at each call site that happens to remember.
|
|
14
|
+
*/
|
|
15
|
+
export const normalizeIpAddress = (address: string): string => {
|
|
16
|
+
const trimmed = address.trim();
|
|
17
|
+
const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(trimmed);
|
|
18
|
+
return mapped?.[1] ?? trimmed;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The caller's address as the nearest proxy recorded it. `x-real-ip` is one hop's word for who the client is
|
|
23
|
+
* and wins; `x-forwarded-for` is a chain appended left to right, so its *first* entry is the original client
|
|
24
|
+
* and everything after it is a proxy.
|
|
25
|
+
*
|
|
26
|
+
* Returns `null` rather than a placeholder. A socket peer behind a proxy is the proxy, so `127.0.0.1` here
|
|
27
|
+
* would be indistinguishable from a genuinely local caller — and that is the failure this exists to prevent.
|
|
28
|
+
*/
|
|
29
|
+
export const clientAddressFromHeaders = (headers: Headers): string | null => {
|
|
30
|
+
const realIp = headers.get("x-real-ip")?.trim();
|
|
31
|
+
if (realIp) return normalizeIpAddress(realIp);
|
|
32
|
+
const forwarded = headers.get("x-forwarded-for")?.split(",")[0]?.trim();
|
|
33
|
+
return forwarded ? normalizeIpAddress(forwarded) : null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** The client port the nearest proxy recorded, for correlating a connection with a peer's own logs. */
|
|
37
|
+
export const clientPortFromHeaders = (headers: Headers): number | null => {
|
|
38
|
+
const port = Number(headers.get("x-forwarded-port"));
|
|
39
|
+
return Number.isInteger(port) && port > 0 && port <= 65535 ? port : null;
|
|
40
|
+
};
|
package/common/index.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
export { applyMixins } from "./applyMixins";
|
|
2
2
|
export { capitalize } from "./capitalize";
|
|
3
|
+
export {
|
|
4
|
+
clientAddressFromHeaders,
|
|
5
|
+
clientPortFromHeaders,
|
|
6
|
+
forwardedHeaders,
|
|
7
|
+
normalizeIpAddress,
|
|
8
|
+
} from "./clientAddress";
|
|
3
9
|
export { deepObjectify } from "./deepObjectify";
|
|
4
10
|
export {
|
|
5
11
|
type FileUploadCapability,
|
|
@@ -76,3 +82,4 @@ export {
|
|
|
76
82
|
type WebsocketAuthRequest,
|
|
77
83
|
websocketAuthContract,
|
|
78
84
|
} from "./websocketAuth";
|
|
85
|
+
export { type WebsocketBinaryFrame, websocketBinaryFrameContract } from "./websocketBinaryFrame";
|
package/common/mcpExposure.ts
CHANGED
|
@@ -67,6 +67,8 @@ export const mcpRefusalOf = (endpoint: McpExposureEndpoint, { readOnly }: McpExp
|
|
|
67
67
|
return "this deployment is read-only, which drops every endpoint that is not a query.";
|
|
68
68
|
if (endpoint.returns.refName === "Any" || endpoint.returns.refName === "Upload")
|
|
69
69
|
return `a return typed \`${endpoint.returns.refName}\` cannot be described to a model.`;
|
|
70
|
+
if (endpoint.returns.refName === "Binary")
|
|
71
|
+
return "a return typed `Binary` is raw bytes, which cost a model its window and tell it nothing.";
|
|
70
72
|
if (endpoint.fileUpload || endpoint.args.some((arg) => arg.refName === "Upload"))
|
|
71
73
|
return "a file upload has no MCP representation.";
|
|
72
74
|
if (endpoint.type === "mutation" && !endpoint.guards.some((name) => name !== "Public"))
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export interface WebsocketBinaryFrame {
|
|
2
|
+
roomId: string;
|
|
3
|
+
payload: Uint8Array;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const MAGIC = 0xab;
|
|
7
|
+
const KIND_PUB = 0x01;
|
|
8
|
+
const HEADER_BYTES = 4;
|
|
9
|
+
const MAX_ROOM_BYTES = 0xffff;
|
|
10
|
+
|
|
11
|
+
const encoder = new TextEncoder();
|
|
12
|
+
const decoder = new TextDecoder();
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Framework-owned binary pubsub frame, shared by the publisher and the client dispatcher. A room whose whole
|
|
16
|
+
* return is `Binary` sends its bytes in a websocket binary frame instead of the JSON `{ type: "pub" }` envelope,
|
|
17
|
+
* because `Buffer.toJSON()` turns bytes into `{ type: "Buffer", data: number[] }` — 3.6x the wire and ~300x the
|
|
18
|
+
* encode cost on a 64 KB payload, and a shape `JSON.parse` never restores.
|
|
19
|
+
*
|
|
20
|
+
* Text and binary frames coexist on one socket, so this is additive: every JSON endpoint is untouched, and a
|
|
21
|
+
* federation gateway relays a binary frame unchanged with no code of its own.
|
|
22
|
+
*/
|
|
23
|
+
export const websocketBinaryFrameContract = {
|
|
24
|
+
encode: ({ roomId, payload }: WebsocketBinaryFrame): Uint8Array => {
|
|
25
|
+
const room = encoder.encode(roomId);
|
|
26
|
+
if (room.length > MAX_ROOM_BYTES) throw new Error(`Room id is too long to frame: ${roomId}`);
|
|
27
|
+
const frame = new Uint8Array(HEADER_BYTES + room.length + payload.length);
|
|
28
|
+
frame[0] = MAGIC;
|
|
29
|
+
frame[1] = KIND_PUB;
|
|
30
|
+
frame[2] = room.length >> 8;
|
|
31
|
+
frame[3] = room.length & 0xff;
|
|
32
|
+
frame.set(room, HEADER_BYTES);
|
|
33
|
+
frame.set(payload, HEADER_BYTES + room.length);
|
|
34
|
+
return frame;
|
|
35
|
+
},
|
|
36
|
+
decode: (data: ArrayBuffer | Uint8Array): WebsocketBinaryFrame | null => {
|
|
37
|
+
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
38
|
+
if (bytes.length < HEADER_BYTES || bytes[0] !== MAGIC || bytes[1] !== KIND_PUB) return null;
|
|
39
|
+
const roomBytes = (bytes[2] << 8) | bytes[3];
|
|
40
|
+
if (bytes.length < HEADER_BYTES + roomBytes) return null;
|
|
41
|
+
return {
|
|
42
|
+
roomId: decoder.decode(bytes.subarray(HEADER_BYTES, HEADER_BYTES + roomBytes)),
|
|
43
|
+
payload: bytes.subarray(HEADER_BYTES + roomBytes),
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
} as const;
|
package/constant/via.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
Binary,
|
|
2
3
|
CLIENT_VALUE,
|
|
3
4
|
type Cls,
|
|
4
5
|
DEFAULT_VALUE,
|
|
@@ -439,7 +440,9 @@ const applyConstantStatics = <Model>(model: ConstantCls<Model>, fieldMap: FieldO
|
|
|
439
440
|
return { ...defaultValue };
|
|
440
441
|
},
|
|
441
442
|
});
|
|
442
|
-
Object.entries(fieldMap).forEach(([, field]) => {
|
|
443
|
+
Object.entries(fieldMap).forEach(([key, field]) => {
|
|
444
|
+
if ((field.modelRef as unknown) === Binary || (field.of as unknown) === Binary)
|
|
445
|
+
throw new Error(`Field "${key}" is Binary, which is not storable. Reference the File model instead.`);
|
|
443
446
|
if (field.enum) model.enums.add(field.enum);
|
|
444
447
|
if (!field.isClass) return;
|
|
445
448
|
if (field.isScalar) model.children.add(field.modelRef);
|
package/fetch/client/wsClient.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Logger, websocketAuthContract } from "akanjs/common";
|
|
1
|
+
import { Logger, websocketAuthContract, websocketBinaryFrameContract } from "akanjs/common";
|
|
2
2
|
import type {
|
|
3
3
|
WebsocketAuthAck,
|
|
4
4
|
WebsocketMessageData,
|
|
@@ -85,6 +85,7 @@ export class WsClient {
|
|
|
85
85
|
if (this.#destroyed) return;
|
|
86
86
|
|
|
87
87
|
this.#ws = new WebSocket(this.url);
|
|
88
|
+
this.#ws.binaryType = "arraybuffer";
|
|
88
89
|
this.#ws.onopen = (e) => {
|
|
89
90
|
this.#reconnectAttempts = 0;
|
|
90
91
|
this.connected = true;
|
|
@@ -101,7 +102,13 @@ export class WsClient {
|
|
|
101
102
|
};
|
|
102
103
|
this.#ws.onmessage = (e) => {
|
|
103
104
|
try {
|
|
104
|
-
|
|
105
|
+
if (typeof e.data !== "string") {
|
|
106
|
+
const frame = websocketBinaryFrameContract.decode(e.data as ArrayBuffer);
|
|
107
|
+
if (frame) this.#handlePubsub(frame.roomId, frame.payload);
|
|
108
|
+
else this.logger.warn("Unknown binary WebSocket frame");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const parsed = JSON.parse(e.data) as { error?: unknown } & WebsocketResData;
|
|
105
112
|
if (parsed?.error) {
|
|
106
113
|
throw this.#restoreError(parsed);
|
|
107
114
|
}
|
|
@@ -16,6 +16,13 @@ export interface QuerySetting {
|
|
|
16
16
|
* now — `() => [dayjs().subtract(1, "hour")]` — current at the moment the user asks for it.
|
|
17
17
|
*/
|
|
18
18
|
args?: unknown[] | (() => unknown[]);
|
|
19
|
+
/**
|
|
20
|
+
* The same list under the name the rest of the framework already uses for it — the `queryArgsOf<Model>` state
|
|
21
|
+
* key, `refresh<Model>({ queryArgs })`, and a field's own `.meta(...)` declaration. Accepted so a query a
|
|
22
|
+
* field declares is one of these as it stands, rather than something every caller has to rename by hand.
|
|
23
|
+
* `args` wins when both are given.
|
|
24
|
+
*/
|
|
25
|
+
queryArgs?: unknown[] | (() => unknown[]);
|
|
19
26
|
}
|
|
20
27
|
|
|
21
28
|
export type ServerInit<
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
package/server/akanApp.ts
CHANGED
|
@@ -547,7 +547,7 @@ export class AkanApp {
|
|
|
547
547
|
if (this.#isWebSocketPath(url.pathname)) return this.#upgradeWebSocket(req, server);
|
|
548
548
|
const assetResponse = await this.#serveImmutableArtifact(req, url);
|
|
549
549
|
if (assetResponse) return assetResponse;
|
|
550
|
-
return await this.#proxyHttp(req);
|
|
550
|
+
return await this.#proxyHttp(req, server);
|
|
551
551
|
}
|
|
552
552
|
|
|
553
553
|
#isWebSocketPath(pathname: string) {
|
|
@@ -592,7 +592,7 @@ export class AkanApp {
|
|
|
592
592
|
if (!child || !upstream) return new Response("No websocket upstream is ready", { status: 503 });
|
|
593
593
|
const url = new URL(req.url);
|
|
594
594
|
const upstreamWs = new WebSocket(`ws://${upstream.host}:${upstream.port}${url.pathname}${url.search}`, {
|
|
595
|
-
headers: this.#makeProxyHeaders(req, child.idx),
|
|
595
|
+
headers: this.#makeProxyHeaders(req, child.idx, server),
|
|
596
596
|
} as unknown as string[]);
|
|
597
597
|
|
|
598
598
|
const upgraded = server.upgrade(req, { data: { childIdx: child.idx, upstream: upstreamWs } });
|
|
@@ -691,14 +691,14 @@ export class AkanApp {
|
|
|
691
691
|
};
|
|
692
692
|
}
|
|
693
693
|
|
|
694
|
-
async #proxyHttp(req: Request): Promise<Response> {
|
|
694
|
+
async #proxyHttp(req: Request, server: Bun.Server<GatewayWsData>): Promise<Response> {
|
|
695
695
|
const child = this.#pickFederationChild();
|
|
696
696
|
if (!child?.upstream || child.upstream.type !== "unix") {
|
|
697
697
|
return this.#respondWithCrashPage(req) ?? new Response("No healthy federation child is ready", { status: 503 });
|
|
698
698
|
}
|
|
699
699
|
const url = new URL(req.url);
|
|
700
700
|
const upstreamUrl = `http://akan-child${url.pathname}${url.search}`;
|
|
701
|
-
const headers = this.#makeProxyHeaders(req, child.idx);
|
|
701
|
+
const headers = this.#makeProxyHeaders(req, child.idx, server);
|
|
702
702
|
child.metrics.activeRequests = (child.metrics.activeRequests ?? 0) + 1;
|
|
703
703
|
child.metrics.totalRequests = (child.metrics.totalRequests ?? 0) + 1;
|
|
704
704
|
const traced = isTraceEnabled();
|
|
@@ -867,8 +867,8 @@ export class AkanApp {
|
|
|
867
867
|
return resolved;
|
|
868
868
|
}
|
|
869
869
|
|
|
870
|
-
#makeProxyHeaders(req: Request, childIdx: number) {
|
|
871
|
-
return makeAkanChildProxyHeaders(req, childIdx);
|
|
870
|
+
#makeProxyHeaders(req: Request, childIdx: number, server?: Bun.Server<GatewayWsData>) {
|
|
871
|
+
return makeAkanChildProxyHeaders(req, childIdx, server?.requestIP(req));
|
|
872
872
|
}
|
|
873
873
|
|
|
874
874
|
#invalidateFederationChildCache() {
|
package/server/akanAppHeaders.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { normalizeIpAddress } from "akanjs/common";
|
|
2
|
+
|
|
1
3
|
const HOP_BY_HOP_HEADERS = new Set([
|
|
2
4
|
"connection",
|
|
3
5
|
"keep-alive",
|
|
@@ -9,13 +11,23 @@ const HOP_BY_HOP_HEADERS = new Set([
|
|
|
9
11
|
"upgrade",
|
|
10
12
|
]);
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
/** What `Server.requestIP` reports for the socket this hop accepted. */
|
|
15
|
+
export interface ProxyClientPeer {
|
|
16
|
+
address: string;
|
|
17
|
+
port: number;
|
|
18
|
+
family: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function makeAkanChildProxyHeaders(req: Request, childIdx: number, peer?: ProxyClientPeer | null): Headers {
|
|
13
22
|
const headers = new Headers(req.headers);
|
|
14
23
|
for (const key of HOP_BY_HOP_HEADERS) headers.delete(key);
|
|
15
24
|
const forwardedFor = headers.get("x-forwarded-for");
|
|
16
|
-
|
|
25
|
+
|
|
26
|
+
const clientAddress = headers.get("x-real-ip") ?? (peer ? normalizeIpAddress(peer.address) : "127.0.0.1");
|
|
17
27
|
const host = headers.get("host");
|
|
28
|
+
headers.set("x-real-ip", clientAddress);
|
|
18
29
|
headers.set("x-forwarded-for", forwardedFor ? `${forwardedFor}, ${clientAddress}` : clientAddress);
|
|
30
|
+
if (peer && !headers.has("x-forwarded-port")) headers.set("x-forwarded-port", String(peer.port));
|
|
19
31
|
headers.set("x-forwarded-host", headers.get("x-forwarded-host") ?? host ?? new URL(req.url).host);
|
|
20
32
|
headers.set(
|
|
21
33
|
"x-forwarded-proto",
|
package/server/akanServer.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type BackendEnv, type BaseEnv, getEnv } from "akanjs/base";
|
|
2
|
-
import { Logger } from "akanjs/common";
|
|
2
|
+
import { Logger, websocketBinaryFrameContract } from "akanjs/common";
|
|
3
3
|
import { DictionaryLookup } from "akanjs/dictionary";
|
|
4
4
|
import type {
|
|
5
5
|
Adaptor,
|
|
@@ -16,6 +16,7 @@ import { createOpenApiDocument } from "../signal/openapi";
|
|
|
16
16
|
import { FetchSerializer } from "../signal/serializer";
|
|
17
17
|
import type { AkanLib, AkanLibProps } from "./akanLib";
|
|
18
18
|
import type { BuilderRpc } from "./artifact";
|
|
19
|
+
import { BinaryPubsub } from "./binaryPubsub";
|
|
19
20
|
import { DevtoolsRouter } from "./devtools";
|
|
20
21
|
import { DiLifecycle } from "./di/diLifecycle";
|
|
21
22
|
import type { HmrWsData, HmrWsHub } from "./hmr/wsHub";
|
|
@@ -27,7 +28,7 @@ import { WebProxyRunner } from "./proxy";
|
|
|
27
28
|
import { SignalResolver } from "./resolver";
|
|
28
29
|
import { ApiRouter } from "./routing/apiRouter";
|
|
29
30
|
import type { AppWsData } from "./routing/appWsData";
|
|
30
|
-
import type { HttpRoutes, SignalRoutes, WebsocketRoutes } from "./types";
|
|
31
|
+
import type { HttpRoutes, LocalPublish, SignalRoutes, WebsocketRoutes } from "./types";
|
|
31
32
|
import type { WebRouter } from "./webRouter";
|
|
32
33
|
|
|
33
34
|
export interface AkanServerProps extends AkanLibProps {
|
|
@@ -138,7 +139,8 @@ export class AkanServer {
|
|
|
138
139
|
shutdownTimeoutMs = AkanServer.#defaultShutdownTimeoutMs();
|
|
139
140
|
|
|
140
141
|
#di: DiLifecycle;
|
|
141
|
-
#localPublish:
|
|
142
|
+
#localPublish: LocalPublish | null = null;
|
|
143
|
+
readonly #binaryPubsub = new BinaryPubsub();
|
|
142
144
|
#metricsTimer: Timer | null = null;
|
|
143
145
|
constructor(
|
|
144
146
|
name = "AkanServer",
|
|
@@ -318,6 +320,7 @@ export class AkanServer {
|
|
|
318
320
|
hmrHub,
|
|
319
321
|
hmrState: webRouter ? { state: webRouter.renderState } : null,
|
|
320
322
|
logger: this.logger,
|
|
323
|
+
onDrain: () => this.#binaryPubsub.flush(),
|
|
321
324
|
}),
|
|
322
325
|
|
|
323
326
|
data: {},
|
|
@@ -375,16 +378,20 @@ export class AkanServer {
|
|
|
375
378
|
|
|
376
379
|
const websocket = this.#di.getWebsocketAdaptor();
|
|
377
380
|
if (!websocket) throw new Error("WebSocket Redis adaptor is not registered");
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
381
|
+
this.#binaryPubsub.setServers(server, wsServer);
|
|
382
|
+
const localPublish: LocalPublish = (roomId, data) => {
|
|
383
|
+
if (data instanceof Uint8Array) {
|
|
384
|
+
this.#binaryPubsub.publish(roomId, websocketBinaryFrameContract.encode({ roomId, payload: data }), {
|
|
385
|
+
coalesce: SignalResolver.coalescesRoom(roomId),
|
|
386
|
+
});
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
384
389
|
const publishData: WebsocketPublishData = { type: "pub", roomId, data };
|
|
385
390
|
server?.publish(roomId, JSON.stringify(publishData));
|
|
386
391
|
wsServer?.publish(roomId, JSON.stringify(publishData));
|
|
387
392
|
};
|
|
393
|
+
SignalResolver.setLocalPublish(localPublish, websocket);
|
|
394
|
+
this.#localPublish = localPublish;
|
|
388
395
|
|
|
389
396
|
this.status = "running";
|
|
390
397
|
this.#startMetricsReporting();
|
|
@@ -479,7 +486,8 @@ export class AkanServer {
|
|
|
479
486
|
|
|
480
487
|
#handleIpcMessage(message: AkanIpcMessage) {
|
|
481
488
|
if (!message || typeof message !== "object") return;
|
|
482
|
-
if (message.type === "pubsub.deliver")
|
|
489
|
+
if (message.type === "pubsub.deliver")
|
|
490
|
+
this.#localPublish?.(message.roomId, message.data as object | object[] | Uint8Array);
|
|
483
491
|
else if (message.type === "health.ping")
|
|
484
492
|
process.send?.({
|
|
485
493
|
type: "health.pong",
|
|
@@ -506,6 +514,7 @@ export class AkanServer {
|
|
|
506
514
|
async #reportMetrics() {
|
|
507
515
|
const metrics = await ProcessMetricsCollector.collect({
|
|
508
516
|
role: this.serverMode,
|
|
517
|
+
pubsubCoalesceCount: this.#binaryPubsub.coalescedCount,
|
|
509
518
|
...(this.#prepared?.webRouter?.getMetrics() ?? {}),
|
|
510
519
|
});
|
|
511
520
|
process.send?.({ type: "metrics.report", pid: process.pid, metrics } satisfies AkanIpcMessage);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Logger } from "akanjs/common";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sends binary pubsub frames and absorbs a slow subscriber. `Server.publish` reports `-1` when the socket is
|
|
5
|
+
* backpressured, `0` when the room has no subscriber, and the byte count otherwise — so a room that cannot
|
|
6
|
+
* keep up parks its newest frame here and every earlier one is dropped, rather than queueing behind the
|
|
7
|
+
* slowest subscriber until the send buffer is the stream. Bun fires `drain` per socket when its buffer
|
|
8
|
+
* empties; that is what flushes the parked frames.
|
|
9
|
+
*/
|
|
10
|
+
export class BinaryPubsub {
|
|
11
|
+
readonly #logger = new Logger("BinaryPubsub");
|
|
12
|
+
readonly #pending = new Map<string, Uint8Array>();
|
|
13
|
+
#servers: Bun.Server<unknown>[] = [];
|
|
14
|
+
#coalescedCount = 0;
|
|
15
|
+
|
|
16
|
+
get coalescedCount() {
|
|
17
|
+
return this.#coalescedCount;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
setServers(...servers: (Bun.Server<never> | null)[]) {
|
|
21
|
+
this.#servers = servers.filter((server): server is Bun.Server<never> => !!server) as Bun.Server<unknown>[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
publish(roomId: string, frame: Uint8Array, { coalesce = false }: { coalesce?: boolean } = {}) {
|
|
25
|
+
if (!this.#send(roomId, frame) || !coalesce) return;
|
|
26
|
+
if (this.#pending.has(roomId)) this.#coalescedCount += 1;
|
|
27
|
+
this.#pending.set(roomId, frame);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Retries every parked room. A room still backpressured re-parks itself and waits for the next drain. */
|
|
31
|
+
flush() {
|
|
32
|
+
if (!this.#pending.size) return;
|
|
33
|
+
for (const [roomId, frame] of [...this.#pending]) {
|
|
34
|
+
this.#pending.delete(roomId);
|
|
35
|
+
if (this.#send(roomId, frame)) this.#pending.set(roomId, frame);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
clear() {
|
|
40
|
+
this.#pending.clear();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** True when at least one server refused the frame for backpressure, which is the only case worth parking. */
|
|
44
|
+
#send(roomId: string, frame: Uint8Array): boolean {
|
|
45
|
+
let backpressured = false;
|
|
46
|
+
for (const server of this.#servers) {
|
|
47
|
+
try {
|
|
48
|
+
if (server.publish(roomId, frame) === -1) backpressured = true;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
this.#logger.warn(`Binary publish failed for ${roomId}: ${error instanceof Error ? error.message : error}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return backpressured;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type BackendEnv,
|
|
3
|
+
Binary,
|
|
3
4
|
type Cls,
|
|
4
5
|
ENDPOINT_META,
|
|
5
6
|
FIELD_META,
|
|
@@ -31,7 +32,7 @@ import { SignalContext, type WebSocketExecutionContext } from "../../signal/sign
|
|
|
31
32
|
import type { SliceCls } from "../../signal/slice";
|
|
32
33
|
import type { SliceInfo } from "../../signal/sliceInfo";
|
|
33
34
|
import type { WebsocketMessageData, WebsocketSubscribeAck } from "../../signal/types";
|
|
34
|
-
import type { HttpRoutes, SignalRoutes, WebsocketRoutes } from "../types";
|
|
35
|
+
import type { HttpRoutes, LocalPublish, SignalRoutes, WebsocketRoutes } from "../types";
|
|
35
36
|
|
|
36
37
|
type HttpRouteHandler = (req: Bun.BunRequest) => Response | Promise<Response | undefined> | undefined;
|
|
37
38
|
type HttpMethodRoutes = Record<string, HttpRouteHandler>;
|
|
@@ -42,12 +43,18 @@ export class SignalResolver {
|
|
|
42
43
|
static makeRoomId(key: string, args: unknown[]) {
|
|
43
44
|
return `${key}${args.length ? "-" : ""}${args.join("-")}`;
|
|
44
45
|
}
|
|
45
|
-
static #localPublish:
|
|
46
|
+
static #localPublish: LocalPublish = () => {
|
|
46
47
|
SignalResolver.logger.verbose(`Local publish is not initialized yet`);
|
|
47
48
|
};
|
|
48
|
-
static
|
|
49
|
+
static readonly #coalescingRooms = new Set<string>();
|
|
50
|
+
|
|
51
|
+
static coalescesRoom(roomId: string): boolean {
|
|
52
|
+
const separator = roomId.indexOf("-");
|
|
53
|
+
return SignalResolver.#coalescingRooms.has(separator >= 0 ? roomId.slice(0, separator) : roomId);
|
|
54
|
+
}
|
|
55
|
+
static setLocalPublish(localPublish: LocalPublish, websocket: WebsocketAdaptor) {
|
|
49
56
|
SignalResolver.#localPublish = localPublish;
|
|
50
|
-
websocket.setEventHandler((roomId, data) => localPublish(roomId, data as object | object[]));
|
|
57
|
+
websocket.setEventHandler((roomId, data) => localPublish(roomId, data as object | object[] | Uint8Array));
|
|
51
58
|
}
|
|
52
59
|
static resolveServerSignal(
|
|
53
60
|
serverSignalCls: ServerSignalCls,
|
|
@@ -59,6 +66,9 @@ export class SignalResolver {
|
|
|
59
66
|
Object.entries(endpointMeta).forEach(([key, endpointInfo]) => {
|
|
60
67
|
if (endpointInfo.type !== "pubsub") throw new Error(`Endpoint ${key} is not a pubsub endpoint`);
|
|
61
68
|
websocket.registerEndpoint(key, endpointInfo.returns.returnRef as Cls, endpointInfo.returns.arrDepth);
|
|
69
|
+
const isBinaryFrame = endpointInfo.returns.returnRef === Binary && !endpointInfo.returns.arrDepth;
|
|
70
|
+
if (isBinaryFrame && endpointInfo.signalOption.backpressure !== "queue") SignalResolver.#coalescingRooms.add(key);
|
|
71
|
+
let warnedRawBytes = false;
|
|
62
72
|
const serializeFn = (data: unknown) =>
|
|
63
73
|
serialize(endpointInfo.returns.returnRef, endpointInfo.returns.arrDepth, data, "object", {
|
|
64
74
|
nullable: endpointInfo.returns.nullable,
|
|
@@ -74,12 +84,22 @@ export class SignalResolver {
|
|
|
74
84
|
registry,
|
|
75
85
|
live,
|
|
76
86
|
});
|
|
87
|
+
const roomId = SignalResolver.makeRoomId(key, roomArgs);
|
|
88
|
+
if (isBinaryFrame) {
|
|
89
|
+
const bytes = Binary._parse(resolvedData as Uint8Array) as Uint8Array;
|
|
90
|
+
websocket.publish(roomId, bytes);
|
|
91
|
+
SignalResolver.#localPublish(roomId, bytes);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
77
94
|
const serializedData = serializeFn(resolvedData) as object | object[] | null;
|
|
78
95
|
if (!serializedData) {
|
|
79
96
|
this.logger.warn(`Failed to serialize data for ${key}`);
|
|
80
97
|
return;
|
|
81
98
|
}
|
|
82
|
-
|
|
99
|
+
if (!warnedRawBytes && ArrayBuffer.isView(serializedData)) {
|
|
100
|
+
warnedRawBytes = true;
|
|
101
|
+
this.logger.warn(`${key} publishes bytes but does not return Binary; declare pubsub(Binary) instead.`);
|
|
102
|
+
}
|
|
83
103
|
websocket.publish(roomId, serializedData);
|
|
84
104
|
SignalResolver.#localPublish(roomId, serializedData);
|
|
85
105
|
},
|
|
@@ -44,6 +44,7 @@ export interface WebsocketHandlersInputs {
|
|
|
44
44
|
hmrHub: HmrWsHub | null;
|
|
45
45
|
hmrState: HmrStateSource | null;
|
|
46
46
|
logger: Logger;
|
|
47
|
+
onDrain?: () => void;
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
type WsTaggedData = { kind?: string };
|
|
@@ -111,8 +112,11 @@ export class ApiRouter {
|
|
|
111
112
|
hmrHub,
|
|
112
113
|
hmrState,
|
|
113
114
|
logger,
|
|
115
|
+
onDrain,
|
|
114
116
|
}: WebsocketHandlersInputs): Bun.WebSocketHandler<WsTaggedData> {
|
|
115
117
|
return {
|
|
118
|
+
|
|
119
|
+
drain: () => onDrain?.(),
|
|
116
120
|
open: (ws) => {
|
|
117
121
|
|
|
118
122
|
const data = ws.data as WsTaggedData | undefined;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { clientAddressFromHeaders, clientPortFromHeaders, forwardedHeaders, normalizeIpAddress } from "akanjs/common";
|
|
2
|
+
|
|
1
3
|
const CREDENTIAL_HEADERS = ["authorization", "cookie", "user-agent"] as const;
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -9,7 +11,8 @@ const CREDENTIAL_HEADERS = ["authorization", "cookie", "user-agent"] as const;
|
|
|
9
11
|
export class AppWsData {
|
|
10
12
|
static fromRequest(req: Request): AppWsData {
|
|
11
13
|
const headers = new Headers();
|
|
12
|
-
|
|
14
|
+
|
|
15
|
+
for (const key of [...CREDENTIAL_HEADERS, ...forwardedHeaders]) {
|
|
13
16
|
const value = req.headers.get(key);
|
|
14
17
|
if (value) headers.set(key, value);
|
|
15
18
|
}
|
|
@@ -48,6 +51,18 @@ export class AppWsData {
|
|
|
48
51
|
* It outlives a credential swap on purpose: the socket is still the same socket.
|
|
49
52
|
*/
|
|
50
53
|
socketId: string;
|
|
54
|
+
/** The caller's address as the nearest proxy recorded it, or null when nothing did. */
|
|
55
|
+
get ip(): string | null {
|
|
56
|
+
return clientAddressFromHeaders(this.headers);
|
|
57
|
+
}
|
|
58
|
+
/** The caller's source port as the nearest proxy recorded it, or null when nothing did. */
|
|
59
|
+
get port(): number | null {
|
|
60
|
+
return clientPortFromHeaders(this.headers);
|
|
61
|
+
}
|
|
62
|
+
/** The address to answer on: what a proxy recorded, else this socket's own peer. */
|
|
63
|
+
ipOf(ws: Bun.ServerWebSocket<unknown>): string | null {
|
|
64
|
+
return this.ip ?? (ws.remoteAddress ? normalizeIpAddress(ws.remoteAddress) : null);
|
|
65
|
+
}
|
|
51
66
|
constructor(headers: Headers) {
|
|
52
67
|
this.createdAt = Date.now();
|
|
53
68
|
this.headers = headers;
|
package/server/types.tsx
CHANGED
|
@@ -13,6 +13,8 @@ export type WebsocketRoutes = Record<string, WebsocketRoute>;
|
|
|
13
13
|
|
|
14
14
|
export type HttpRoutes = Bun.Serve.Options<unknown>["routes"];
|
|
15
15
|
|
|
16
|
+
export type LocalPublish = (roomId: string, data: object | object[] | Uint8Array) => void;
|
|
17
|
+
|
|
16
18
|
export interface SignalRouteOptions {
|
|
17
19
|
globalPrefix?: false;
|
|
18
20
|
}
|
package/service/ipcTypes.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
Any,
|
|
3
3
|
applyFnToArrayObjects,
|
|
4
|
+
Binary,
|
|
4
5
|
type Cls,
|
|
5
6
|
type Dayjs,
|
|
6
7
|
dayjs,
|
|
@@ -41,10 +42,12 @@ export interface CompressAdaptor {
|
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
export class JsonCompressor extends adapt("jsonCompressor", () => ({})) implements CompressAdaptor {
|
|
44
|
-
encode(
|
|
45
|
+
encode(ref: Cls, arrDepth: number, value: unknown): Buffer | null {
|
|
46
|
+
if (ref === Binary && !arrDepth) return Buffer.from(value as Uint8Array);
|
|
45
47
|
return Buffer.from(JSON.stringify(value));
|
|
46
48
|
}
|
|
47
49
|
decode<T = unknown>(ref: Cls, arrDepth: number, buffer: Buffer, { raw = false }: { raw?: boolean } = {}): T {
|
|
50
|
+
if (ref === Binary && !arrDepth) return buffer as T;
|
|
48
51
|
const value = JSON.parse(buffer.toString()) as T;
|
|
49
52
|
if (raw || arrDepth > 0 || PrimitiveRegistry.has(ref)) return value;
|
|
50
53
|
return new (ref as ConstantCls)().set(value as object) as T;
|
|
@@ -59,6 +62,7 @@ export class ProtobufCompressor extends adapt("protobufCompressor", () => ({}))
|
|
|
59
62
|
[Float, "float"],
|
|
60
63
|
[Boolean, "bool"],
|
|
61
64
|
[Date, "double"],
|
|
65
|
+
[Binary, "bytes"],
|
|
62
66
|
]);
|
|
63
67
|
#primitiveProtoEncodeMap = new Map<PrimitiveScalar, (value: never) => unknown>([
|
|
64
68
|
[Date, (value: Date | Dayjs) => dayjs(value).toDate().getTime()],
|
package/signal/internalArg.ts
CHANGED
|
@@ -21,6 +21,19 @@ export class Res implements InternalArg {
|
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Injects the caller's IP, as the nearest proxy recorded it rather than as the socket peer reports it.
|
|
26
|
+
* Behind the federation gateway every peer is the gateway, so an endpoint that reads `remoteAddress` sees
|
|
27
|
+
* `127.0.0.1` for every caller — this reads the forwarded headers first and falls back to the peer only
|
|
28
|
+
* when nothing proxied the call. IPv4 comes back unwrapped from `::ffff:`, so it can address a `udp4`
|
|
29
|
+
* socket. `null` when no proxy recorded one and the transport has no peer.
|
|
30
|
+
*/
|
|
31
|
+
export class Ip implements InternalArg<string | null> {
|
|
32
|
+
getArg(context: SignalContext): string | null {
|
|
33
|
+
return context.getClientIp();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
24
37
|
/**
|
|
25
38
|
* Injects websocket state, this connection's id, and subscription hooks into message/pubsub handlers.
|
|
26
39
|
* `socketId` is the one `AppWsData` minted at the handshake, so a handler never reads `ws.data` to
|
|
@@ -210,6 +210,7 @@ const getProtectedGuards = (guards?: string[]) =>
|
|
|
210
210
|
|
|
211
211
|
const isBinaryResponseEndpoint = (endpoint: SerializedEndpoint) =>
|
|
212
212
|
endpoint.returns.refName === "Upload" ||
|
|
213
|
+
endpoint.returns.refName === "Binary" ||
|
|
213
214
|
(endpoint.returns.refName === "Any" &&
|
|
214
215
|
Boolean(endpoint.path?.includes("*") || endpoint.path?.toLowerCase().includes("blob")));
|
|
215
216
|
|
|
@@ -193,6 +193,8 @@ export class JsonSchemaBuilder {
|
|
|
193
193
|
return { type: "integer" };
|
|
194
194
|
case "Upload":
|
|
195
195
|
return { type: "string", format: "binary" };
|
|
196
|
+
case "Binary":
|
|
197
|
+
return { type: "string", contentEncoding: "base64" };
|
|
196
198
|
case "Any":
|
|
197
199
|
return {};
|
|
198
200
|
default:
|
package/signal/signalContext.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type PromiseOrObject,
|
|
9
9
|
Upload,
|
|
10
10
|
} from "akanjs/base";
|
|
11
|
+
import { clientAddressFromHeaders, clientPortFromHeaders, normalizeIpAddress } from "akanjs/common";
|
|
11
12
|
import {
|
|
12
13
|
type ConstantCls,
|
|
13
14
|
type ConstantFieldTypeInput,
|
|
@@ -429,6 +430,27 @@ export class SignalContext<
|
|
|
429
430
|
if (this.transport === "http") return this.getHttpContext<{ [key: string]: T }>().req[key] ?? null;
|
|
430
431
|
return this.getWebSocketContext<{ [key: string]: T }>().ws.data[key] ?? null;
|
|
431
432
|
}
|
|
433
|
+
/**
|
|
434
|
+
* The caller's IP, preferring what a proxy recorded over the socket peer. Behind the federation gateway the
|
|
435
|
+
* peer is the gateway itself for every request and for the whole life of every socket, so `remoteAddress`
|
|
436
|
+
* alone names the wrong machine — which is why nothing here reads it first. IPv4 arrives unwrapped from its
|
|
437
|
+
* `::ffff:` form, so it can be used as a destination as well as an identity.
|
|
438
|
+
*
|
|
439
|
+
* `null` means no proxy recorded one and the transport has no peer to fall back on — never a placeholder,
|
|
440
|
+
* because a loopback-looking address for an unknown caller is the failure this replaced.
|
|
441
|
+
*/
|
|
442
|
+
getClientIp(): string | null {
|
|
443
|
+
if (this.transport === "http") return clientAddressFromHeaders(this.getHttpContext().req.headers);
|
|
444
|
+
const { ws } = this.getWebSocketContext<{ headers?: Headers }>();
|
|
445
|
+
const forwarded = ws.data.headers ? clientAddressFromHeaders(ws.data.headers) : null;
|
|
446
|
+
return forwarded ?? (ws.remoteAddress ? normalizeIpAddress(ws.remoteAddress) : null);
|
|
447
|
+
}
|
|
448
|
+
/** The caller's source port as the nearest proxy recorded it, else this socket's own. */
|
|
449
|
+
getClientPort(): number | null {
|
|
450
|
+
if (this.transport === "http") return clientPortFromHeaders(this.getHttpContext().req.headers);
|
|
451
|
+
const { ws } = this.getWebSocketContext<{ headers?: Headers }>();
|
|
452
|
+
return (ws.data.headers ? clientPortFromHeaders(ws.data.headers) : null) ?? null;
|
|
453
|
+
}
|
|
432
454
|
getRoomId(key: string) {
|
|
433
455
|
if (this.transport !== "websocket") throw new Error("Transport is not websocket");
|
|
434
456
|
else if (this.endpointInfo.type !== "pubsub") throw new Error("Endpoint is not pubsub");
|
package/signal/types.ts
CHANGED
|
@@ -85,6 +85,13 @@ export interface SignalOption<Response = any, Nullable extends boolean = false,
|
|
|
85
85
|
method?: HttpMutationMethod;
|
|
86
86
|
/** Marks this mutation as the framework file-upload endpoint (see resolveFileUploadCapability). */
|
|
87
87
|
fileUpload?: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* What a `pubsub(Binary)` does when a subscriber cannot keep up. `"coalesce"` (the default) keeps only the
|
|
90
|
+
* newest frame per room, which is what a telemetry or video stream wants — an old frame is worthless once a
|
|
91
|
+
* newer one exists. Name `"queue"` when the frames are a sequence a subscriber has to see in full, such as
|
|
92
|
+
* deltas against a base it already holds; the send buffer then grows with the slowest subscriber.
|
|
93
|
+
*/
|
|
94
|
+
backpressure?: "coalesce" | "queue";
|
|
88
95
|
|
|
89
96
|
scheduleType?: "init" | "destroy" | "cron" | "interval" | "timeout";
|
|
90
97
|
scheduleCron?: string;
|
package/test/sampleOf.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
Any,
|
|
3
|
+
Binary,
|
|
3
4
|
type Cls,
|
|
4
5
|
FIELD_META,
|
|
5
6
|
Float,
|
|
@@ -28,6 +29,7 @@ const scalarSampleMap = new Map<PrimitiveScalar, () => any>([
|
|
|
28
29
|
[Boolean, () => sample.bool()],
|
|
29
30
|
[Date, () => sample.dayjs()],
|
|
30
31
|
[Upload, () => "FileUpload"],
|
|
32
|
+
[Binary, () => new Uint8Array([0, 1, 2])],
|
|
31
33
|
[Any, () => ({})],
|
|
32
34
|
]);
|
|
33
35
|
const getPrimitiveSample = (ref: Cls, field: ConstantField) => {
|
|
@@ -17,7 +17,7 @@ export declare class PrimitiveRegistry {
|
|
|
17
17
|
static getNames(): string[];
|
|
18
18
|
static getAll(): (typeof PrimitiveScalar)[];
|
|
19
19
|
}
|
|
20
|
-
export type PrimitiveValue = string | number | boolean | Dayjs | Date | null | undefined;
|
|
20
|
+
export type PrimitiveValue = string | number | boolean | Dayjs | Date | Uint8Array | null | undefined;
|
|
21
21
|
export declare class PrimitiveScalar {
|
|
22
22
|
static refName: string;
|
|
23
23
|
static [SERVER_VALUE]: unknown;
|
|
@@ -83,6 +83,18 @@ export declare class Any extends PrimitiveScalar {
|
|
|
83
83
|
static [DEFAULT_VALUE]: object | null;
|
|
84
84
|
static [EXAMPLE_VALUE]: object;
|
|
85
85
|
}
|
|
86
|
+
export declare class Binary extends PrimitiveScalar {
|
|
87
|
+
#private;
|
|
88
|
+
static refName: "Binary";
|
|
89
|
+
static [SERVER_VALUE]: Uint8Array;
|
|
90
|
+
static [CLIENT_VALUE]: Uint8Array;
|
|
91
|
+
static [DEFAULT_VALUE]: Uint8Array | null;
|
|
92
|
+
static [PURIFIED_VALUE]: Uint8Array | null;
|
|
93
|
+
static [EXAMPLE_VALUE]: string;
|
|
94
|
+
static validate(value: Uint8Array | string): boolean;
|
|
95
|
+
static parseValue(input: Uint8Array | string): Uint8Array;
|
|
96
|
+
static serializeValue(value: Uint8Array | string): string;
|
|
97
|
+
}
|
|
86
98
|
export declare class Upload extends PrimitiveScalar {
|
|
87
99
|
static refName: "Upload";
|
|
88
100
|
static [SERVER_VALUE]: File;
|
|
@@ -136,4 +148,4 @@ declare global {
|
|
|
136
148
|
_checkValue(value: Date): void;
|
|
137
149
|
}
|
|
138
150
|
}
|
|
139
|
-
export type DefaultPrimitiveName = "String" | "Boolean" | "Date" | "Int" | "Float" | "ID" | "Any" | "Upload";
|
|
151
|
+
export type DefaultPrimitiveName = "String" | "Boolean" | "Date" | "Int" | "Float" | "ID" | "Any" | "Binary" | "Upload";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Headers a proxy hop writes so the next hop can still name the original caller. */
|
|
2
|
+
export declare const forwardedHeaders: readonly ["x-real-ip", "x-forwarded-for", "x-forwarded-port", "x-forwarded-host", "x-forwarded-proto"];
|
|
3
|
+
/**
|
|
4
|
+
* An IPv4 client reaching a dual-stack listener is reported as `::ffff:203.0.113.10`. That form is not a
|
|
5
|
+
* valid destination for a `udp4` socket and does not compare equal to the same address written plainly, so
|
|
6
|
+
* it is unwrapped at every boundary rather than at each call site that happens to remember.
|
|
7
|
+
*/
|
|
8
|
+
export declare const normalizeIpAddress: (address: string) => string;
|
|
9
|
+
/**
|
|
10
|
+
* The caller's address as the nearest proxy recorded it. `x-real-ip` is one hop's word for who the client is
|
|
11
|
+
* and wins; `x-forwarded-for` is a chain appended left to right, so its *first* entry is the original client
|
|
12
|
+
* and everything after it is a proxy.
|
|
13
|
+
*
|
|
14
|
+
* Returns `null` rather than a placeholder. A socket peer behind a proxy is the proxy, so `127.0.0.1` here
|
|
15
|
+
* would be indistinguishable from a genuinely local caller — and that is the failure this exists to prevent.
|
|
16
|
+
*/
|
|
17
|
+
export declare const clientAddressFromHeaders: (headers: Headers) => string | null;
|
|
18
|
+
/** The client port the nearest proxy recorded, for correlating a connection with a peer's own logs. */
|
|
19
|
+
export declare const clientPortFromHeaders: (headers: Headers) => number | null;
|
package/types/common/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { applyMixins } from "./applyMixins.d.ts";
|
|
2
2
|
export { capitalize } from "./capitalize.d.ts";
|
|
3
|
+
export { clientAddressFromHeaders, clientPortFromHeaders, forwardedHeaders, normalizeIpAddress, } from "./clientAddress.d.ts";
|
|
3
4
|
export { deepObjectify } from "./deepObjectify.d.ts";
|
|
4
5
|
export { type FileUploadCapability, fileUploadContract, resolveFileUploadCapability, } from "./fileUpload.d.ts";
|
|
5
6
|
export { formatNumber } from "./formatNumber.d.ts";
|
|
@@ -31,3 +32,4 @@ export { splitVersion } from "./splitVersion.d.ts";
|
|
|
31
32
|
export { getBasePathFromPathname, parseBasePaths, parseSubRouteHosts, resolveSubRouteHosts } from "./subRoute.d.ts";
|
|
32
33
|
export type * from "./types.d.ts";
|
|
33
34
|
export { type WebsocketAuthAckData, type WebsocketAuthRequest, websocketAuthContract, } from "./websocketAuth.d.ts";
|
|
35
|
+
export { type WebsocketBinaryFrame, websocketBinaryFrameContract } from "./websocketBinaryFrame.d.ts";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface WebsocketBinaryFrame {
|
|
2
|
+
roomId: string;
|
|
3
|
+
payload: Uint8Array;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Framework-owned binary pubsub frame, shared by the publisher and the client dispatcher. A room whose whole
|
|
7
|
+
* return is `Binary` sends its bytes in a websocket binary frame instead of the JSON `{ type: "pub" }` envelope,
|
|
8
|
+
* because `Buffer.toJSON()` turns bytes into `{ type: "Buffer", data: number[] }` — 3.6x the wire and ~300x the
|
|
9
|
+
* encode cost on a 64 KB payload, and a shape `JSON.parse` never restores.
|
|
10
|
+
*
|
|
11
|
+
* Text and binary frames coexist on one socket, so this is additive: every JSON endpoint is untouched, and a
|
|
12
|
+
* federation gateway relays a binary frame unchanged with no code of its own.
|
|
13
|
+
*/
|
|
14
|
+
export declare const websocketBinaryFrameContract: {
|
|
15
|
+
readonly encode: ({ roomId, payload }: WebsocketBinaryFrame) => Uint8Array;
|
|
16
|
+
readonly decode: (data: ArrayBuffer | Uint8Array) => WebsocketBinaryFrame | null;
|
|
17
|
+
};
|
|
@@ -14,6 +14,13 @@ export interface QuerySetting {
|
|
|
14
14
|
* now — `() => [dayjs().subtract(1, "hour")]` — current at the moment the user asks for it.
|
|
15
15
|
*/
|
|
16
16
|
args?: unknown[] | (() => unknown[]);
|
|
17
|
+
/**
|
|
18
|
+
* The same list under the name the rest of the framework already uses for it — the `queryArgsOf<Model>` state
|
|
19
|
+
* key, `refresh<Model>({ queryArgs })`, and a field's own `.meta(...)` declaration. Accepted so a query a
|
|
20
|
+
* field declares is one of these as it stands, rather than something every caller has to rename by hand.
|
|
21
|
+
* `args` wins when both are given.
|
|
22
|
+
*/
|
|
23
|
+
queryArgs?: unknown[] | (() => unknown[]);
|
|
17
24
|
}
|
|
18
25
|
export type ServerInit<RefName extends string, Light, Insight = any, QueryArgs = any, Filter extends FilterInstance = any, _CapitalizedRefName extends string = Capitalize<RefName>, _LightObj = GetStateObject<Light>, _InsightObj = GetStateObject<Insight>, _Sort = ExtractSort<Filter>> = SliceMeta & {
|
|
19
26
|
[K in `${RefName}ObjList`]: _LightObj[];
|
|
@@ -1 +1,7 @@
|
|
|
1
|
-
|
|
1
|
+
/** What `Server.requestIP` reports for the socket this hop accepted. */
|
|
2
|
+
export interface ProxyClientPeer {
|
|
3
|
+
address: string;
|
|
4
|
+
port: number;
|
|
5
|
+
family: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function makeAkanChildProxyHeaders(req: Request, childIdx: number, peer?: ProxyClientPeer | null): Headers;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sends binary pubsub frames and absorbs a slow subscriber. `Server.publish` reports `-1` when the socket is
|
|
3
|
+
* backpressured, `0` when the room has no subscriber, and the byte count otherwise — so a room that cannot
|
|
4
|
+
* keep up parks its newest frame here and every earlier one is dropped, rather than queueing behind the
|
|
5
|
+
* slowest subscriber until the send buffer is the stream. Bun fires `drain` per socket when its buffer
|
|
6
|
+
* empties; that is what flushes the parked frames.
|
|
7
|
+
*/
|
|
8
|
+
export declare class BinaryPubsub {
|
|
9
|
+
#private;
|
|
10
|
+
get coalescedCount(): number;
|
|
11
|
+
setServers(...servers: (Bun.Server<never> | null)[]): void;
|
|
12
|
+
publish(roomId: string, frame: Uint8Array, { coalesce }?: {
|
|
13
|
+
coalesce?: boolean;
|
|
14
|
+
}): void;
|
|
15
|
+
/** Retries every parked room. A room still backpressured re-parks itself and waits for the next drain. */
|
|
16
|
+
flush(): void;
|
|
17
|
+
clear(): void;
|
|
18
|
+
}
|
|
@@ -7,12 +7,13 @@ import type { InternalInfo } from "../../signal/internalInfo.d.ts";
|
|
|
7
7
|
import type { MiddlewareCls } from "../../signal/middleware.d.ts";
|
|
8
8
|
import type { ServerSignalCls } from "../../signal/serverSignal.d.ts";
|
|
9
9
|
import type { SliceCls } from "../../signal/slice.d.ts";
|
|
10
|
-
import type { HttpRoutes, SignalRoutes } from "../types.d.ts";
|
|
10
|
+
import type { HttpRoutes, LocalPublish, SignalRoutes } from "../types.d.ts";
|
|
11
11
|
export declare class SignalResolver {
|
|
12
12
|
#private;
|
|
13
13
|
static logger: Logger;
|
|
14
14
|
static makeRoomId(key: string, args: unknown[]): string;
|
|
15
|
-
static
|
|
15
|
+
static coalescesRoom(roomId: string): boolean;
|
|
16
|
+
static setLocalPublish(localPublish: LocalPublish, websocket: WebsocketAdaptor): void;
|
|
16
17
|
static resolveServerSignal(serverSignalCls: ServerSignalCls, { registry, live }: {
|
|
17
18
|
registry: InjectRegistry;
|
|
18
19
|
live: LiveRegistry;
|
|
@@ -37,6 +37,7 @@ export interface WebsocketHandlersInputs {
|
|
|
37
37
|
hmrHub: HmrWsHub | null;
|
|
38
38
|
hmrState: HmrStateSource | null;
|
|
39
39
|
logger: Logger;
|
|
40
|
+
onDrain?: () => void;
|
|
40
41
|
}
|
|
41
42
|
type WsTaggedData = {
|
|
42
43
|
kind?: string;
|
|
@@ -56,6 +57,6 @@ export declare class ApiRouter {
|
|
|
56
57
|
* - `kind === "akan-hmr"` — dev HMR, delegated to `HmrWsHub`.
|
|
57
58
|
* - everything else — app signal channel, dispatched via `wsRoutes`.
|
|
58
59
|
*/
|
|
59
|
-
static buildWebsocketHandlers({ wsRoutes, registry, hmrHub, hmrState, logger, }: WebsocketHandlersInputs): Bun.WebSocketHandler<WsTaggedData>;
|
|
60
|
+
static buildWebsocketHandlers({ wsRoutes, registry, hmrHub, hmrState, logger, onDrain, }: WebsocketHandlersInputs): Bun.WebSocketHandler<WsTaggedData>;
|
|
60
61
|
}
|
|
61
62
|
export {};
|
|
@@ -26,5 +26,11 @@ export declare class AppWsData {
|
|
|
26
26
|
* It outlives a credential swap on purpose: the socket is still the same socket.
|
|
27
27
|
*/
|
|
28
28
|
socketId: string;
|
|
29
|
+
/** The caller's address as the nearest proxy recorded it, or null when nothing did. */
|
|
30
|
+
get ip(): string | null;
|
|
31
|
+
/** The caller's source port as the nearest proxy recorded it, or null when nothing did. */
|
|
32
|
+
get port(): number | null;
|
|
33
|
+
/** The address to answer on: what a proxy recorded, else this socket's own peer. */
|
|
34
|
+
ipOf(ws: Bun.ServerWebSocket<unknown>): string | null;
|
|
29
35
|
constructor(headers: Headers);
|
|
30
36
|
}
|
package/types/server/types.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { SsrManifest } from "./ssrTypes.d.ts";
|
|
|
5
5
|
export type WebsocketRoute = (ws: Bun.ServerWebSocket<unknown>, data: unknown[], event?: "message" | "subscribe" | "unsubscribe") => PromiseOrObject<unknown>;
|
|
6
6
|
export type WebsocketRoutes = Record<string, WebsocketRoute>;
|
|
7
7
|
export type HttpRoutes = Bun.Serve.Options<unknown>["routes"];
|
|
8
|
+
export type LocalPublish = (roomId: string, data: object | object[] | Uint8Array) => void;
|
|
8
9
|
export interface SignalRouteOptions {
|
|
9
10
|
globalPrefix?: false;
|
|
10
11
|
}
|
|
@@ -5,7 +5,7 @@ export interface CompressAdaptor {
|
|
|
5
5
|
}
|
|
6
6
|
declare const JsonCompressor_base: import("..").AdaptorCls<{}, {}>;
|
|
7
7
|
export declare class JsonCompressor extends JsonCompressor_base implements CompressAdaptor {
|
|
8
|
-
encode(
|
|
8
|
+
encode(ref: Cls, arrDepth: number, value: unknown): Buffer | null;
|
|
9
9
|
decode<T = unknown>(ref: Cls, arrDepth: number, buffer: Buffer, { raw }?: {
|
|
10
10
|
raw?: boolean;
|
|
11
11
|
}): T;
|
|
@@ -18,6 +18,16 @@ export declare class Res implements InternalArg {
|
|
|
18
18
|
redirect(url: string | URL, status?: number): Response;
|
|
19
19
|
};
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Injects the caller's IP, as the nearest proxy recorded it rather than as the socket peer reports it.
|
|
23
|
+
* Behind the federation gateway every peer is the gateway, so an endpoint that reads `remoteAddress` sees
|
|
24
|
+
* `127.0.0.1` for every caller — this reads the forwarded headers first and falls back to the peer only
|
|
25
|
+
* when nothing proxied the call. IPv4 comes back unwrapped from `::ffff:`, so it can address a `udp4`
|
|
26
|
+
* socket. `null` when no proxy recorded one and the transport has no peer.
|
|
27
|
+
*/
|
|
28
|
+
export declare class Ip implements InternalArg<string | null> {
|
|
29
|
+
getArg(context: SignalContext): string | null;
|
|
30
|
+
}
|
|
21
31
|
/**
|
|
22
32
|
* Injects websocket state, this connection's id, and subscription hooks into message/pubsub handlers.
|
|
23
33
|
* `socketId` is the one `AppWsData` minted at the handshake, so a handler never reads `ws.data` to
|
|
@@ -69,6 +69,18 @@ export declare class SignalContext<Ctx extends HttpExecutionContext | WebSocketE
|
|
|
69
69
|
getHttpContext<Appended = unknown>(): HttpExecutionContext<Appended>;
|
|
70
70
|
getWebSocketContext<Appended = unknown>(): WebSocketExecutionContext<Appended>;
|
|
71
71
|
get<T = unknown>(key: string): T | null;
|
|
72
|
+
/**
|
|
73
|
+
* The caller's IP, preferring what a proxy recorded over the socket peer. Behind the federation gateway the
|
|
74
|
+
* peer is the gateway itself for every request and for the whole life of every socket, so `remoteAddress`
|
|
75
|
+
* alone names the wrong machine — which is why nothing here reads it first. IPv4 arrives unwrapped from its
|
|
76
|
+
* `::ffff:` form, so it can be used as a destination as well as an identity.
|
|
77
|
+
*
|
|
78
|
+
* `null` means no proxy recorded one and the transport has no peer to fall back on — never a placeholder,
|
|
79
|
+
* because a loopback-looking address for an unknown caller is the failure this replaced.
|
|
80
|
+
*/
|
|
81
|
+
getClientIp(): string | null;
|
|
82
|
+
/** The caller's source port as the nearest proxy recorded it, else this socket's own. */
|
|
83
|
+
getClientPort(): number | null;
|
|
72
84
|
getRoomId(key: string): string;
|
|
73
85
|
getEnv(): Env;
|
|
74
86
|
getArg<T = unknown>(argName: string): T | undefined;
|
package/types/signal/types.d.ts
CHANGED
|
@@ -74,6 +74,13 @@ export interface SignalOption<Response = any, Nullable extends boolean = false,
|
|
|
74
74
|
method?: HttpMutationMethod;
|
|
75
75
|
/** Marks this mutation as the framework file-upload endpoint (see resolveFileUploadCapability). */
|
|
76
76
|
fileUpload?: boolean;
|
|
77
|
+
/**
|
|
78
|
+
* What a `pubsub(Binary)` does when a subscriber cannot keep up. `"coalesce"` (the default) keeps only the
|
|
79
|
+
* newest frame per room, which is what a telemetry or video stream wants — an old frame is worthless once a
|
|
80
|
+
* newer one exists. Name `"queue"` when the frames are a sequence a subscriber has to see in full, such as
|
|
81
|
+
* deltas against a base it already holds; the send buffer then grows with the slowest subscriber.
|
|
82
|
+
*/
|
|
83
|
+
backpressure?: "coalesce" | "queue";
|
|
77
84
|
scheduleType?: "init" | "destroy" | "cron" | "interval" | "timeout";
|
|
78
85
|
scheduleCron?: string;
|
|
79
86
|
scheduleTime?: number;
|
package/ui/Data/Dashboard.tsx
CHANGED
|
@@ -49,7 +49,8 @@ export default function Dashboard<T extends string, State>({
|
|
|
49
49
|
const [selected, setSelected] = useState(typeof filter === "string" ? filter : undefined);
|
|
50
50
|
|
|
51
51
|
const settingOf = (column: string): QuerySetting | undefined => {
|
|
52
|
-
|
|
52
|
+
|
|
53
|
+
if (queryMap?.[column]?.queryKey) return queryMap[column];
|
|
53
54
|
const meta = fieldQueryMetaOf(summaryRefName, column);
|
|
54
55
|
if (!meta?.queryKey || meta.refName !== slice.refName) return undefined;
|
|
55
56
|
return { queryKey: meta.queryKey, args: meta.queryArgs };
|
package/ui/Data/QueryMaker.tsx
CHANGED
|
@@ -45,10 +45,10 @@ interface QueryMakerArgsProps {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/** A filter's args may be written as a thunk, so that an arg relative to now is read when the filter is applied. */
|
|
48
|
-
export const resolveQuerySetting = (setting: QuerySetting): ResolvedQuerySetting =>
|
|
49
|
-
|
|
50
|
-
args: typeof
|
|
51
|
-
}
|
|
48
|
+
export const resolveQuerySetting = (setting: QuerySetting): ResolvedQuerySetting => {
|
|
49
|
+
const args = setting.args ?? setting.queryArgs;
|
|
50
|
+
return { queryKey: setting.queryKey, args: typeof args === "function" ? args() : (args ?? []) };
|
|
51
|
+
};
|
|
52
52
|
|
|
53
53
|
const isFillableArg = (arg: SerializedArg) => !arg.modelType;
|
|
54
54
|
const defaultArg = (arg: SerializedArg) => ((arg.arrDepth ?? 0) > 0 ? [] : null);
|
package/ui/Signal/Listener.tsx
CHANGED
|
@@ -20,8 +20,23 @@ interface ListenerResultProps {
|
|
|
20
20
|
status: "ready" | "loading" | "error" | "listening";
|
|
21
21
|
data: unknown;
|
|
22
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* A byte payload has no useful JSON form: `JSON.stringify` spells a `Uint8Array` as `{"0":2,"1":148,…}`, which
|
|
25
|
+
* is unreadable and, for one video chunk, megabytes of DOM. The head is enough to tell a stream apart.
|
|
26
|
+
*/
|
|
27
|
+
const previewBytes = (bytes: Uint8Array) => {
|
|
28
|
+
const head = [...bytes.subarray(0, 32)].map((byte) => byte.toString(16).padStart(2, "0")).join(" ");
|
|
29
|
+
return `Uint8Array(${bytes.length}) ${head}${bytes.length > 32 ? " …" : ""}`;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const replaceBytes = (_key: string, value: unknown) => {
|
|
33
|
+
if (!ArrayBuffer.isView(value)) return value;
|
|
34
|
+
const view = value as ArrayBufferView;
|
|
35
|
+
return previewBytes(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
|
|
36
|
+
};
|
|
37
|
+
|
|
23
38
|
const ListenerResult = ({ status, data }: ListenerResultProps) => {
|
|
24
|
-
const dataStr = typeof data === "object" ? JSON.stringify(data,
|
|
39
|
+
const dataStr = typeof data === "object" ? JSON.stringify(data, replaceBytes, 2) : String(data);
|
|
25
40
|
const ref = useRef<HTMLPreElement>(null);
|
|
26
41
|
useEffect(() => {
|
|
27
42
|
if (!ref.current) return;
|