akanjs 3.0.0-alpha.63 → 3.0.0-alpha.65
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/index.ts +24 -1
- package/local/apps/akan/akan-local.db +0 -0
- package/local/apps/akan/akan-local.db-shm +0 -0
- package/local/apps/akan/akan-local.db-wal +0 -0
- package/local/apps/akan/akan-local_solid.db +0 -0
- package/local/apps/akan/akan-local_solid.db-shm +0 -0
- package/local/apps/akan/akan-local_solid.db-wal +0 -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 +10 -6
- package/server/akanAppHeaders.ts +14 -2
- package/server/akanServer.ts +59 -13
- package/server/binaryPubsub.ts +55 -0
- package/server/resolver/signal.resolver.ts +71 -21
- package/server/routing/apiRouter.ts +4 -0
- package/server/routing/appWsData.ts +16 -1
- package/server/types.tsx +25 -0
- package/server/webRouter.ts +48 -32
- package/service/ipcTypes.ts +1 -0
- package/service/predefinedAdaptor/compress.adaptor.ts +5 -1
- package/signal/internalArg.ts +14 -2
- package/signal/openapi/openapi.ts +1 -0
- package/signal/schema/JsonSchemaBuilder.ts +2 -0
- package/signal/signalContext.ts +27 -4
- 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/index.d.ts +22 -1
- package/types/server/akanAppHeaders.d.ts +7 -1
- package/types/server/akanServer.d.ts +7 -2
- 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 +14 -0
- package/types/server/webRouter.d.ts +12 -3
- package/types/service/ipcTypes.d.ts +1 -0
- package/types/service/predefinedAdaptor/compress.adaptor.d.ts +1 -1
- package/types/signal/internalArg.d.ts +13 -4
- package/types/signal/signalContext.d.ts +14 -2
- 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<
|
package/index.ts
CHANGED
|
@@ -24,6 +24,24 @@ export interface AkanRouteConfig {
|
|
|
24
24
|
domains: AkanRouteDomains;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Which web surfaces an app serves, resolved. `ssr` is the RSC/SSR route renderer and everything it needs —
|
|
29
|
+
* the pages bundle, the client bundles, the RSC worker process. `csr` is the single-file SPA shell that the
|
|
30
|
+
* Capacitor mobile build ships and that `/__csr` serves.
|
|
31
|
+
*/
|
|
32
|
+
export interface AkanWebConfig {
|
|
33
|
+
ssr: boolean;
|
|
34
|
+
csr: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* What an `akan.config.ts` may write. `false` is an API-only app — no web artifact is built and no web route
|
|
39
|
+
* is mounted; `true` (the default) is both surfaces. The object form keeps SSR and toggles only the CSR
|
|
40
|
+
* bundle, which is the whole range there is: the CSR bundle inlines the stylesheet the SSR build compiles, so
|
|
41
|
+
* CSR without SSR would ship an unstyled app and is not expressible here.
|
|
42
|
+
*/
|
|
43
|
+
export type AkanWebOption = boolean | { csr: boolean };
|
|
44
|
+
|
|
27
45
|
export type DatabaseMode = "single" | "multiple" | "cluster";
|
|
28
46
|
export type MobileEnv = "local" | "debug" | "develop" | "main";
|
|
29
47
|
export type MobilePermission = "camera" | "contacts" | "location" | "push" | "speech";
|
|
@@ -162,6 +180,8 @@ export interface AkanPlugin {
|
|
|
162
180
|
export interface AppConfigResult {
|
|
163
181
|
docker: DockerConfig;
|
|
164
182
|
defaultDatabaseMode: DatabaseMode;
|
|
183
|
+
/** Web surfaces built into the app and mounted at boot. Both default to `true`. */
|
|
184
|
+
web: AkanWebConfig;
|
|
165
185
|
routes?: AkanRouteConfig[];
|
|
166
186
|
/**
|
|
167
187
|
* Mounts `libs/<lib>/page` into this app under `page/(libs)/(<lib>)` on sync. `true` takes every lib
|
|
@@ -197,7 +217,10 @@ export interface LibConfigContext {
|
|
|
197
217
|
readonly type: "lib";
|
|
198
218
|
}
|
|
199
219
|
|
|
200
|
-
export type AppConfigInput = DeepPartial<AppConfigResult> & {
|
|
220
|
+
export type AppConfigInput = Omit<DeepPartial<AppConfigResult>, "web"> & {
|
|
221
|
+
web?: AkanWebOption;
|
|
222
|
+
plugins?: AkanPlugin[];
|
|
223
|
+
};
|
|
201
224
|
export type LibConfigInput = DeepPartial<LibConfigResult> & { plugins?: AkanPlugin[] };
|
|
202
225
|
export type AppConfig = AppConfigInput | ((app: AppConfigContext) => AppConfigInput);
|
|
203
226
|
export type LibConfig = LibConfigInput | ((lib: LibConfigContext) => LibConfigInput);
|
|
Binary file
|
|
Binary file
|
|
File without changes
|
|
Binary file
|
|
Binary file
|
|
File without changes
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
package/server/akanApp.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { resolveEncodedSidecar } from "./assetEncoding";
|
|
|
11
11
|
import { isPortInUseError } from "./lifecycle/portInUse";
|
|
12
12
|
import { RotatingLogWriter } from "./logging/rotatingLogWriter";
|
|
13
13
|
import { ProcessMetricsCollector } from "./processMetricsCollector";
|
|
14
|
+
import { getWebConfigFromEnv } from "./types";
|
|
14
15
|
|
|
15
16
|
interface ChildState {
|
|
16
17
|
idx: number;
|
|
@@ -103,6 +104,8 @@ export class AkanApp {
|
|
|
103
104
|
readonly #port: number;
|
|
104
105
|
readonly #wsBasePort: number;
|
|
105
106
|
readonly #openapi?: boolean;
|
|
107
|
+
/** The gateway hands `/_akan/client|styles|fonts` straight off disk, so it needs the same answer its children do. */
|
|
108
|
+
readonly #web = getWebConfigFromEnv();
|
|
106
109
|
readonly #modules: string[];
|
|
107
110
|
readonly #children = new Map<number, ChildState>();
|
|
108
111
|
readonly #roomChildren = new Map<string, Set<number>>();
|
|
@@ -547,7 +550,7 @@ export class AkanApp {
|
|
|
547
550
|
if (this.#isWebSocketPath(url.pathname)) return this.#upgradeWebSocket(req, server);
|
|
548
551
|
const assetResponse = await this.#serveImmutableArtifact(req, url);
|
|
549
552
|
if (assetResponse) return assetResponse;
|
|
550
|
-
return await this.#proxyHttp(req);
|
|
553
|
+
return await this.#proxyHttp(req, server);
|
|
551
554
|
}
|
|
552
555
|
|
|
553
556
|
#isWebSocketPath(pathname: string) {
|
|
@@ -555,6 +558,7 @@ export class AkanApp {
|
|
|
555
558
|
}
|
|
556
559
|
|
|
557
560
|
async #serveImmutableArtifact(req: Request, url: URL): Promise<Response | null> {
|
|
561
|
+
if (!this.#web.ssr) return null;
|
|
558
562
|
const clientPrefix = "/_akan/client/";
|
|
559
563
|
if (url.pathname.startsWith(clientPrefix)) {
|
|
560
564
|
const filePath = this.#safeResolve(
|
|
@@ -592,7 +596,7 @@ export class AkanApp {
|
|
|
592
596
|
if (!child || !upstream) return new Response("No websocket upstream is ready", { status: 503 });
|
|
593
597
|
const url = new URL(req.url);
|
|
594
598
|
const upstreamWs = new WebSocket(`ws://${upstream.host}:${upstream.port}${url.pathname}${url.search}`, {
|
|
595
|
-
headers: this.#makeProxyHeaders(req, child.idx),
|
|
599
|
+
headers: this.#makeProxyHeaders(req, child.idx, server),
|
|
596
600
|
} as unknown as string[]);
|
|
597
601
|
|
|
598
602
|
const upgraded = server.upgrade(req, { data: { childIdx: child.idx, upstream: upstreamWs } });
|
|
@@ -691,14 +695,14 @@ export class AkanApp {
|
|
|
691
695
|
};
|
|
692
696
|
}
|
|
693
697
|
|
|
694
|
-
async #proxyHttp(req: Request): Promise<Response> {
|
|
698
|
+
async #proxyHttp(req: Request, server: Bun.Server<GatewayWsData>): Promise<Response> {
|
|
695
699
|
const child = this.#pickFederationChild();
|
|
696
700
|
if (!child?.upstream || child.upstream.type !== "unix") {
|
|
697
701
|
return this.#respondWithCrashPage(req) ?? new Response("No healthy federation child is ready", { status: 503 });
|
|
698
702
|
}
|
|
699
703
|
const url = new URL(req.url);
|
|
700
704
|
const upstreamUrl = `http://akan-child${url.pathname}${url.search}`;
|
|
701
|
-
const headers = this.#makeProxyHeaders(req, child.idx);
|
|
705
|
+
const headers = this.#makeProxyHeaders(req, child.idx, server);
|
|
702
706
|
child.metrics.activeRequests = (child.metrics.activeRequests ?? 0) + 1;
|
|
703
707
|
child.metrics.totalRequests = (child.metrics.totalRequests ?? 0) + 1;
|
|
704
708
|
const traced = isTraceEnabled();
|
|
@@ -867,8 +871,8 @@ export class AkanApp {
|
|
|
867
871
|
return resolved;
|
|
868
872
|
}
|
|
869
873
|
|
|
870
|
-
#makeProxyHeaders(req: Request, childIdx: number) {
|
|
871
|
-
return makeAkanChildProxyHeaders(req, childIdx);
|
|
874
|
+
#makeProxyHeaders(req: Request, childIdx: number, server?: Bun.Server<GatewayWsData>) {
|
|
875
|
+
return makeAkanChildProxyHeaders(req, childIdx, server?.requestIP(req));
|
|
872
876
|
}
|
|
873
877
|
|
|
874
878
|
#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,6 @@
|
|
|
1
|
+
import type { AkanWebConfig, AkanWebOption } from "akanjs";
|
|
1
2
|
import { type BackendEnv, type BaseEnv, getEnv } from "akanjs/base";
|
|
2
|
-
import { Logger } from "akanjs/common";
|
|
3
|
+
import { Logger, websocketBinaryFrameContract } from "akanjs/common";
|
|
3
4
|
import { DictionaryLookup } from "akanjs/dictionary";
|
|
4
5
|
import type {
|
|
5
6
|
Adaptor,
|
|
@@ -16,6 +17,7 @@ import { createOpenApiDocument } from "../signal/openapi";
|
|
|
16
17
|
import { FetchSerializer } from "../signal/serializer";
|
|
17
18
|
import type { AkanLib, AkanLibProps } from "./akanLib";
|
|
18
19
|
import type { BuilderRpc } from "./artifact";
|
|
20
|
+
import { BinaryPubsub } from "./binaryPubsub";
|
|
19
21
|
import { DevtoolsRouter } from "./devtools";
|
|
20
22
|
import { DiLifecycle } from "./di/diLifecycle";
|
|
21
23
|
import type { HmrWsData, HmrWsHub } from "./hmr/wsHub";
|
|
@@ -27,7 +29,13 @@ import { WebProxyRunner } from "./proxy";
|
|
|
27
29
|
import { SignalResolver } from "./resolver";
|
|
28
30
|
import { ApiRouter } from "./routing/apiRouter";
|
|
29
31
|
import type { AppWsData } from "./routing/appWsData";
|
|
30
|
-
import
|
|
32
|
+
import {
|
|
33
|
+
getWebConfigFromEnv,
|
|
34
|
+
type HttpRoutes,
|
|
35
|
+
type LocalPublish,
|
|
36
|
+
type SignalRoutes,
|
|
37
|
+
type WebsocketRoutes,
|
|
38
|
+
} from "./types";
|
|
31
39
|
import type { WebRouter } from "./webRouter";
|
|
32
40
|
|
|
33
41
|
export interface AkanServerProps extends AkanLibProps {
|
|
@@ -134,11 +142,14 @@ export class AkanServer {
|
|
|
134
142
|
mcpAuth: McpAuthOption = AkanServer.#mcpAuthFromEnv();
|
|
135
143
|
mcpOption: Omit<McpServerOption, "enabled" | "readOnly" | "auth"> = AkanServer.#mcpOptionFromEnv();
|
|
136
144
|
serverMode: "federation" | "batch" | "all";
|
|
145
|
+
/** Resolved at `init`: what this process actually serves, after env and artifact availability. */
|
|
146
|
+
web: AkanWebConfig = getWebConfigFromEnv();
|
|
137
147
|
modules: string[];
|
|
138
148
|
shutdownTimeoutMs = AkanServer.#defaultShutdownTimeoutMs();
|
|
139
149
|
|
|
140
150
|
#di: DiLifecycle;
|
|
141
|
-
#localPublish:
|
|
151
|
+
#localPublish: LocalPublish | null = null;
|
|
152
|
+
readonly #binaryPubsub = new BinaryPubsub();
|
|
142
153
|
#metricsTimer: Timer | null = null;
|
|
143
154
|
constructor(
|
|
144
155
|
name = "AkanServer",
|
|
@@ -182,6 +193,12 @@ export class AkanServer {
|
|
|
182
193
|
this.openapi = openapi;
|
|
183
194
|
return this;
|
|
184
195
|
}
|
|
196
|
+
/** Narrows the web surface this process serves. Never widens it past what the build produced. */
|
|
197
|
+
setWeb(web: AkanWebOption = true) {
|
|
198
|
+
if (this.status !== "stopped") throw new Error("Web config must be set before app initialization.");
|
|
199
|
+
this.web = AkanServer.#narrowWeb(this.web, web);
|
|
200
|
+
return this;
|
|
201
|
+
}
|
|
185
202
|
setMcp(mcp: boolean | McpServerOption = true) {
|
|
186
203
|
if (this.status !== "stopped") throw new Error("MCP config must be set before app initialization.");
|
|
187
204
|
this.mcp = typeof mcp === "boolean" ? mcp : (mcp.enabled ?? true);
|
|
@@ -254,7 +271,7 @@ export class AkanServer {
|
|
|
254
271
|
};
|
|
255
272
|
}
|
|
256
273
|
|
|
257
|
-
async init({ routes: initRoutes = true, web
|
|
274
|
+
async init({ routes: initRoutes = true, web }: { routes?: boolean; web?: AkanWebOption } = {}) {
|
|
258
275
|
if (this.status !== "stopped") throw new Error("AkanServer is not able to init. It is already running.");
|
|
259
276
|
this.status = "initializing";
|
|
260
277
|
const { routes, wsRoutes, routeOptions } = await this.#di.initializeAll();
|
|
@@ -263,7 +280,8 @@ export class AkanServer {
|
|
|
263
280
|
this.status = "initialized";
|
|
264
281
|
return this;
|
|
265
282
|
}
|
|
266
|
-
|
|
283
|
+
const requestedWeb = AkanServer.#narrowWeb(this.web, web);
|
|
284
|
+
const noWeb = () => {
|
|
267
285
|
this.#prepared = {
|
|
268
286
|
routes,
|
|
269
287
|
routeOptions,
|
|
@@ -277,11 +295,25 @@ export class AkanServer {
|
|
|
277
295
|
};
|
|
278
296
|
this.status = "initialized";
|
|
279
297
|
return this;
|
|
298
|
+
};
|
|
299
|
+
if (!requestedWeb.ssr) {
|
|
300
|
+
this.web = requestedWeb;
|
|
301
|
+
this.logger.info("web off: serving api only (AKAN_SSR=false, or a build with `web: false`)");
|
|
302
|
+
return noWeb();
|
|
280
303
|
}
|
|
281
304
|
const { WebRouter } = await import("./webRouter");
|
|
282
305
|
const webRouter = await WebRouter.create({
|
|
306
|
+
web: requestedWeb,
|
|
283
307
|
upgradeHmrWs: (req, data) => this.#server?.upgrade(req, { data }) ?? false,
|
|
284
308
|
});
|
|
309
|
+
|
|
310
|
+
if (!webRouter) {
|
|
311
|
+
this.web = { ssr: false, csr: false };
|
|
312
|
+
this.logger.warn("web off: no build artifact under .akan/artifact; serving api only");
|
|
313
|
+
return noWeb();
|
|
314
|
+
}
|
|
315
|
+
this.web = webRouter.web;
|
|
316
|
+
this.logger.info(`web on: ssr=${this.web.ssr} csr=${this.web.csr}`);
|
|
285
317
|
const { renderEnvRoutes, hmrHub, builderRpc } = await webRouter.initializeRoute();
|
|
286
318
|
const webProxyRunner = WebProxyRunner.create(this.#di.webProxies);
|
|
287
319
|
this.#prepared = {
|
|
@@ -318,6 +350,7 @@ export class AkanServer {
|
|
|
318
350
|
hmrHub,
|
|
319
351
|
hmrState: webRouter ? { state: webRouter.renderState } : null,
|
|
320
352
|
logger: this.logger,
|
|
353
|
+
onDrain: () => this.#binaryPubsub.flush(),
|
|
321
354
|
}),
|
|
322
355
|
|
|
323
356
|
data: {},
|
|
@@ -375,16 +408,20 @@ export class AkanServer {
|
|
|
375
408
|
|
|
376
409
|
const websocket = this.#di.getWebsocketAdaptor();
|
|
377
410
|
if (!websocket) throw new Error("WebSocket Redis adaptor is not registered");
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
411
|
+
this.#binaryPubsub.setServers(server, wsServer);
|
|
412
|
+
const localPublish: LocalPublish = (roomId, data) => {
|
|
413
|
+
if (data instanceof Uint8Array) {
|
|
414
|
+
this.#binaryPubsub.publish(roomId, websocketBinaryFrameContract.encode({ roomId, payload: data }), {
|
|
415
|
+
coalesce: SignalResolver.coalescesRoom(roomId),
|
|
416
|
+
});
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
384
419
|
const publishData: WebsocketPublishData = { type: "pub", roomId, data };
|
|
385
420
|
server?.publish(roomId, JSON.stringify(publishData));
|
|
386
421
|
wsServer?.publish(roomId, JSON.stringify(publishData));
|
|
387
422
|
};
|
|
423
|
+
SignalResolver.setLocalPublish(localPublish, websocket);
|
|
424
|
+
this.#localPublish = localPublish;
|
|
388
425
|
|
|
389
426
|
this.status = "running";
|
|
390
427
|
this.#startMetricsReporting();
|
|
@@ -405,7 +442,7 @@ export class AkanServer {
|
|
|
405
442
|
return this;
|
|
406
443
|
}
|
|
407
444
|
|
|
408
|
-
async start({ listen, web
|
|
445
|
+
async start({ listen, web }: { listen?: boolean; web?: AkanWebOption } = {}) {
|
|
409
446
|
const isNoListenCommand = process.env.AKAN_COMMAND_TYPE === "script" || process.env.AKAN_COMMAND_TYPE === "console";
|
|
410
447
|
const shouldListen = (listen ?? !isNoListenCommand) && this.serverMode !== "batch";
|
|
411
448
|
await this.init({ routes: shouldListen, web });
|
|
@@ -479,7 +516,8 @@ export class AkanServer {
|
|
|
479
516
|
|
|
480
517
|
#handleIpcMessage(message: AkanIpcMessage) {
|
|
481
518
|
if (!message || typeof message !== "object") return;
|
|
482
|
-
if (message.type === "pubsub.deliver")
|
|
519
|
+
if (message.type === "pubsub.deliver")
|
|
520
|
+
this.#localPublish?.(message.roomId, message.data as object | object[] | Uint8Array);
|
|
483
521
|
else if (message.type === "health.ping")
|
|
484
522
|
process.send?.({
|
|
485
523
|
type: "health.pong",
|
|
@@ -506,6 +544,7 @@ export class AkanServer {
|
|
|
506
544
|
async #reportMetrics() {
|
|
507
545
|
const metrics = await ProcessMetricsCollector.collect({
|
|
508
546
|
role: this.serverMode,
|
|
547
|
+
pubsubCoalesceCount: this.#binaryPubsub.coalescedCount,
|
|
509
548
|
...(this.#prepared?.webRouter?.getMetrics() ?? {}),
|
|
510
549
|
});
|
|
511
550
|
process.send?.({ type: "metrics.report", pid: process.pid, metrics } satisfies AkanIpcMessage);
|
|
@@ -629,6 +668,13 @@ export class AkanServer {
|
|
|
629
668
|
return !names.some((name) => process.env[name] === "false" || process.env[name] === "0");
|
|
630
669
|
}
|
|
631
670
|
|
|
671
|
+
/** Narrows only — a surface the build or the env left out cannot be switched back on here. */
|
|
672
|
+
static #narrowWeb(current: AkanWebConfig, web: AkanWebOption | undefined): AkanWebConfig {
|
|
673
|
+
if (web === undefined || web === true) return current;
|
|
674
|
+
if (web === false) return { ssr: false, csr: false };
|
|
675
|
+
return { ssr: current.ssr, csr: web.csr && current.csr };
|
|
676
|
+
}
|
|
677
|
+
|
|
632
678
|
/** Named rather than defaulted: an absent env must leave the option unset so a value written in code still wins. */
|
|
633
679
|
static #isEnvOff(...names: string[]) {
|
|
634
680
|
return names.some((name) => process.env[name] === "false" || process.env[name] === "0");
|
|
@@ -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
|
+
}
|