Add link session preparation flow

- wire front link resolve/prepare routes
- add orchestrator session command handling
- update admin dashboards and device/link logic
This commit is contained in:
user
2026-03-28 17:47:03 +02:00
parent 5da61ed853
commit 0a11be5006
20 changed files with 1099 additions and 175 deletions

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
/**
* Mobile Proxy Test — Bun Reverse Proxy Server
*

View File

@@ -0,0 +1,54 @@
import type { FlowExecCtx } from "@pkg/logic/core/flow.execution.context";
import { ERROR_CODES, errorStatusMap, type Err } from "@pkg/result";
import { randomUUID } from "node:crypto";
import type { ContentfulStatusCode } from "hono/utils/http-status";
export function buildFlowExecCtx(): FlowExecCtx {
return { flowId: randomUUID() };
}
export function normalizeBaseUrl(url: string): string {
return url.endsWith("/") ? url.slice(0, -1) : url;
}
export function createAppError(
fctx: FlowExecCtx,
code: string,
message: string,
description: string,
detail: string,
actionable?: boolean,
): Err {
return {
flowId: fctx.flowId,
code,
message,
description,
detail,
actionable,
};
}
export function jsonError(error: Err, status?: number) {
return {
body: {
data: null,
error: {
...error,
},
},
status:
status ||
errorStatusMap[error.code] ||
errorStatusMap[ERROR_CODES.INTERNAL_SERVER_ERROR] ||
500,
};
}
export function toStatusCode(status: number): ContentfulStatusCode {
return status as ContentfulStatusCode;
}
export function isErrPayload(value: unknown): value is Err {
return !!value && typeof value === "object" && "code" in value;
}

View File

@@ -0,0 +1,13 @@
import { Hono } from "hono";
import { buildFlowExecCtx, toStatusCode } from "../../core/utils";
import { prepareLink, resolveLink } from "./service";
export const linksRouter = new Hono()
.get("/links/:token/resolve", async (c) => {
const response = await resolveLink(buildFlowExecCtx(), c.req.param("token"));
return c.json(response.body, toStatusCode(response.status));
})
.post("/links/:token/prepare", async (c) => {
const response = await prepareLink(buildFlowExecCtx(), c.req.param("token"));
return c.json(response.body, toStatusCode(response.status));
});

View File

@@ -0,0 +1,297 @@
import { getLinkController } from "@pkg/logic/domains/link/controller";
import { DeviceStatus } from "@pkg/logic/domains/device/data";
import type { FlowExecCtx } from "@pkg/logic/core/flow.execution.context";
import { formatErrorDetail, logDomainEvent } from "@pkg/logger";
import { ERROR_CODES, type Err } from "@pkg/result";
import { settings } from "@pkg/settings";
import {
createAppError,
isErrPayload,
jsonError,
normalizeBaseUrl,
} from "../../core/utils";
const linkController = getLinkController();
type OrchestratorPreparePayload = {
deviceId: number;
packageName: string;
linkToken: string;
};
type OrchestratorPrepareResponse = {
data: {
deviceId: number;
containerId: string;
packageName: string;
serial: string;
status: string;
};
error: null;
};
async function callOrchestratorPrepare(
fctx: FlowExecCtx,
payload: OrchestratorPreparePayload,
): Promise<OrchestratorPrepareResponse> {
const response = await fetch(
`${normalizeBaseUrl(settings.orchestratorApiUrl)}/internal/sessions/prepare`,
{
method: "POST",
headers: {
"content-type": "application/json",
"x-internal-api-key": settings.internalApiKey,
"x-flow-id": fctx.flowId,
},
body: JSON.stringify(payload),
},
);
let body: unknown = null;
try {
body = await response.json();
} catch (error) {
throw createAppError(
fctx,
ERROR_CODES.EXTERNAL_SERVICE_ERROR,
"Invalid orchestrator response",
"The orchestrator returned a response that could not be parsed",
formatErrorDetail(error),
);
}
if (!response.ok && body && typeof body === "object" && "error" in body) {
const payloadError = (body as { error?: unknown }).error;
if (isErrPayload(payloadError)) {
throw payloadError;
}
}
if (
!body ||
typeof body !== "object" ||
!("data" in body) ||
("error" in body && body.error)
) {
throw createAppError(
fctx,
ERROR_CODES.EXTERNAL_SERVICE_ERROR,
"Unexpected orchestrator response",
"The orchestrator response was missing the expected data payload",
JSON.stringify(body),
);
}
return body as OrchestratorPrepareResponse;
}
export async function resolveLink(fctx: FlowExecCtx, token: string) {
logDomainEvent({
event: "front.link_resolve.started",
fctx,
meta: { token },
});
const linkResult = await linkController.validate(fctx, token);
if (linkResult.isErr()) {
logDomainEvent({
level: "warn",
event: "front.link_resolve.rejected",
fctx,
error: linkResult.error,
meta: { token },
});
return jsonError(linkResult.error);
}
const link = linkResult.value;
if (!link.device) {
return jsonError(
createAppError(
fctx,
ERROR_CODES.NOT_ALLOWED,
"Link is not assigned to a device",
"This link cannot start a session because no device is assigned",
`token=${token}`,
true,
),
);
}
if (!link.supportedApp) {
return jsonError(
createAppError(
fctx,
ERROR_CODES.NOT_ALLOWED,
"Link is not assigned to an app",
"This link cannot start a session because no app is assigned",
`token=${token}`,
true,
),
);
}
return {
body: {
data: {
link: {
id: link.id,
token: link.token,
status: link.status,
expiresAt: link.expiresAt,
},
device: {
id: link.device.id,
title: link.device.title,
status: link.device.status,
inUse: link.device.inUse,
isAvailable:
link.device.status === DeviceStatus.ONLINE && !link.device.inUse,
},
supportedApp: {
id: link.supportedApp.id,
title: link.supportedApp.title,
packageName: link.supportedApp.packageName,
},
},
error: null,
},
status: 200,
};
}
export async function prepareLink(fctx: FlowExecCtx, token: string) {
logDomainEvent({
event: "front.link_prepare.started",
fctx,
meta: { token },
});
const linkResult = await linkController.validate(fctx, token);
if (linkResult.isErr()) {
logDomainEvent({
level: "warn",
event: "front.link_prepare.rejected",
fctx,
error: linkResult.error,
meta: { token },
});
return jsonError(linkResult.error);
}
const link = linkResult.value;
if (!link.device) {
return jsonError(
createAppError(
fctx,
ERROR_CODES.NOT_ALLOWED,
"Link is not assigned to a device",
"This link cannot start a session because no device is assigned",
`token=${token}`,
true,
),
);
}
if (!link.supportedApp) {
return jsonError(
createAppError(
fctx,
ERROR_CODES.NOT_ALLOWED,
"Link is not assigned to an app",
"This link cannot start a session because no app is assigned",
`token=${token}`,
true,
),
);
}
if (link.device.status !== DeviceStatus.ONLINE || link.device.inUse) {
return jsonError(
createAppError(
fctx,
ERROR_CODES.NOT_ALLOWED,
"Device is not available",
"The assigned device is currently busy or offline",
`deviceId=${link.device.id} status=${link.device.status} inUse=${link.device.inUse}`,
true,
),
);
}
try {
const orchestratorResponse = await callOrchestratorPrepare(fctx, {
deviceId: link.device.id,
packageName: link.supportedApp.packageName,
linkToken: link.token,
});
logDomainEvent({
event: "front.link_prepare.succeeded",
fctx,
meta: {
token,
deviceId: link.device.id,
packageName: link.supportedApp.packageName,
},
});
return {
body: {
data: {
link: {
id: link.id,
token: link.token,
},
device: {
id: link.device.id,
title: link.device.title,
host: link.device.host,
wsPort: link.device.wsPort,
},
supportedApp: {
id: link.supportedApp.id,
title: link.supportedApp.title,
packageName: link.supportedApp.packageName,
},
session: orchestratorResponse.data,
},
error: null,
},
status: 200,
};
} catch (error) {
const err: Err = isErrPayload(error)
? error
: createAppError(
fctx,
ERROR_CODES.EXTERNAL_SERVICE_ERROR,
"Failed to prepare session",
"The front server could not prepare the assigned Android session",
formatErrorDetail(error),
);
logDomainEvent({
level: "error",
event: "front.link_prepare.failed",
fctx,
error: err,
meta: {
token,
orchestratorUrl: settings.orchestratorApiUrl,
},
});
return jsonError(
err.flowId
? err
: {
...err,
flowId: fctx.flowId,
},
err.code === ERROR_CODES.EXTERNAL_SERVICE_ERROR ? 502 : undefined,
);
}
}

View File

@@ -1,33 +1,15 @@
import "./instrumentation.js";
import { createHttpTelemetryMiddleware } from "@pkg/logic/core/http.telemetry";
import type { FlowExecCtx } from "@pkg/logic/core/flow.execution.context";
import { logDomainEvent } from "@pkg/logger";
import { serve } from "@hono/node-server";
import { settings } from "@pkg/settings";
import { randomUUID } from "node:crypto";
import { Hono } from "hono";
import { linksRouter } from "./domains/links/router";
const app = new Hono().use("*", createHttpTelemetryMiddleware("front"));
const host = process.env.HOST || "0.0.0.0";
const port = Number(process.env.PORT || "3000");
function normalizeBaseUrl(url: string): string {
return url.endsWith("/") ? url.slice(0, -1) : url;
}
function buildFlowExecCtx(): FlowExecCtx {
return { flowId: randomUUID() };
}
function getClientDownloadedApkName(): string {
const filename = settings.clientDownloadedApkName.trim();
return filename.toLowerCase().endsWith(".apk")
? filename
: `${filename}.apk`;
}
app.get("/health", (c) => {
return c.json({ ok: true });
});
@@ -36,110 +18,7 @@ app.get("/ping", (c) => {
return c.text("pong");
});
app.get("/downloads/file/:buildId", async (c) => {
const fctx = buildFlowExecCtx();
const buildId = c.req.param("buildId");
logDomainEvent({
event: "processor.apk_download.started",
fctx,
meta: { buildId },
});
const buildResult = await mobileBuildController.validateActiveBuildId(
fctx,
buildId,
);
if (buildResult.isErr()) {
logDomainEvent({
level: "warn",
event: "processor.apk_download.rejected",
fctx,
error: buildResult.error,
meta: { buildId },
});
return c.json(
{
data: null,
error: { ...buildResult.error, flowId: fctx.flowId },
},
404,
);
}
const build = buildResult.value;
if (!build.apkAssetPath) {
logDomainEvent({
level: "warn",
event: "processor.apk_download.missing_artifact",
fctx,
meta: { buildId },
});
return c.json(
{
data: null,
error: {
flowId: fctx.flowId,
code: "NOT_FOUND",
message: "APK not available",
description: "This build does not have a generated APK yet",
detail: `buildId=${buildId}`,
},
},
404,
);
}
const assetUrl = `${normalizeBaseUrl(settings.appBuilderApiUrl)}${build.apkAssetPath}`;
const assetResponse = await fetch(assetUrl);
if (!assetResponse.ok || !assetResponse.body) {
logDomainEvent({
level: "error",
event: "processor.apk_download.fetch_failed",
fctx,
meta: {
buildId,
assetUrl,
status: assetResponse.status,
},
});
return c.json(
{
data: null,
error: {
flowId: fctx.flowId,
code: "INTERNAL_SERVER_ERROR",
message: "Failed to fetch APK artifact",
description: "Please try again later",
detail: `assetUrl=${assetUrl} status=${assetResponse.status}`,
},
},
502,
);
}
logDomainEvent({
event: "processor.apk_download.succeeded",
fctx,
meta: {
buildId,
assetUrl,
downloadName: getClientDownloadedApkName(),
},
});
return new Response(assetResponse.body, {
status: 200,
headers: {
"content-type":
assetResponse.headers.get("content-type") ||
"application/vnd.android.package-archive",
"content-disposition": `attachment; filename="${getClientDownloadedApkName()}"`,
"cache-control": "no-store",
},
});
});
app.route("/", linksRouter);
serve(
{