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(
{

View File

@@ -27,7 +27,7 @@ function buildStreamUrl(host: string, wsPort: string): string | null {
const baseWss = WS_SCRCPY_URL.replace(/^https:/, "wss:").replace(/^http:/, "ws:");
const wsParam =
`${baseWss}/?action=proxy-adb&remote=tcp:${SCRCPY_SERVER_PORT}&udid=${encodeURIComponent(udid)}`;
`${baseWss}/?action=proxy-adb&remote=${encodeURIComponent(`tcp:${SCRCPY_SERVER_PORT}`)}&udid=${encodeURIComponent(udid)}`;
const hash =
`#!action=stream&udid=${encodeURIComponent(udid)}&player=mse&ws=${encodeURIComponent(wsParam)}`;

View File

@@ -26,10 +26,27 @@ class LinkViewModel {
revokingId = $state<number | null>(null);
showCreateDialog = $state(false);
/**
* SvelteKit's query() caches the RemoteQuery object in an internal query_map.
* Subsequent calls to listLinksSQ() return the same cached Query with stale data.
* We must call refresh() on the cached query to force a fresh server request.
*/
private _linksQuery: ReturnType<typeof listLinksSQ> | null = null;
async fetchLinks() {
this.loading = true;
try {
const result = await listLinksSQ();
const query = listLinksSQ();
// On subsequent calls the query is cached and stale — refresh it
if (query.ready) {
await query.refresh();
}
// After refresh, read .current; on first load, await the initial fetch
const result = query.ready ? query.current : await query;
this._linksQuery = query;
if (result?.error || !result?.data) {
toast.error(
result?.error?.message || "Failed to fetch links",

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { page } from "$app/state";
import Icon from "$lib/components/atoms/icon.svelte";
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
import { Badge } from "$lib/components/ui/badge";
@@ -6,12 +7,11 @@
import * as Card from "$lib/components/ui/card";
import { Separator } from "$lib/components/ui/separator";
import { Skeleton } from "$lib/components/ui/skeleton";
import { mainNavTree } from "$lib/core/constants";
import DeviceForm from "$lib/domains/device/device-form.svelte";
import { mainNavTree, WS_SCRCPY_URL } from "$lib/core/constants";
import { deviceDetailsVM } from "$lib/domains/device/device-details.vm.svelte";
import DeviceForm from "$lib/domains/device/device-form.svelte";
import { deviceVM } from "$lib/domains/device/device.vm.svelte";
import { breadcrumbs } from "$lib/global.stores";
import { page } from "$app/state";
import ExternalLink from "@lucide/svelte/icons/external-link";
import MonitorSmartphone from "@lucide/svelte/icons/monitor-smartphone";
import RefreshCw from "@lucide/svelte/icons/refresh-cw";
@@ -29,11 +29,9 @@
let editWsPort = $state("");
let editIsActive = $state(false);
let editInUse = $state(false);
let editStatus = $state("offline" as
| "online"
| "offline"
| "busy"
| "error");
let editStatus = $state(
"offline" as "online" | "offline" | "busy" | "error",
);
function formatDate(value: Date | string | null | undefined): string {
if (!value) return "—";
@@ -133,7 +131,8 @@
<div class="flex items-center gap-2">
<Icon icon={Smartphone} cls="h-5 w-5 text-primary" />
<Card.Title>
{currentDevice?.title || `Device #${page.params.id}`}
{currentDevice?.title ||
`Device #${page.params.id}`}
</Card.Title>
</div>
@@ -141,7 +140,9 @@
<div
class="text-muted-foreground flex flex-wrap items-center gap-2 text-sm"
>
<Badge variant={statusVariant(currentDevice.status)}>
<Badge
variant={statusVariant(currentDevice.status)}
>
{currentDevice.status}
</Badge>
<span>
@@ -226,7 +227,10 @@
<Card.Root>
<Card.Header>
<div class="flex items-center gap-2">
<Icon icon={Server} cls="h-4 w-4 text-primary" />
<Icon
icon={Server}
cls="h-4 w-4 text-primary"
/>
<Card.Title class="text-base">
Metadata
</Card.Title>
@@ -235,27 +239,45 @@
<Card.Content class="space-y-4">
<div class="grid gap-3 text-sm">
<div>
<p class="text-muted-foreground text-xs">Device ID</p>
<p class="font-medium">{currentDevice.id}</p>
<p class="text-muted-foreground text-xs">
Device ID
</p>
<p class="font-medium">
{currentDevice.id}
</p>
</div>
<div>
<p class="text-muted-foreground text-xs">Host</p>
<p class="text-muted-foreground text-xs">
Host
</p>
<p class="break-all font-mono text-xs">
{currentDevice.host}
</p>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<p class="text-muted-foreground text-xs">WS Port</p>
<p
class="text-muted-foreground text-xs"
>
WS Port
</p>
<p>{currentDevice.wsPort}</p>
</div>
<div>
<p class="text-muted-foreground text-xs">In Use</p>
<p>{currentDevice.inUse ? "Yes" : "No"}</p>
<p
class="text-muted-foreground text-xs"
>
In Use
</p>
<p>
{currentDevice.inUse ? "Yes" : "No"}
</p>
</div>
</div>
<div>
<p class="text-muted-foreground text-xs">Container ID</p>
<p class="text-muted-foreground text-xs">
Container ID
</p>
<p class="break-all font-mono text-xs">
{currentDevice.containerId}
</p>
@@ -267,12 +289,28 @@
<div class="grid gap-3 text-sm">
<div class="grid grid-cols-2 gap-3">
<div>
<p class="text-muted-foreground text-xs">Created</p>
<p class="text-xs">{formatDate(currentDevice.createdAt)}</p>
<p
class="text-muted-foreground text-xs"
>
Created
</p>
<p class="text-xs">
{formatDate(
currentDevice.createdAt,
)}
</p>
</div>
<div>
<p class="text-muted-foreground text-xs">Updated</p>
<p class="text-xs">{formatDate(currentDevice.updatedAt)}</p>
<p
class="text-muted-foreground text-xs"
>
Updated
</p>
<p class="text-xs">
{formatDate(
currentDevice.updatedAt,
)}
</p>
</div>
</div>
<div>
@@ -280,7 +318,8 @@
Viewer URL
</p>
<p class="break-all font-mono text-xs">
{streamUrl || "Missing host configuration"}
{streamUrl ||
"Missing host configuration"}
</p>
</div>
</div>
@@ -290,16 +329,25 @@
<Card.Root>
<Card.Header>
<div class="flex items-center gap-2">
<Icon icon={RefreshCw} cls="h-4 w-4 text-primary" />
<Icon
icon={RefreshCw}
cls="h-4 w-4 text-primary"
/>
<Card.Title class="text-base">
Edit Device
</Card.Title>
</div>
<Card.Description>
Manually update connection details or override
<code class="bg-muted rounded px-1 text-xs">isActive</code>,
<code class="bg-muted rounded px-1 text-xs">inUse</code>, and
<code class="bg-muted rounded px-1 text-xs">status</code> when needed.
<code class="bg-muted rounded px-1 text-xs"
>isActive</code
>,
<code class="bg-muted rounded px-1 text-xs"
>inUse</code
>, and
<code class="bg-muted rounded px-1 text-xs"
>status</code
> when needed.
</Card.Description>
</Card.Header>
<Card.Content>
@@ -335,9 +383,9 @@
Live Device Session
</Card.Title>
</div>
{#if streamUrl}
<div class="flex items-center gap-1">
<a
href={streamUrl}
href={WS_SCRCPY_URL}
target="_blank"
rel="noreferrer"
class={buttonVariants({
@@ -345,10 +393,30 @@
size: "sm",
})}
>
<Icon icon={ExternalLink} cls="mr-1.5 h-3.5 w-3.5" />
Pop out
<Icon
icon={ExternalLink}
cls="mr-1.5 h-3.5 w-3.5"
/>
Device Viewer Home
</a>
{/if}
{#if streamUrl}
<a
href={streamUrl}
target="_blank"
rel="noreferrer"
class={buttonVariants({
variant: "ghost",
size: "sm",
})}
>
<Icon
icon={ExternalLink}
cls="mr-1.5 h-3.5 w-3.5"
/>
Pop out
</a>
{/if}
</div>
</div>
<Card.Description>
Full interactive device access is embedded here so

View File

@@ -0,0 +1,136 @@
import type { FlowExecCtx } from "@pkg/logic/core/flow.execution.context";
import { formatErrorDetail } from "@pkg/logger";
import { ERROR_CODES, errorStatusMap, type Err } from "@pkg/result";
import { settings } from "@pkg/settings";
import { randomUUID } from "node:crypto";
import type { MiddlewareHandler } from "hono";
import type { ContentfulStatusCode } from "hono/utils/http-status";
import * as v from "valibot";
export type AppBindings = {
Variables: {
fctx: FlowExecCtx;
};
};
export function buildFlowExecCtx(flowId?: string): FlowExecCtx {
return { flowId: flowId || randomUUID() };
}
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;
}
export function parseDeviceId(rawId: string, fctx: FlowExecCtx): number | Err {
const parsed = Number(rawId);
if (!Number.isInteger(parsed) || parsed <= 0) {
return createAppError(
fctx,
ERROR_CODES.VALIDATION_ERROR,
"Invalid device id",
"The provided device id is not valid",
`deviceId=${rawId}`,
true,
);
}
return parsed;
}
export async function parseJsonBody<TInput, TSchema extends v.GenericSchema<TInput>>(
request: Request,
schema: TSchema,
fctx: FlowExecCtx,
): Promise<TInput | Err> {
let payload: unknown;
try {
payload = await request.json();
} catch (error) {
return createAppError(
fctx,
ERROR_CODES.VALIDATION_ERROR,
"Invalid JSON body",
"The request body must be valid JSON",
formatErrorDetail(error),
true,
);
}
const parsed = v.safeParse(schema, payload);
if (!parsed.success) {
return createAppError(
fctx,
ERROR_CODES.VALIDATION_ERROR,
"Invalid request body",
"The request payload did not match the expected shape",
parsed.issues.map((issue) => issue.message).join("; "),
true,
);
}
return parsed.output;
}
export const requireInternalApiKey: MiddlewareHandler<AppBindings> = async (
c,
next,
) => {
const fctx = buildFlowExecCtx(c.req.header("x-flow-id"));
c.set("fctx", fctx);
const apiKey = c.req.header("x-internal-api-key");
if (apiKey !== settings.internalApiKey) {
const error = createAppError(
fctx,
ERROR_CODES.AUTH_ERROR,
"Unauthorized",
"The internal API key is invalid",
"Missing or invalid x-internal-api-key header",
true,
);
const response = jsonError(error, 403);
return c.json(response.body, toStatusCode(response.status));
}
await next();
};

View File

@@ -0,0 +1,57 @@
import type { Device } from "@pkg/logic/domains/device/data";
import { formatErrorDetail } from "@pkg/logger";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
export type InternalAction = "start" | "stop" | "restart";
const execFileAsync = promisify(execFile);
export class AndroidCommandExecutor {
constructor(
private readonly binary: string,
private readonly timeoutMs: number,
) {}
async prepareAssignedApp(device: Device, packageName: string) {
const serial = `${device.host}:${device.wsPort}`;
await this.run(["connect", serial]);
await this.run(["-s", serial, "wait-for-device"]);
await this.run(
["-s", serial, "shell", "am", "force-stop", packageName],
{ allowFailure: true },
);
await this.run([
"-s",
serial,
"shell",
"monkey",
"-p",
packageName,
"-c",
"android.intent.category.LAUNCHER",
"1",
]);
return { serial };
}
async disconnect(device: Device) {
const serial = `${device.host}:${device.wsPort}`;
await this.run(["disconnect", serial], { allowFailure: true });
}
private async run(args: string[], options?: { allowFailure?: boolean }) {
try {
return await execFileAsync(this.binary, args, {
timeout: this.timeoutMs,
});
} catch (error) {
if (options?.allowFailure) {
return { stdout: "", stderr: formatErrorDetail(error) };
}
throw error;
}
}
}

View File

@@ -0,0 +1,74 @@
import { Hono } from "hono";
import type { AppBindings } from "../../core/utils";
import { jsonError, parseDeviceId, toStatusCode } from "../../core/utils";
import {
getDeviceById,
handleContainerAction,
listDevices,
} from "./service";
export const commandRouter = new Hono<AppBindings>()
.get("/devices", async (c) => {
const fctx = c.get("fctx");
const result = await listDevices(fctx);
if (result.isErr()) {
const response = jsonError(result.error);
return c.json(response.body, toStatusCode(response.status));
}
return c.json({ data: result.value, error: null });
})
.get("/devices/:id", async (c) => {
const fctx = c.get("fctx");
const parsedId = parseDeviceId(c.req.param("id"), fctx);
if (typeof parsedId !== "number") {
const response = jsonError(parsedId);
return c.json(response.body, toStatusCode(response.status));
}
const result = await getDeviceById(fctx, parsedId);
if (result.isErr()) {
const response = jsonError(result.error);
return c.json(response.body, toStatusCode(response.status));
}
return c.json({ data: result.value, error: null });
})
.post("/devices/:id/start", async (c) => {
const fctx = c.get("fctx");
const parsedId = parseDeviceId(c.req.param("id"), fctx);
if (typeof parsedId !== "number") {
const response = jsonError(parsedId);
return c.json(response.body, toStatusCode(response.status));
}
const response = await handleContainerAction("start", parsedId, fctx);
return c.json(response.body, toStatusCode(response.status));
})
.post("/devices/:id/stop", async (c) => {
const fctx = c.get("fctx");
const parsedId = parseDeviceId(c.req.param("id"), fctx);
if (typeof parsedId !== "number") {
const response = jsonError(parsedId);
return c.json(response.body, toStatusCode(response.status));
}
const response = await handleContainerAction("stop", parsedId, fctx);
return c.json(response.body, toStatusCode(response.status));
})
.post("/devices/:id/restart", async (c) => {
const fctx = c.get("fctx");
const parsedId = parseDeviceId(c.req.param("id"), fctx);
if (typeof parsedId !== "number") {
const response = jsonError(parsedId);
return c.json(response.body, toStatusCode(response.status));
}
const response = await handleContainerAction("restart", parsedId, fctx);
return c.json(response.body, toStatusCode(response.status));
});

View File

@@ -0,0 +1,64 @@
import { getDeviceController } from "@pkg/logic/domains/device/controller";
import type { FlowExecCtx } from "@pkg/logic/core/flow.execution.context";
import type { Device } from "@pkg/logic/domains/device/data";
import { createAppError, jsonError } from "../../core/utils";
import { AndroidCommandExecutor } from "./executor";
import { logDomainEvent } from "@pkg/logger";
import { ERROR_CODES } from "@pkg/result";
export type InternalAction = "start" | "stop" | "restart";
const deviceController = getDeviceController();
const adbBinary = process.env.ADB_BIN || "adb";
const adbTimeoutMs = Number(process.env.ADB_TIMEOUT_MS || "15000");
const androidExecutor = new AndroidCommandExecutor(adbBinary, adbTimeoutMs);
export async function handleContainerAction(
action: InternalAction,
deviceId: number,
fctx: FlowExecCtx,
) {
const deviceResult = await deviceController.getById(fctx, deviceId);
if (deviceResult.isErr()) {
return jsonError(deviceResult.error);
}
logDomainEvent({
level: "warn",
event: "orchestrator.device_container.stubbed",
fctx,
meta: {
action,
deviceId,
containerId: deviceResult.value.containerId,
},
});
return jsonError(
createAppError(
fctx,
ERROR_CODES.NOT_IMPLEMENTED,
"Container action not implemented",
"This container control endpoint is currently a stub",
`action=${action} deviceId=${deviceId}`,
),
501,
);
}
export async function listDevices(fctx: FlowExecCtx) {
return deviceController.list(fctx);
}
export async function getDeviceById(fctx: FlowExecCtx, deviceId: number) {
return deviceController.getById(fctx, deviceId);
}
export async function prepareAssignedApp(device: Device, packageName: string) {
return androidExecutor.prepareAssignedApp(device, packageName);
}
export async function disconnectDevice(device: Device) {
return androidExecutor.disconnect(device);
}

View File

@@ -0,0 +1,9 @@
import * as v from "valibot";
export const sessionPrepareSchema = v.object({
deviceId: v.pipe(v.number(), v.integer()),
packageName: v.pipe(v.string(), v.minLength(1)),
linkToken: v.optional(v.pipe(v.string(), v.minLength(1))),
});
export type SessionPrepareInput = v.InferOutput<typeof sessionPrepareSchema>;

View File

@@ -0,0 +1,30 @@
import { Hono } from "hono";
import type { AppBindings } from "../../core/utils";
import {
isErrPayload,
jsonError,
parseJsonBody,
toStatusCode,
} from "../../core/utils";
import { type SessionPrepareInput, sessionPrepareSchema } from "./data";
import { prepareSession } from "./service";
export const orchestrateRouter = new Hono<AppBindings>().post(
"/sessions/prepare",
async (c) => {
const fctx = c.get("fctx");
const body = await parseJsonBody<SessionPrepareInput, typeof sessionPrepareSchema>(
c.req.raw,
sessionPrepareSchema,
fctx,
);
if (isErrPayload(body)) {
const response = jsonError(body);
return c.json(response.body, toStatusCode(response.status));
}
const response = await prepareSession(fctx, body);
return c.json(response.body, toStatusCode(response.status));
},
);

View File

@@ -0,0 +1,134 @@
import type { FlowExecCtx } from "@pkg/logic/core/flow.execution.context";
import { formatErrorDetail, logDomainEvent } from "@pkg/logger";
import { createAppError, jsonError } from "../../core/utils";
import type { SessionPrepareInput } from "./data";
import { ERROR_CODES } from "@pkg/result";
import { getDeviceController } from "@pkg/logic/domains/device/controller";
import { disconnectDevice, prepareAssignedApp } from "../command/service";
const deviceController = getDeviceController();
export async function prepareSession(
fctx: FlowExecCtx,
input: SessionPrepareInput,
) {
logDomainEvent({
event: "orchestrator.session_prepare.started",
fctx,
meta: {
deviceId: input.deviceId,
packageName: input.packageName,
linkToken: input.linkToken,
},
});
const deviceResult = await deviceController.getById(fctx, input.deviceId);
if (deviceResult.isErr()) {
logDomainEvent({
level: "warn",
event: "orchestrator.session_prepare.device_lookup_failed",
fctx,
error: deviceResult.error,
meta: {
deviceId: input.deviceId,
},
});
return jsonError(deviceResult.error);
}
const allocatedDevice = await deviceController.allocate(
fctx,
input.deviceId,
);
if (allocatedDevice.isErr()) {
logDomainEvent({
level: "warn",
event: "orchestrator.session_prepare.device_unavailable",
fctx,
error: allocatedDevice.error,
meta: {
deviceId: input.deviceId,
},
});
return jsonError(allocatedDevice.error);
}
let releaseRequired = true;
try {
const prepResult = await prepareAssignedApp(
allocatedDevice.value,
input.packageName,
);
logDomainEvent({
event: "orchestrator.session_prepare.succeeded",
fctx,
meta: {
deviceId: input.deviceId,
packageName: input.packageName,
serial: prepResult.serial,
},
});
releaseRequired = false;
return {
body: {
data: {
deviceId: input.deviceId,
containerId: allocatedDevice.value.containerId,
packageName: input.packageName,
serial: prepResult.serial,
status: "prepared",
},
error: null,
},
status: 200,
};
} catch (error) {
logDomainEvent({
level: "error",
event: "orchestrator.session_prepare.failed",
fctx,
error,
meta: {
deviceId: input.deviceId,
packageName: input.packageName,
},
});
return jsonError(
createAppError(
fctx,
ERROR_CODES.EXTERNAL_SERVICE_ERROR,
"Failed to prepare Android session",
"The orchestrator could not connect to the Android device or launch the app",
formatErrorDetail(error),
),
502,
);
} finally {
await disconnectDevice(allocatedDevice.value);
if (releaseRequired) {
const releaseResult = await deviceController.release(
fctx,
input.deviceId,
);
if (releaseResult.isErr()) {
logDomainEvent({
level: "error",
event: "orchestrator.session_prepare.release_failed",
fctx,
error: releaseResult.error,
meta: {
deviceId: input.deviceId,
},
});
}
}
}
}

View File

@@ -3,8 +3,15 @@ import "./instrumentation.js";
import { createHttpTelemetryMiddleware } from "@pkg/logic/core/http.telemetry";
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import type { AppBindings } from "./core/utils";
import { requireInternalApiKey } from "./core/utils";
import { commandRouter } from "./domains/command/router";
import { orchestrateRouter } from "./domains/orchestrate/router";
const app = new Hono().use("*", createHttpTelemetryMiddleware("orchestrator"));
const app = new Hono<AppBindings>().use(
"*",
createHttpTelemetryMiddleware("orchestrator"),
);
const host = process.env.HOST || "0.0.0.0";
const port = Number(process.env.PORT || "3000");
@@ -17,6 +24,10 @@ app.get("/ping", (c) => {
return c.text("pong");
});
app.use("/internal/*", requireInternalApiKey);
app.route("/internal", commandRouter);
app.route("/internal", orchestrateRouter);
serve(
{
fetch: app.fetch,