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:
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* Mobile Proxy Test — Bun Reverse Proxy Server
|
||||
*
|
||||
|
||||
54
apps/front/src/core/utils.ts
Normal file
54
apps/front/src/core/utils.ts
Normal 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;
|
||||
}
|
||||
13
apps/front/src/domains/links/router.ts
Normal file
13
apps/front/src/domains/links/router.ts
Normal 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));
|
||||
});
|
||||
297
apps/front/src/domains/links/service.ts
Normal file
297
apps/front/src/domains/links/service.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user