akanjs 3.0.0-beta.27 → 3.0.0-beta.29
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/baseEnv.ts +19 -5
- package/client/device.ts +22 -6
- package/client/index.ts +1 -0
- package/client/native/accessibility.ts +1 -0
- package/client/native/app-state.ts +1 -0
- package/client/native/app.ts +1 -0
- package/client/native/appearance.ts +1 -0
- package/client/native/auth-session.ts +1 -0
- package/client/native/autostart.ts +1 -0
- package/client/native/badge.ts +1 -0
- package/client/native/biometric.ts +1 -0
- package/client/native/browser.ts +1 -0
- package/client/native/camera.ts +1 -0
- package/client/native/clipboard.ts +1 -0
- package/client/native/contacts.ts +1 -0
- package/client/native/device.ts +1 -0
- package/client/native/dialog.ts +1 -0
- package/client/native/dock.ts +1 -0
- package/client/native/file-picker.ts +1 -0
- package/client/native/filesystem.ts +1 -0
- package/client/native/geolocation.ts +1 -0
- package/client/native/global-shortcut.ts +1 -0
- package/client/native/haptics.ts +1 -0
- package/client/native/http.ts +1 -0
- package/client/native/iap.ts +1 -0
- package/client/native/keep-awake.ts +1 -0
- package/client/native/keyboard.ts +1 -0
- package/client/native/local-notifications.ts +1 -0
- package/client/native/menu.ts +1 -0
- package/client/native/network.ts +1 -0
- package/client/native/opener.ts +1 -0
- package/client/native/preferences.ts +1 -0
- package/client/native/push.ts +1 -0
- package/client/native/screen-orientation.ts +1 -0
- package/client/native/screen.ts +1 -0
- package/client/native/secure-storage.ts +1 -0
- package/client/native/share.ts +1 -0
- package/client/native/single-instance.ts +1 -0
- package/client/native/splash-screen.ts +1 -0
- package/client/native/sqlite.ts +1 -0
- package/client/native/toast.ts +1 -0
- package/client/native/tray.ts +1 -0
- package/client/native/updates.ts +1 -0
- package/client/native/volume.ts +1 -0
- package/client/native/window-state.ts +1 -0
- package/client/native/window.ts +1 -0
- package/client/native.ts +31 -1
- package/client/resolveServerUrl.ts +11 -0
- package/client/router.ts +8 -0
- package/index.ts +115 -6
- package/native/desktop.ts +5 -0
- package/package.json +11 -1
- package/server/akanApp.ts +27 -5
- package/server/akanAppHeaders.ts +4 -1
- package/server/akanServer.ts +43 -13
- package/server/contentEncoding.ts +11 -0
- package/server/index.ts +1 -0
- package/server/lifecycle/orphanGroup.ts +21 -0
- package/server/nativeFile.ts +107 -0
- package/server/ops/appInfo.ts +3 -1
- package/server/ops/opsRoute.ts +3 -2
- package/server/routing/apiRouter.ts +26 -3
- package/server/routing/hostAllowlist.ts +24 -0
- package/server/selfExec.ts +33 -0
- package/server/types.tsx +3 -1
- package/server/webRouter.ts +7 -4
- package/service/ipcTypes.ts +4 -0
- package/signal/CrossSiteGuard.ts +7 -0
- package/test/signalTestRuntime.ts +8 -2
- package/types/base/baseEnv.d.ts +1 -0
- package/types/client/index.d.ts +1 -0
- package/types/client/native/accessibility.d.ts +1 -0
- package/types/client/native/app-state.d.ts +1 -0
- package/types/client/native/app.d.ts +1 -0
- package/types/client/native/appearance.d.ts +1 -0
- package/types/client/native/auth-session.d.ts +1 -0
- package/types/client/native/autostart.d.ts +1 -0
- package/types/client/native/badge.d.ts +1 -0
- package/types/client/native/biometric.d.ts +1 -0
- package/types/client/native/browser.d.ts +1 -0
- package/types/client/native/camera.d.ts +1 -0
- package/types/client/native/clipboard.d.ts +1 -0
- package/types/client/native/contacts.d.ts +1 -0
- package/types/client/native/device.d.ts +1 -0
- package/types/client/native/dialog.d.ts +1 -0
- package/types/client/native/dock.d.ts +1 -0
- package/types/client/native/file-picker.d.ts +1 -0
- package/types/client/native/filesystem.d.ts +1 -0
- package/types/client/native/geolocation.d.ts +1 -0
- package/types/client/native/global-shortcut.d.ts +1 -0
- package/types/client/native/haptics.d.ts +1 -0
- package/types/client/native/http.d.ts +1 -0
- package/types/client/native/iap.d.ts +1 -0
- package/types/client/native/keep-awake.d.ts +1 -0
- package/types/client/native/keyboard.d.ts +1 -0
- package/types/client/native/local-notifications.d.ts +1 -0
- package/types/client/native/menu.d.ts +1 -0
- package/types/client/native/network.d.ts +1 -0
- package/types/client/native/opener.d.ts +1 -0
- package/types/client/native/preferences.d.ts +1 -0
- package/types/client/native/push.d.ts +1 -0
- package/types/client/native/screen-orientation.d.ts +1 -0
- package/types/client/native/screen.d.ts +1 -0
- package/types/client/native/secure-storage.d.ts +1 -0
- package/types/client/native/share.d.ts +1 -0
- package/types/client/native/single-instance.d.ts +1 -0
- package/types/client/native/splash-screen.d.ts +1 -0
- package/types/client/native/sqlite.d.ts +1 -0
- package/types/client/native/toast.d.ts +1 -0
- package/types/client/native/tray.d.ts +1 -0
- package/types/client/native/updates.d.ts +1 -0
- package/types/client/native/volume.d.ts +1 -0
- package/types/client/native/window-state.d.ts +1 -0
- package/types/client/native/window.d.ts +1 -0
- package/types/client/native.d.ts +14 -2
- package/types/client/resolveServerUrl.d.ts +1 -0
- package/types/client/router.d.ts +3 -0
- package/types/index.d.ts +112 -6
- package/types/native/desktop.d.ts +3 -0
- package/types/server/akanAppHeaders.d.ts +2 -0
- package/types/server/index.d.ts +1 -0
- package/types/server/lifecycle/orphanGroup.d.ts +7 -0
- package/types/server/nativeFile.d.ts +10 -0
- package/types/server/ops/appInfo.d.ts +1 -0
- package/types/server/routing/apiRouter.d.ts +3 -1
- package/types/server/routing/hostAllowlist.d.ts +8 -0
- package/types/server/selfExec.d.ts +8 -0
- package/types/server/types.d.ts +3 -1
- package/types/service/ipcTypes.d.ts +16 -0
- package/types/signal/CrossSiteGuard.d.ts +1 -0
- package/types/ui/recipe/buttonRecipe.d.ts +2 -2
- package/types/vendor/@akanjs/native/packages/cli/src/api.d.ts +73 -4
- package/types/vendor/@akanjs/native/packages/cli/src/config.d.ts +48 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/desktop-server.d.ts +8 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/prepare.d.ts +8 -2
- package/types/vendor/@akanjs/native/packages/cli/src/lib/project.d.ts +10 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/publish.d.ts +32 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/updates.d.ts +26 -0
- package/types/vendor/@akanjs/native/packages/cli/src/platforms/desktop-update.d.ts +10 -1
- package/types/vendor/@akanjs/native/packages/cli/src/platforms/macos.d.ts +5 -0
- package/types/vendor/@akanjs/native/packages/cli/src/platforms/windows-installer.d.ts +53 -0
- package/types/vendor/@akanjs/native/packages/desktop/src/dispatcher.d.ts +8 -1
- package/types/vendor/@akanjs/native/packages/desktop/src/grants.d.ts +11 -0
- package/types/vendor/@akanjs/native/packages/desktop/src/main.d.ts +11 -0
- package/types/vendor/@akanjs/native/packages/desktop/src/paths.d.ts +13 -2
- package/types/vendor/@akanjs/native/packages/desktop/src/plugin.d.ts +44 -3
- package/types/vendor/@akanjs/native/packages/desktop/src/relaunch.d.ts +40 -0
- package/types/vendor/@akanjs/native/packages/desktop/src/server.d.ts +97 -0
- package/types/vendor/@akanjs/native/plugins/app/src/index.d.ts +12 -0
- package/types/vendor/@akanjs/native/plugins/file-picker/src/index.d.ts +20 -1
- package/types/vendor/@akanjs/native/plugins/push/src/index.d.ts +9 -0
- package/types/vendor/@akanjs/native/plugins/updates/src/desktop.d.ts +34 -0
- package/types/vendor/@akanjs/native/plugins/updates/src/index.d.ts +18 -6
- package/types/vendor/@akanjs/native/plugins/volume/src/common.d.ts +10 -0
- package/types/vendor/@akanjs/native/plugins/volume/src/desktop.d.ts +18 -0
- package/types/vendor/@akanjs/native/plugins/volume/src/index.d.ts +28 -0
- package/types/vendor/@akanjs/native/plugins/volume/src.d.ts +1 -0
- package/types/webkit/csrFrameDump.d.ts +42 -0
- package/types/webkit/nativeNavigation.d.ts +1 -0
- package/types/webkit/nativePurchase.d.ts +6 -2
- package/types/webkit/nativeUpdates.d.ts +8 -0
- package/ui/Agent/Attach.tsx +6 -2
- package/ui/CsrImage.tsx +3 -2
- package/ui/System/CSR.tsx +4 -0
- package/vendor/@akanjs/native/native/android/src/com/akanjs/runtime/AkanNativeActivity.kt +2 -0
- package/vendor/@akanjs/native/native/desktop/Cargo.toml +1 -1
- package/vendor/@akanjs/native/native/desktop/src/lib.rs +161 -25
- package/vendor/@akanjs/native/native/desktop/src/volume.rs +184 -0
- package/vendor/@akanjs/native/native/desktop/src/win/mod.rs +3 -1
- package/vendor/@akanjs/native/native/desktop/src/win/volume.rs +71 -0
- package/vendor/@akanjs/native/packages/cli/src/api.ts +162 -12
- package/vendor/@akanjs/native/packages/cli/src/commands/build.ts +5 -3
- package/vendor/@akanjs/native/packages/cli/src/commands/update.ts +17 -71
- package/vendor/@akanjs/native/packages/cli/src/config.ts +41 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/configkeys.ts +9 -2
- package/vendor/@akanjs/native/packages/cli/src/lib/desktop-server.ts +58 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/hmr.ts +2 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/prepare.ts +10 -2
- package/vendor/@akanjs/native/packages/cli/src/lib/project.ts +38 -2
- package/vendor/@akanjs/native/packages/cli/src/lib/publish.ts +121 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/updates.ts +87 -5
- package/vendor/@akanjs/native/packages/cli/src/platforms/android.ts +1 -0
- package/vendor/@akanjs/native/packages/cli/src/platforms/desktop-update.ts +52 -25
- package/vendor/@akanjs/native/packages/cli/src/platforms/desktop.ts +16 -0
- package/vendor/@akanjs/native/packages/cli/src/platforms/macos.ts +44 -1
- package/vendor/@akanjs/native/packages/cli/src/platforms/windows-installer.ts +601 -0
- package/vendor/@akanjs/native/packages/cli/src/platforms/windows.ts +2 -0
- package/vendor/@akanjs/native/packages/desktop/src/dispatcher.ts +29 -2
- package/vendor/@akanjs/native/packages/desktop/src/grants.ts +47 -0
- package/vendor/@akanjs/native/packages/desktop/src/host.ts +109 -11
- package/vendor/@akanjs/native/packages/desktop/src/main.ts +59 -11
- package/vendor/@akanjs/native/packages/desktop/src/paths.ts +38 -4
- package/vendor/@akanjs/native/packages/desktop/src/plugin.ts +46 -3
- package/vendor/@akanjs/native/packages/desktop/src/relaunch.ts +236 -0
- package/vendor/@akanjs/native/packages/desktop/src/server.ts +610 -0
- package/vendor/@akanjs/native/plugins/app/android/AppPlugin.kt +14 -0
- package/vendor/@akanjs/native/plugins/app/native-plugin.json +3 -3
- package/vendor/@akanjs/native/plugins/app/src/desktop.ts +10 -0
- package/vendor/@akanjs/native/plugins/app/src/index.ts +12 -2
- package/vendor/@akanjs/native/plugins/app/src/web.ts +3 -0
- package/vendor/@akanjs/native/plugins/file-picker/src/desktop.ts +39 -2
- package/vendor/@akanjs/native/plugins/file-picker/src/index.ts +36 -1
- package/vendor/@akanjs/native/plugins/push/android/PushPlugin.kt +9 -1
- package/vendor/@akanjs/native/plugins/push/ios/PushPlugin.swift +4 -0
- package/vendor/@akanjs/native/plugins/push/src/index.ts +10 -0
- package/vendor/@akanjs/native/plugins/updates/src/desktop.ts +758 -447
- package/vendor/@akanjs/native/plugins/updates/src/index.ts +18 -6
- package/vendor/@akanjs/native/plugins/volume/android/VolumePlugin.kt +107 -0
- package/vendor/@akanjs/native/plugins/volume/native-plugin.json +14 -0
- package/vendor/@akanjs/native/plugins/volume/src/common.ts +28 -0
- package/vendor/@akanjs/native/plugins/volume/src/desktop.ts +185 -0
- package/vendor/@akanjs/native/plugins/volume/src/index.ts +32 -0
- package/webkit/RenderLayer.tsx +71 -6
- package/webkit/csrFrameDump.ts +67 -0
- package/webkit/nativeNavigation.ts +17 -0
- package/webkit/nativePurchase.ts +12 -2
- package/webkit/nativeUpdates.ts +55 -0
- package/webkit/useCsrValues.ts +27 -0
package/server/akanApp.ts
CHANGED
|
@@ -4,8 +4,9 @@ import path from "node:path";
|
|
|
4
4
|
import { Logger } from "akanjs/common";
|
|
5
5
|
import type { AkanChildRole, AkanChildStatus, AkanIpcMessage, AkanMetricsReport, AkanUpstream } from "akanjs/service";
|
|
6
6
|
import { getApiPrefix, getWsPrefix, normalizeRoutePrefix, resetEnvCache } from "../base/baseEnv";
|
|
7
|
+
import { CrossSiteGuard } from "../signal/CrossSiteGuard";
|
|
7
8
|
import { isTraceEnabled } from "../signal/trace";
|
|
8
|
-
import { makeAkanChildProxyHeaders } from "./akanAppHeaders";
|
|
9
|
+
import { AKAN_CHILD_HOST, makeAkanChildProxyHeaders } from "./akanAppHeaders";
|
|
9
10
|
import type { BuilderCsrReq, BuilderCsrRes, BuilderMessage, BuilderReq, BuilderRes } from "./artifact";
|
|
10
11
|
import { compressResponse, encodedFileResponse } from "./contentEncoding";
|
|
11
12
|
import { isPortInUseError } from "./lifecycle/portInUse";
|
|
@@ -19,6 +20,8 @@ import { RotatingLogWriter } from "./logging/rotatingLogWriter";
|
|
|
19
20
|
import { AppInfo } from "./ops/appInfo";
|
|
20
21
|
import type { OpsRoute } from "./ops/opsRoute";
|
|
21
22
|
import { ProcessMetricsCollector } from "./processMetricsCollector";
|
|
23
|
+
import { HostAllowlist } from "./routing/hostAllowlist";
|
|
24
|
+
import { SelfExec } from "./selfExec";
|
|
22
25
|
import { resolveStaticPath } from "./staticPath";
|
|
23
26
|
import { getWebConfigFromEnv } from "./types";
|
|
24
27
|
|
|
@@ -139,6 +142,7 @@ export class AkanApp {
|
|
|
139
142
|
#logControl: LogControlSocket | null = null;
|
|
140
143
|
#logStream: LogStreamRoute | null = null;
|
|
141
144
|
#ops: OpsRoute | null = null;
|
|
145
|
+
#hostAllowlist = HostAllowlist.fromEnv();
|
|
142
146
|
static readonly #ansiPattern = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, "g");
|
|
143
147
|
#gatewayMetrics: AkanMetricsReport = {};
|
|
144
148
|
#proxyHopCount = 0;
|
|
@@ -149,6 +153,7 @@ export class AkanApp {
|
|
|
149
153
|
#stopping = false;
|
|
150
154
|
|
|
151
155
|
constructor(serverPathOrOptions: string | AkanAppOptions = "./server", options: AkanAppOptions = {}) {
|
|
156
|
+
SelfExec.adopt();
|
|
152
157
|
const resolvedOptions = typeof serverPathOrOptions === "string" ? options : serverPathOrOptions;
|
|
153
158
|
const serverPath = typeof serverPathOrOptions === "string" ? serverPathOrOptions : "./server";
|
|
154
159
|
this.#serverPath = AkanApp.#resolveServerPath(resolvedOptions.serverPath ?? serverPath);
|
|
@@ -235,6 +240,10 @@ export class AkanApp {
|
|
|
235
240
|
process.exit(await OpsCommand.run(process.argv.slice(3)));
|
|
236
241
|
}
|
|
237
242
|
if (this.#solo) return await this.#startSolo();
|
|
243
|
+
if (SelfExec.carried)
|
|
244
|
+
throw new Error(
|
|
245
|
+
"A desktop app's server runs in one process, and this one was asked for a gateway and replicas, which would start with the `bun` on the user's PATH: by `replica` or `solo: false` in main.ts, or by AKAN_SOLO=false, an AKAN_REPLICA other than one traffic replica, or AKAN_COMMAND_TYPE=start in its env. Leave them out of the app the desktop build carries.",
|
|
246
|
+
);
|
|
238
247
|
Logger.role = "gateway";
|
|
239
248
|
await this.#prepareRuntimeDir();
|
|
240
249
|
await this.#startLogHub();
|
|
@@ -579,6 +588,7 @@ export class AkanApp {
|
|
|
579
588
|
this.#server = Bun.serve({
|
|
580
589
|
idleTimeout: 0,
|
|
581
590
|
port: this.#port,
|
|
591
|
+
hostname: process.env.AKAN_LISTEN_HOST || undefined,
|
|
582
592
|
fetch: (req, server) => this.#handleFetch(req, server),
|
|
583
593
|
websocket: {
|
|
584
594
|
idleTimeout: 0,
|
|
@@ -595,6 +605,7 @@ export class AkanApp {
|
|
|
595
605
|
}
|
|
596
606
|
|
|
597
607
|
async #handleFetch(req: Request, server: Bun.Server<GatewayWsData>): Promise<Response | undefined> {
|
|
608
|
+
if (this.#hostAllowlist && !this.#hostAllowlist.allows(req)) return this.#hostAllowlist.refuse();
|
|
598
609
|
const url = new URL(req.url);
|
|
599
610
|
if (url.pathname === "/_akan/app/health") return Response.json(this.#getHealthStatus());
|
|
600
611
|
if (url.pathname === "/_akan/app/metrics") return Response.json(this.#getMetricsStatus());
|
|
@@ -638,6 +649,12 @@ export class AkanApp {
|
|
|
638
649
|
const upstream = child?.upstream ? (child.wsUpstream ?? this.#getChildUpstream(child.idx, child.role).ws) : null;
|
|
639
650
|
if (!child || !upstream) return new Response("No websocket upstream is ready", { status: 503 });
|
|
640
651
|
const url = new URL(req.url);
|
|
652
|
+
|
|
653
|
+
try {
|
|
654
|
+
CrossSiteGuard.assertOrigin(req, url, url.pathname === "/_akan/hmr" ? "hmr" : "websocket");
|
|
655
|
+
} catch {
|
|
656
|
+
return new Response("Forbidden", { status: 403 });
|
|
657
|
+
}
|
|
641
658
|
const upstreamWs = new WebSocket(`ws://${upstream.host}:${upstream.port}${url.pathname}${url.search}`, {
|
|
642
659
|
headers: makeAkanChildProxyHeaders(req, child.idx, server.requestIP(req)),
|
|
643
660
|
} as unknown as string[]);
|
|
@@ -926,8 +943,10 @@ export class AkanApp {
|
|
|
926
943
|
async #proxyResponse(req: Request, upstreamRes: Response): Promise<Response> {
|
|
927
944
|
const headers = new Headers(upstreamRes.headers);
|
|
928
945
|
|
|
929
|
-
headers.
|
|
930
|
-
|
|
946
|
+
if (headers.has("content-encoding")) {
|
|
947
|
+
headers.delete("content-encoding");
|
|
948
|
+
headers.delete("content-length");
|
|
949
|
+
}
|
|
931
950
|
this.#rewriteInternalLocation(headers);
|
|
932
951
|
const proxied = new Response(upstreamRes.body, {
|
|
933
952
|
status: upstreamRes.status,
|
|
@@ -947,7 +966,8 @@ export class AkanApp {
|
|
|
947
966
|
if (!location) return;
|
|
948
967
|
try {
|
|
949
968
|
const parsed = new URL(location);
|
|
950
|
-
if (parsed.hostname ===
|
|
969
|
+
if (parsed.hostname === AKAN_CHILD_HOST)
|
|
970
|
+
headers.set("location", `${parsed.pathname}${parsed.search}${parsed.hash}`);
|
|
951
971
|
} catch {
|
|
952
972
|
}
|
|
953
973
|
}
|
|
@@ -1074,6 +1094,7 @@ export class AkanApp {
|
|
|
1074
1094
|
child.upstream = message.upstream;
|
|
1075
1095
|
child.wsUpstream = message.wsUpstream;
|
|
1076
1096
|
child.healthPath = message.healthPath;
|
|
1097
|
+
if (message.crossSite) CrossSiteGuard.configure(message.crossSite);
|
|
1077
1098
|
child.lastPongAtMono = performance.now();
|
|
1078
1099
|
child.restartAttempts = 0;
|
|
1079
1100
|
|
|
@@ -1084,7 +1105,8 @@ export class AkanApp {
|
|
|
1084
1105
|
this.#federationChildCache = null;
|
|
1085
1106
|
|
|
1086
1107
|
const trafficChildren = [...this.#children.values()].filter((item) => item.role !== "batch");
|
|
1087
|
-
|
|
1108
|
+
|
|
1109
|
+
if (!this.#stopping && child.role !== "batch" && trafficChildren.every((item) => item.ready)) {
|
|
1088
1110
|
process.send?.({ type: "backend-ready", pid: process.pid } satisfies AkanIpcMessage);
|
|
1089
1111
|
}
|
|
1090
1112
|
if ([...this.#children.values()].every((item) => item.ready)) {
|
package/server/akanAppHeaders.ts
CHANGED
|
@@ -11,6 +11,9 @@ const HOP_BY_HOP_HEADERS = new Set([
|
|
|
11
11
|
"upgrade",
|
|
12
12
|
]);
|
|
13
13
|
|
|
14
|
+
/** The Host every request the gateway hands a replica carries; a browser never sends it to a replica's port. */
|
|
15
|
+
export const AKAN_CHILD_HOST = "akan-child";
|
|
16
|
+
|
|
14
17
|
export interface ProxyClientPeer {
|
|
15
18
|
address: string;
|
|
16
19
|
port: number;
|
|
@@ -44,6 +47,6 @@ export function makeAkanChildProxyHeaders(req: Request, childIdx: number, peer?:
|
|
|
44
47
|
if (!headers.has("x-request-id") && process.env.AKAN_BENCH_SKIP_REQUEST_ID !== "1") {
|
|
45
48
|
headers.set("x-request-id", crypto.randomUUID());
|
|
46
49
|
}
|
|
47
|
-
headers.set("host",
|
|
50
|
+
headers.set("host", AKAN_CHILD_HOST);
|
|
48
51
|
return headers;
|
|
49
52
|
}
|
package/server/akanServer.ts
CHANGED
|
@@ -19,11 +19,13 @@ import { AgentRelayAccess } from "../signal/guards";
|
|
|
19
19
|
import { createOpenApiDocument } from "../signal/openapi";
|
|
20
20
|
import { FetchSerializer } from "../signal/serializer";
|
|
21
21
|
import { SignalContext } from "../signal/signalContext";
|
|
22
|
+
import { AKAN_CHILD_HOST } from "./akanAppHeaders";
|
|
22
23
|
import type { AkanLib, AkanLibProps } from "./akanLib";
|
|
23
24
|
import { BinaryPubsub } from "./binaryPubsub";
|
|
24
25
|
import { DevtoolsRouter } from "./devtools";
|
|
25
26
|
import { DiLifecycle } from "./di/diLifecycle";
|
|
26
27
|
import type { HmrWsData, HmrWsHub } from "./hmr/wsHub";
|
|
28
|
+
import { OrphanGroup } from "./lifecycle/orphanGroup";
|
|
27
29
|
import { isPortInUseError } from "./lifecycle/portInUse";
|
|
28
30
|
import { resolveRuntimeDir } from "./lifecycle/runtimeDir";
|
|
29
31
|
import { ShutdownManager } from "./lifecycle/shutdownManager";
|
|
@@ -42,6 +44,7 @@ import { WebProxyRunner } from "./proxy";
|
|
|
42
44
|
import { SignalResolver } from "./resolver";
|
|
43
45
|
import { type ApiRouteInputs, ApiRouter } from "./routing/apiRouter";
|
|
44
46
|
import type { AppWsData } from "./routing/appWsData";
|
|
47
|
+
import { HostAllowlist } from "./routing/hostAllowlist";
|
|
45
48
|
import { createSoloAppRoutes } from "./routing/soloAppRoutes";
|
|
46
49
|
import {
|
|
47
50
|
getWebConfigFromEnv,
|
|
@@ -167,6 +170,8 @@ export class AkanServer {
|
|
|
167
170
|
#ops: OpsRoute | null | undefined;
|
|
168
171
|
#lastMetrics: AkanMetricsReport = {};
|
|
169
172
|
#stopping: Promise<void> | null = null;
|
|
173
|
+
#parentGone: boolean = false;
|
|
174
|
+
#orphanExiting = false;
|
|
170
175
|
constructor(
|
|
171
176
|
name = "AkanServer",
|
|
172
177
|
env: BackendEnv = {},
|
|
@@ -392,7 +397,10 @@ export class AkanServer {
|
|
|
392
397
|
}),
|
|
393
398
|
data: {},
|
|
394
399
|
} as Bun.WebSocketHandler<AppWsData | HmrWsData>;
|
|
395
|
-
|
|
400
|
+
|
|
401
|
+
const hostAllowlist = unix ? null : HostAllowlist.fromEnv();
|
|
402
|
+
const hostname = process.env.AKAN_LISTEN_HOST || undefined;
|
|
403
|
+
const buildRoutes = (upgradeAppWs: ApiRouteInputs["upgradeAppWs"], allowlist: HostAllowlist | null) =>
|
|
396
404
|
ApiRouter.buildRoutes({
|
|
397
405
|
prefix: this.prefix,
|
|
398
406
|
websocketPrefix: this.websocketPrefix,
|
|
@@ -402,19 +410,25 @@ export class AkanServer {
|
|
|
402
410
|
renderEnvRoutes,
|
|
403
411
|
upgradeAppWs,
|
|
404
412
|
webProxyRunner,
|
|
413
|
+
hostAllowlist: allowlist,
|
|
405
414
|
});
|
|
406
415
|
this.#server = Bun.serve({
|
|
407
416
|
idleTimeout: 0,
|
|
408
|
-
...(unix ? { unix } : { port }),
|
|
409
|
-
routes: buildRoutes((req, data) => this.#server?.upgrade(req, { data }) ?? false),
|
|
417
|
+
...(unix ? { unix } : { port, hostname }),
|
|
418
|
+
routes: buildRoutes((req, data) => this.#server?.upgrade(req, { data }) ?? false, hostAllowlist),
|
|
410
419
|
websocket: websocketHandlers,
|
|
411
420
|
} as Parameters<typeof Bun.serve>[0]);
|
|
412
421
|
if (unix && process.env.AKAN_CHILD_WS_PORT) {
|
|
413
422
|
const preferredWsPort = Number(process.env.AKAN_CHILD_WS_PORT);
|
|
423
|
+
|
|
414
424
|
const wsServeOptions = (port: number) => ({
|
|
415
425
|
idleTimeout: 0,
|
|
416
426
|
port,
|
|
417
|
-
|
|
427
|
+
hostname: "127.0.0.1",
|
|
428
|
+
routes: buildRoutes(
|
|
429
|
+
(req, data) => this.#wsServer?.upgrade(req, { data }) ?? false,
|
|
430
|
+
new HostAllowlist([AKAN_CHILD_HOST]),
|
|
431
|
+
),
|
|
418
432
|
websocket: websocketHandlers,
|
|
419
433
|
});
|
|
420
434
|
try {
|
|
@@ -459,7 +473,7 @@ export class AkanServer {
|
|
|
459
473
|
this.#di.registerSchedule(this.serverMode);
|
|
460
474
|
this.logger.verbose(`🚀 ${this.name} is running on ${unix ? `unix://${unix}` : `port ${port}`}`);
|
|
461
475
|
const wsPort = this.#wsServer?.port;
|
|
462
|
-
|
|
476
|
+
this.#sendReady({
|
|
463
477
|
type: "ready",
|
|
464
478
|
pid: process.pid,
|
|
465
479
|
replicaIdx: Number(process.env.AKAN_REPLICA_IDX ?? 0),
|
|
@@ -467,7 +481,8 @@ export class AkanServer {
|
|
|
467
481
|
upstream: unix ? { type: "unix", socketPath: unix } : { type: "tcp", host: "127.0.0.1", port: Number(port) },
|
|
468
482
|
wsUpstream: typeof wsPort === "number" ? { type: "tcp", host: "127.0.0.1", port: wsPort } : undefined,
|
|
469
483
|
healthPath: "/_akan/app/child-health",
|
|
470
|
-
|
|
484
|
+
crossSite: CrossSiteGuard.option(),
|
|
485
|
+
});
|
|
471
486
|
await this.#di.runSchedulerInit();
|
|
472
487
|
ShutdownManager.register(this.logger, () => this.stop());
|
|
473
488
|
return this;
|
|
@@ -476,7 +491,10 @@ export class AkanServer {
|
|
|
476
491
|
async start({ listen, web }: { listen?: boolean; web?: AkanWebOption } = {}) {
|
|
477
492
|
const isNoListenCommand = process.env.AKAN_COMMAND_TYPE === "script" || process.env.AKAN_COMMAND_TYPE === "console";
|
|
478
493
|
const shouldListen = (listen ?? !isNoListenCommand) && this.serverMode !== "batch";
|
|
494
|
+
|
|
495
|
+
if (shouldListen || !isNoListenCommand) this.#registerParentIpc();
|
|
479
496
|
await this.init({ routes: shouldListen, web });
|
|
497
|
+
if (this.#parentGone) return this;
|
|
480
498
|
if (!shouldListen) {
|
|
481
499
|
const websocket = this.#di.getWebsocketAdaptor();
|
|
482
500
|
if (websocket)
|
|
@@ -486,20 +504,18 @@ export class AkanServer {
|
|
|
486
504
|
Logger.role = this.serverMode;
|
|
487
505
|
this.#metricsTimer ??= ProcessMetricsCollector.startReporting(() => this.#reportMetrics());
|
|
488
506
|
this.#di.registerSchedule(this.serverMode);
|
|
489
|
-
this.#registerParentIpc();
|
|
490
507
|
await this.#startLogTransport();
|
|
491
|
-
|
|
508
|
+
this.#sendReady({
|
|
492
509
|
type: "ready",
|
|
493
510
|
pid: process.pid,
|
|
494
511
|
replicaIdx: Number(process.env.AKAN_REPLICA_IDX ?? 0),
|
|
495
512
|
role: this.serverMode,
|
|
496
|
-
}
|
|
513
|
+
});
|
|
497
514
|
await this.#di.runSchedulerInit();
|
|
498
515
|
ShutdownManager.register(this.logger, () => this.stop());
|
|
499
516
|
}
|
|
500
517
|
return this;
|
|
501
518
|
}
|
|
502
|
-
this.#registerParentIpc();
|
|
503
519
|
return this.listen();
|
|
504
520
|
}
|
|
505
521
|
stop(): Promise<void> {
|
|
@@ -546,14 +562,28 @@ export class AkanServer {
|
|
|
546
562
|
#registerParentIpc() {
|
|
547
563
|
process.on("message", (message) => this.#handleIpcMessage(message as AkanIpcMessage));
|
|
548
564
|
process.on("disconnect", () => this.#handleParentDisconnect());
|
|
565
|
+
|
|
566
|
+
if (process.send && !process.connected) this.#handleParentDisconnect();
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
#sendReady(message: AkanIpcMessage) {
|
|
570
|
+
if (process.send?.(message) === false) this.#handleParentDisconnect();
|
|
549
571
|
}
|
|
550
572
|
|
|
551
573
|
#handleParentDisconnect() {
|
|
574
|
+
if (this.#parentGone) return;
|
|
575
|
+
this.#parentGone = true;
|
|
552
576
|
this.logger.warn("Parent IPC channel closed; shutting down to avoid an orphaned replica");
|
|
553
|
-
setTimeout(() =>
|
|
577
|
+
setTimeout(() => this.#exitOrphaned(1), this.shutdownTimeoutMs + 1_000);
|
|
554
578
|
void this.stop()
|
|
555
|
-
.then(() =>
|
|
556
|
-
.catch(() =>
|
|
579
|
+
.then(() => this.#exitOrphaned(0))
|
|
580
|
+
.catch(() => this.#exitOrphaned(1));
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
#exitOrphaned(code: number) {
|
|
584
|
+
if (this.#orphanExiting) return;
|
|
585
|
+
this.#orphanExiting = true;
|
|
586
|
+
OrphanGroup.exit(code);
|
|
557
587
|
}
|
|
558
588
|
|
|
559
589
|
#handleIpcMessage(message: AkanIpcMessage) {
|
|
@@ -32,6 +32,8 @@ const GZIP_LEVEL = 6;
|
|
|
32
32
|
/** Under a KB the framing bytes and the call cost more than the repetition they remove. */
|
|
33
33
|
const MIN_COMPRESS_BYTES = 1024;
|
|
34
34
|
|
|
35
|
+
const MAX_COMPRESS_BYTES = 4 * 1024 * 1024;
|
|
36
|
+
|
|
35
37
|
const compressBody = (bytes: Uint8Array, encoding: ContentEncoding) =>
|
|
36
38
|
encoding === "br"
|
|
37
39
|
? brotliCompressSync(bytes, {
|
|
@@ -42,8 +44,17 @@ const compressBody = (bytes: Uint8Array, encoding: ContentEncoding) =>
|
|
|
42
44
|
})
|
|
43
45
|
: gzipSync(bytes, { level: GZIP_LEVEL });
|
|
44
46
|
|
|
47
|
+
const isWholeBody = (req: Request, response: Response) =>
|
|
48
|
+
!req.headers.has("range") &&
|
|
49
|
+
!response.headers.has("content-range") &&
|
|
50
|
+
response.status >= 200 &&
|
|
51
|
+
response.status < 300 &&
|
|
52
|
+
response.status !== 206;
|
|
53
|
+
|
|
45
54
|
export const compressResponse = async (req: Request, response: Response): Promise<Response> => {
|
|
46
55
|
if (response.headers.has("content-encoding") || !response.body) return response;
|
|
56
|
+
if (!isWholeBody(req, response)) return response;
|
|
57
|
+
if (Number(response.headers.get("content-length")) > MAX_COMPRESS_BYTES) return response;
|
|
47
58
|
if (!isCompressibleContentType(response.headers.get("content-type") ?? "")) return response;
|
|
48
59
|
if (process.env.AKAN_HTTP_COMPRESS === "false" || process.env.AKAN_HTTP_COMPRESS === "0") return response;
|
|
49
60
|
const acceptEncoding = req.headers.get("accept-encoding") ?? "";
|
package/server/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ export * from "./console";
|
|
|
7
7
|
export * from "./decorators";
|
|
8
8
|
export * from "./devtools";
|
|
9
9
|
export type { ChangeBatch, ChangeKind } from "./hmr/wsHub";
|
|
10
|
+
export * from "./nativeFile";
|
|
10
11
|
export * from "./oauth";
|
|
11
12
|
export * from "./processMetricsCollector";
|
|
12
13
|
export * from "./proxy";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { SelfExec } from "../selfExec";
|
|
2
|
+
|
|
3
|
+
export class OrphanGroup {
|
|
4
|
+
static exit(code: number, { leads = SelfExec.carried && process.platform !== "win32", graceMs = 1_000 } = {}) {
|
|
5
|
+
if (!leads) process.exit(code);
|
|
6
|
+
process.removeAllListeners("SIGTERM");
|
|
7
|
+
process.on("SIGTERM", () => undefined);
|
|
8
|
+
OrphanGroup.#signal("SIGTERM");
|
|
9
|
+
setTimeout(() => {
|
|
10
|
+
OrphanGroup.#signal("SIGKILL");
|
|
11
|
+
process.exit(code);
|
|
12
|
+
}, graceMs);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
static #signal(signal: NodeJS.Signals) {
|
|
16
|
+
try {
|
|
17
|
+
process.kill(-process.pid, signal);
|
|
18
|
+
} catch {
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { lstatSync, readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { getEnv } from "akanjs/base";
|
|
6
|
+
import type { AkanIpcMessage } from "akanjs/service";
|
|
7
|
+
|
|
8
|
+
export type NativeFileMode = "read" | "write" | "folder";
|
|
9
|
+
|
|
10
|
+
type Resolved = Extract<AkanIpcMessage, { type: "file.resolved" }>;
|
|
11
|
+
|
|
12
|
+
export class NativeFile {
|
|
13
|
+
static timeoutMs = 5_000;
|
|
14
|
+
static operationMode: () => string = () => getEnv().operationMode;
|
|
15
|
+
static readonly #waiting = new Map<string, (answer: Resolved) => void>();
|
|
16
|
+
static #listener: ((message: unknown) => void) | null = null;
|
|
17
|
+
|
|
18
|
+
/** The path behind `grant`, which the user must have given for `mode`. */
|
|
19
|
+
static async resolve(grant: string, mode: NativeFileMode): Promise<string> {
|
|
20
|
+
const found = grant.startsWith("dev:") ? NativeFile.#devGrant(grant) : await NativeFile.#ask(grant);
|
|
21
|
+
if (found.mode !== mode) throw new Error(`The file was granted to ${found.mode}, not to ${mode}.`);
|
|
22
|
+
return found.path;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A path inside a folder grant; one that climbs out of the folder is refused. */
|
|
26
|
+
static async resolveIn(grant: string, relative: string): Promise<string> {
|
|
27
|
+
const folder = await NativeFile.resolve(grant, "folder");
|
|
28
|
+
const target = path.resolve(folder, relative);
|
|
29
|
+
NativeFile.#assertInside(folder, target, relative);
|
|
30
|
+
|
|
31
|
+
NativeFile.#assertInside(NativeFile.#realPath(folder), NativeFile.#realPath(target), relative);
|
|
32
|
+
return target;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
static #assertInside(folder: string, target: string, relative: string) {
|
|
36
|
+
const inside = path.relative(folder, target);
|
|
37
|
+
if (inside === ".." || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside))
|
|
38
|
+
throw new Error(`${relative} is outside the folder the user granted.`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
static #realPath(target: string): string {
|
|
42
|
+
const rest: string[] = [];
|
|
43
|
+
for (let current = target; ; current = path.dirname(current)) {
|
|
44
|
+
try {
|
|
45
|
+
return path.join(realpathSync.native(current), ...rest);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT" || path.dirname(current) === current) throw error;
|
|
48
|
+
if (NativeFile.#exists(current)) throw new Error(`${target} is a link to nothing.`);
|
|
49
|
+
rest.unshift(path.basename(current));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
static #exists(entry: string) {
|
|
55
|
+
try {
|
|
56
|
+
lstatSync(entry);
|
|
57
|
+
return true;
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
static #ask(grant: string): Promise<{ path: string; mode: NativeFileMode }> {
|
|
64
|
+
const send = process.send?.bind(process);
|
|
65
|
+
if (!send) return Promise.reject(new Error("A file grant resolves only inside the desktop app that gave it."));
|
|
66
|
+
NativeFile.#listen();
|
|
67
|
+
const id = crypto.randomUUID();
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
const timer = setTimeout(() => {
|
|
70
|
+
NativeFile.#waiting.delete(id);
|
|
71
|
+
reject(new Error("The desktop app did not answer for the file grant."));
|
|
72
|
+
}, NativeFile.timeoutMs);
|
|
73
|
+
NativeFile.#waiting.set(id, (answer) => {
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
NativeFile.#waiting.delete(id);
|
|
76
|
+
if (answer.path && answer.mode) resolve({ path: answer.path, mode: answer.mode });
|
|
77
|
+
else reject(new Error(answer.error ?? "The desktop app refused the file grant."));
|
|
78
|
+
});
|
|
79
|
+
send({ type: "file.resolve", id, grant } satisfies AkanIpcMessage);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
static #listen() {
|
|
84
|
+
if (NativeFile.#listener) return;
|
|
85
|
+
NativeFile.#listener = (message) => {
|
|
86
|
+
const answer = message as AkanIpcMessage | null;
|
|
87
|
+
if (answer?.type === "file.resolved") NativeFile.#waiting.get(answer.id)?.(answer);
|
|
88
|
+
};
|
|
89
|
+
process.on("message", NativeFile.#listener);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
static #devGrant(grant: string): { path: string; mode: NativeFileMode } {
|
|
93
|
+
|
|
94
|
+
if (NativeFile.operationMode() !== "local")
|
|
95
|
+
throw new Error("A dev build's file grant resolves only on akan start, in operationMode local.");
|
|
96
|
+
const [, mode, encoded = "", signature = ""] = grant.split(":");
|
|
97
|
+
if (mode !== "read" && mode !== "write" && mode !== "folder") throw new Error("The file grant is malformed.");
|
|
98
|
+
const key = readFileSync(
|
|
99
|
+
process.env.AKAN_NATIVE_DEV_GRANT_KEY || path.join(homedir(), ".akan", "native", "dev-file-grant.key"),
|
|
100
|
+
);
|
|
101
|
+
const expected = createHmac("sha256", key).update(`${mode}:${encoded}`).digest();
|
|
102
|
+
const given = Buffer.from(signature, "base64url");
|
|
103
|
+
if (given.length !== expected.length || !timingSafeEqual(given, expected))
|
|
104
|
+
throw new Error("The file grant was not signed by this computer's dev build.");
|
|
105
|
+
return { path: Buffer.from(encoded, "base64url").toString(), mode };
|
|
106
|
+
}
|
|
107
|
+
}
|
package/server/ops/appInfo.ts
CHANGED
|
@@ -7,6 +7,7 @@ export interface AppPublicInfo {
|
|
|
7
7
|
repoName: string;
|
|
8
8
|
environment: string;
|
|
9
9
|
operationMode: string;
|
|
10
|
+
workspaceRoot?: string;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
export interface AppDetailInfo extends AppPublicInfo {
|
|
@@ -39,7 +40,8 @@ export class AppInfo {
|
|
|
39
40
|
|
|
40
41
|
static public(): AppPublicInfo {
|
|
41
42
|
const { appName, repoName, environment, operationMode } = getEnv();
|
|
42
|
-
|
|
43
|
+
const workspaceRoot = operationMode === "local" ? process.env.AKAN_WORKSPACE_ROOT : undefined;
|
|
44
|
+
return { appName, repoName, environment, operationMode, ...(workspaceRoot ? { workspaceRoot } : {}) };
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
static detail(proc: AppProcessInfo): AppDetailInfo {
|
package/server/ops/opsRoute.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Logger } from "akanjs/common";
|
|
2
2
|
import { getEnv } from "../../base/baseEnv";
|
|
3
|
+
import { SelfExec } from "../selfExec";
|
|
3
4
|
import type { AppDetailInfo } from "./appInfo";
|
|
4
5
|
import { OpsTokenVerifier } from "./opsToken";
|
|
5
6
|
import { type SnapshotJobRequest, SnapshotJobs } from "./snapshotJobs";
|
|
@@ -65,8 +66,8 @@ export class OpsRoute {
|
|
|
65
66
|
const args = ["ops", "snapshot", "--id", request.id, "--out", dir, "--db", sources.main, "--json"];
|
|
66
67
|
if (sources.solid) args.push("--solid-db", sources.solid);
|
|
67
68
|
if (request.includeSolid) args.push("--include-solid");
|
|
68
|
-
const proc = Bun.spawn(
|
|
69
|
-
env:
|
|
69
|
+
const proc = Bun.spawn(SelfExec.command(Bun.main, ...args), {
|
|
70
|
+
env: SelfExec.env(),
|
|
70
71
|
stdout: "pipe",
|
|
71
72
|
stderr: "pipe",
|
|
72
73
|
});
|
|
@@ -8,6 +8,7 @@ import { copyBunRequestFields, type WebProxyRunner } from "../proxy";
|
|
|
8
8
|
import { SignalResolver } from "../resolver";
|
|
9
9
|
import type { HttpRoutes, SignalRouteOptions, WebsocketRoutes } from "../types";
|
|
10
10
|
import { AppWsData } from "./appWsData";
|
|
11
|
+
import type { HostAllowlist } from "./hostAllowlist";
|
|
11
12
|
|
|
12
13
|
export interface HmrStateSource {
|
|
13
14
|
readonly state: {
|
|
@@ -34,6 +35,7 @@ export interface ApiRouteInputs {
|
|
|
34
35
|
renderEnvRoutes: HttpRoutes;
|
|
35
36
|
upgradeAppWs: (req: Request, data: AppWsData) => boolean;
|
|
36
37
|
webProxyRunner?: WebProxyRunner | null;
|
|
38
|
+
hostAllowlist?: HostAllowlist | null;
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
type RouteValue = NonNullHttpRoutes[keyof NonNullHttpRoutes];
|
|
@@ -62,6 +64,7 @@ export class ApiRouter {
|
|
|
62
64
|
renderEnvRoutes,
|
|
63
65
|
upgradeAppWs,
|
|
64
66
|
webProxyRunner,
|
|
67
|
+
hostAllowlist,
|
|
65
68
|
}: ApiRouteInputs): NonNullHttpRoutes {
|
|
66
69
|
const endpointEntries = Object.entries(routes ?? {}).map(
|
|
67
70
|
([p, handler]) =>
|
|
@@ -76,6 +79,12 @@ export class ApiRouter {
|
|
|
76
79
|
const endpointPaths = new Set([...endpointEntries.map(([path]) => path), ...builtinEntries.map(([path]) => path)]);
|
|
77
80
|
const routeTable = {
|
|
78
81
|
[`${prefix}${websocketPrefix}` as "/api/ws"]: (req) => {
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
CrossSiteGuard.assertOrigin(req, new URL(req.url), "websocket");
|
|
85
|
+
} catch {
|
|
86
|
+
return new Response("Forbidden", { status: 403 });
|
|
87
|
+
}
|
|
79
88
|
const upgraded = upgradeAppWs(req, AppWsData.fromRequest(req));
|
|
80
89
|
if (upgraded) return;
|
|
81
90
|
return new Response("Failed to upgrade to WebSocket", { status: 500 });
|
|
@@ -84,9 +93,10 @@ export class ApiRouter {
|
|
|
84
93
|
...Object.fromEntries(builtinEntries),
|
|
85
94
|
...(renderEnvRoutes ?? {}),
|
|
86
95
|
} as NonNullHttpRoutes;
|
|
87
|
-
|
|
96
|
+
const served = webProxyRunner
|
|
88
97
|
? ApiRouter.#wrapRoutesWithWebProxy(routeTable, webProxyRunner, prefix, endpointPaths)
|
|
89
98
|
: routeTable;
|
|
99
|
+
return hostAllowlist ? ApiRouter.#guardHosts(served, hostAllowlist) : served;
|
|
90
100
|
}
|
|
91
101
|
|
|
92
102
|
static buildWebsocketHandlers({
|
|
@@ -214,6 +224,15 @@ export class ApiRouter {
|
|
|
214
224
|
) as NonNullHttpRoutes;
|
|
215
225
|
}
|
|
216
226
|
|
|
227
|
+
static #guardHosts(routes: NonNullHttpRoutes, allowlist: HostAllowlist): NonNullHttpRoutes {
|
|
228
|
+
return Object.fromEntries(
|
|
229
|
+
Object.entries(routes).map(([path, route]) => [
|
|
230
|
+
path,
|
|
231
|
+
ApiRouter.#mapRoute(route, (handler) => (req) => (allowlist.allows(req) ? handler(req) : allowlist.refuse())),
|
|
232
|
+
]),
|
|
233
|
+
) as NonNullHttpRoutes;
|
|
234
|
+
}
|
|
235
|
+
|
|
217
236
|
static #isApiRoute(path: string, apiPrefix: string): boolean {
|
|
218
237
|
const normalized = apiPrefix.replace(/\/$/, "");
|
|
219
238
|
return path === normalized || path.startsWith(`${normalized}/`);
|
|
@@ -242,12 +261,16 @@ export class ApiRouter {
|
|
|
242
261
|
|
|
243
262
|
static #mapRoute(route: RouteValue, wrap: (handler: RouteHandler) => RouteHandler): RouteValue {
|
|
244
263
|
if (typeof route === "function") return wrap(route as RouteHandler) as RouteValue;
|
|
245
|
-
if (route instanceof Response) return wrap(() => route) as RouteValue;
|
|
264
|
+
if (route instanceof Response) return wrap(() => route.clone()) as RouteValue;
|
|
246
265
|
if (!route || typeof route !== "object") return route;
|
|
247
266
|
return Object.fromEntries(
|
|
248
267
|
Object.entries(route).map(([method, handler]) => [
|
|
249
268
|
method,
|
|
250
|
-
typeof handler === "function"
|
|
269
|
+
typeof handler === "function"
|
|
270
|
+
? wrap(handler as RouteHandler)
|
|
271
|
+
: handler instanceof Response
|
|
272
|
+
? wrap(() => handler.clone())
|
|
273
|
+
: handler,
|
|
251
274
|
]),
|
|
252
275
|
) as RouteValue;
|
|
253
276
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
export class HostAllowlist {
|
|
4
|
+
readonly #hosts: Set<string>;
|
|
5
|
+
|
|
6
|
+
constructor(hosts: Iterable<string>) {
|
|
7
|
+
this.#hosts = new Set([...hosts].map((host) => host.trim().toLowerCase()).filter(Boolean));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** `AKAN_ALLOWED_HOSTS`: comma-separated `host:port` entries; unset or blank serves every Host. */
|
|
11
|
+
static fromEnv(env: Record<string, string | undefined> = process.env): HostAllowlist | null {
|
|
12
|
+
const allowlist = new HostAllowlist((env.AKAN_ALLOWED_HOSTS ?? "").split(","));
|
|
13
|
+
return allowlist.#hosts.size ? allowlist : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
allows(req: Request): boolean {
|
|
17
|
+
const host = req.headers.get("host")?.trim().toLowerCase();
|
|
18
|
+
return !!host && this.#hosts.has(host);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
refuse(): Response {
|
|
22
|
+
return new Response("Forbidden", { status: 403 });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
interface SelfExecGlobal {
|
|
2
|
+
[key: symbol]: string | undefined;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export class SelfExec {
|
|
6
|
+
|
|
7
|
+
static readonly #key = Symbol.for("akan.selfExec.beBun");
|
|
8
|
+
|
|
9
|
+
static get #beBun(): string | undefined {
|
|
10
|
+
return (globalThis as unknown as SelfExecGlobal)[SelfExec.#key];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
static adopt() {
|
|
14
|
+
const value = process.env.BUN_BE_BUN;
|
|
15
|
+
if (value === undefined) return;
|
|
16
|
+
(globalThis as unknown as SelfExecGlobal)[SelfExec.#key] = value;
|
|
17
|
+
delete process.env.BUN_BE_BUN;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A desktop app's carried server: only its shell starts the app executable as Bun. */
|
|
21
|
+
static get carried(): boolean {
|
|
22
|
+
return SelfExec.#beBun !== undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
static env(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
26
|
+
const beBun = SelfExec.#beBun;
|
|
27
|
+
return beBun === undefined ? env : { ...env, BUN_BE_BUN: beBun };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
static command(script: string, ...args: string[]): string[] {
|
|
31
|
+
return [process.execPath, ...process.execArgv, script, ...args];
|
|
32
|
+
}
|
|
33
|
+
}
|
package/server/types.tsx
CHANGED
|
@@ -125,7 +125,9 @@ export const getWebConfigFromEnv = (): AkanWebConfig => {
|
|
|
125
125
|
|
|
126
126
|
export interface MobileDeepLinkAssociation {
|
|
127
127
|
targetName: string;
|
|
128
|
-
|
|
128
|
+
/** Null when the target names no id for the platform; that platform then gets no association. */
|
|
129
|
+
iosAppId: string | null;
|
|
130
|
+
androidAppId: string | null;
|
|
129
131
|
domains: string[];
|
|
130
132
|
iosTeamId?: string;
|
|
131
133
|
androidSha256CertFingerprints?: string[];
|