poller and release on end to frontend
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { resolveLinkSQ } from "./link.remote";
|
||||
import { checkLinkValiditySQ, resolveLinkSQ } from "./link.remote";
|
||||
import type { Err } from "@pkg/result";
|
||||
|
||||
export type SessionEndReason =
|
||||
@@ -22,6 +22,17 @@ export async function resolveLinkFresh(token: string) {
|
||||
return await query;
|
||||
}
|
||||
|
||||
export async function checkLinkValidityFresh(token: string) {
|
||||
const query = checkLinkValiditySQ({ token });
|
||||
|
||||
if (query.ready) {
|
||||
await query.refresh();
|
||||
return query.current;
|
||||
}
|
||||
|
||||
return await query;
|
||||
}
|
||||
|
||||
export function mapLinkErrorToSessionEndReason(
|
||||
error?: Pick<Err, "code" | "message" | "description"> | null,
|
||||
): SessionEndReason {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { getFlowExecCtxForRemoteFuncs } from "$lib/core/server.utils";
|
||||
import { command, getRequestEvent, query } from "$app/server";
|
||||
import { prepareLinkFlow, resolveLinkFlow } from "./service";
|
||||
import {
|
||||
checkLinkValidityFlow,
|
||||
endLinkSessionFlow,
|
||||
prepareLinkFlow,
|
||||
resolveLinkFlow,
|
||||
} from "./service";
|
||||
import { tokenSchema } from "./data";
|
||||
|
||||
export const resolveLinkSQ = query(tokenSchema, async ({ token }) => {
|
||||
@@ -14,3 +19,15 @@ export const prepareLinkSC = command(tokenSchema, async ({ token }) => {
|
||||
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
|
||||
return prepareLinkFlow(fctx, token);
|
||||
});
|
||||
|
||||
export const checkLinkValiditySQ = query(tokenSchema, async ({ token }) => {
|
||||
const event = getRequestEvent();
|
||||
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
|
||||
return checkLinkValidityFlow(fctx, token);
|
||||
});
|
||||
|
||||
export const endLinkSessionSC = command(tokenSchema, async ({ token }) => {
|
||||
const event = getRequestEvent();
|
||||
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
|
||||
return endLinkSessionFlow(fctx, token);
|
||||
});
|
||||
|
||||
@@ -13,8 +13,10 @@ import { DeviceStatus } from "@pkg/logic/domains/device/data";
|
||||
import { ERROR_CODES, type Err } from "@pkg/result";
|
||||
import { settings } from "@pkg/settings";
|
||||
import { WS_SCRCPY_URL } from "$lib/core/constants";
|
||||
import { getDeviceController } from "@pkg/logic/domains/device/controller";
|
||||
|
||||
const lc = getLinkController();
|
||||
const dc = getDeviceController();
|
||||
|
||||
type LinkSessionShape = {
|
||||
id: number;
|
||||
@@ -243,3 +245,97 @@ export async function prepareLinkFlow(fctx: FlowExecCtx, token: string) {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkLinkValidityFlow(fctx: FlowExecCtx, token: string) {
|
||||
logDomainEvent({
|
||||
event: "front.link_validity_check.started",
|
||||
fctx,
|
||||
meta: { token },
|
||||
});
|
||||
|
||||
const linkResult = await lc.validateReadOnly(fctx, token);
|
||||
if (linkResult.isErr()) {
|
||||
logDomainEvent({
|
||||
level: "warn",
|
||||
event: "front.link_validity_check.rejected",
|
||||
fctx,
|
||||
error: linkResult.error,
|
||||
meta: { token },
|
||||
});
|
||||
|
||||
return { data: null, error: linkResult.error };
|
||||
}
|
||||
|
||||
const link = linkResult.value;
|
||||
const streamUrl = link.device
|
||||
? buildStreamUrl(link.device.host, link.device.wsPort)
|
||||
: null;
|
||||
|
||||
return {
|
||||
data: {
|
||||
link: {
|
||||
id: link.id,
|
||||
token: link.token,
|
||||
status: link.status,
|
||||
expiresAt: link.expiresAt,
|
||||
},
|
||||
device: link.device
|
||||
? {
|
||||
id: link.device.id,
|
||||
streamUrl,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function endLinkSessionFlow(fctx: FlowExecCtx, token: string) {
|
||||
logDomainEvent({
|
||||
event: "front.link_session_end.started",
|
||||
fctx,
|
||||
meta: { token },
|
||||
});
|
||||
|
||||
const linkResult = await lc.getByToken(fctx, token);
|
||||
if (linkResult.isErr()) {
|
||||
logDomainEvent({
|
||||
level: "warn",
|
||||
event: "front.link_session_end.link_lookup_failed",
|
||||
fctx,
|
||||
error: linkResult.error,
|
||||
meta: { token },
|
||||
});
|
||||
|
||||
return { data: null, error: linkResult.error };
|
||||
}
|
||||
|
||||
const linkedDeviceId = linkResult.value.linkedDeviceId;
|
||||
if (linkedDeviceId === null) {
|
||||
return {
|
||||
data: { released: false, reason: "no_linked_device" as const },
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
const releaseResult = await dc.release(fctx, linkedDeviceId);
|
||||
if (releaseResult.isErr()) {
|
||||
logDomainEvent({
|
||||
level: "error",
|
||||
event: "front.link_session_end.release_failed",
|
||||
fctx,
|
||||
error: releaseResult.error,
|
||||
meta: {
|
||||
token,
|
||||
deviceId: linkedDeviceId,
|
||||
},
|
||||
});
|
||||
|
||||
return { data: null, error: releaseResult.error };
|
||||
}
|
||||
|
||||
return {
|
||||
data: { released: true, deviceId: linkedDeviceId },
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import {
|
||||
checkLinkValidityFresh,
|
||||
mapLinkErrorToSessionEndReason,
|
||||
resolveLinkFresh,
|
||||
} from "$lib/domains/link/client";
|
||||
import { endLinkSessionSC } from "$lib/domains/link/link.remote";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
@@ -16,14 +17,26 @@
|
||||
|
||||
onMount(() => {
|
||||
let disposed = false;
|
||||
let hasEnded = false;
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let inFlight = false;
|
||||
let consecutivePollErrors = 0;
|
||||
|
||||
const endSession = async (reason: string) => {
|
||||
if (disposed) return;
|
||||
if (disposed || hasEnded) return;
|
||||
hasEnded = true;
|
||||
|
||||
if (timer) clearInterval(timer);
|
||||
streamUrl = null;
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
await endLinkSessionSC({ token });
|
||||
} catch {
|
||||
// Best-effort cleanup; user ejection should proceed regardless.
|
||||
}
|
||||
}
|
||||
|
||||
await goto(`/session-ended?reason=${encodeURIComponent(reason)}`, {
|
||||
replaceState: true,
|
||||
});
|
||||
@@ -34,7 +47,7 @@
|
||||
inFlight = true;
|
||||
|
||||
try {
|
||||
const result = await resolveLinkFresh(token);
|
||||
const result = await checkLinkValidityFresh(token);
|
||||
|
||||
if (disposed) return;
|
||||
|
||||
@@ -52,7 +65,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const nextStreamUrl = result.data.device.streamUrl;
|
||||
const nextStreamUrl = result.data.device?.streamUrl;
|
||||
if (typeof nextStreamUrl === "string" && nextStreamUrl.length > 0) {
|
||||
streamUrl = nextStreamUrl;
|
||||
}
|
||||
@@ -79,13 +92,13 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const initial = await resolveLinkFresh(token);
|
||||
const initial = await checkLinkValidityFresh(token);
|
||||
if (initial?.error || !initial?.data) {
|
||||
await endSession(mapLinkErrorToSessionEndReason(initial?.error));
|
||||
return;
|
||||
}
|
||||
|
||||
streamUrl = initial.data.device.streamUrl;
|
||||
streamUrl = initial.data.device?.streamUrl ?? null;
|
||||
state = "ready";
|
||||
statusText = "Session active";
|
||||
|
||||
@@ -100,6 +113,9 @@
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (timer) clearInterval(timer);
|
||||
if (!hasEnded && token) {
|
||||
void endLinkSessionSC({ token });
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { getLinkController } from "@pkg/logic/domains/link/controller";
|
||||
import { createLinkSchema, updateLinkSchema } from "@pkg/logic/domains/link/data";
|
||||
import { getDeviceController } from "@pkg/logic/domains/device/controller";
|
||||
import { getFlowExecCtxForRemoteFuncs, unauthorized } from "$lib/core/server.utils";
|
||||
import { command, getRequestEvent, query } from "$app/server";
|
||||
import * as v from "valibot";
|
||||
|
||||
const lc = getLinkController();
|
||||
const dc = getDeviceController();
|
||||
|
||||
export const listLinksSQ = query(async () => {
|
||||
const event = getRequestEvent();
|
||||
@@ -66,10 +68,25 @@ export const revokeLinkSC = command(
|
||||
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
|
||||
if (!fctx.userId) return unauthorized(fctx);
|
||||
|
||||
const res = await lc.revoke(fctx, payload.id);
|
||||
return res.isOk()
|
||||
? { data: res.value, error: null }
|
||||
: { data: null, error: res.error };
|
||||
const linkResult = await lc.getById(fctx, payload.id);
|
||||
if (linkResult.isErr()) {
|
||||
return { data: null, error: linkResult.error };
|
||||
}
|
||||
|
||||
const revokeResult = await lc.revoke(fctx, payload.id);
|
||||
if (revokeResult.isErr()) {
|
||||
return { data: null, error: revokeResult.error };
|
||||
}
|
||||
|
||||
const linkedDeviceId = linkResult.value.linkedDeviceId;
|
||||
if (linkedDeviceId !== null) {
|
||||
const releaseResult = await dc.release(fctx, linkedDeviceId);
|
||||
if (releaseResult.isErr()) {
|
||||
return { data: null, error: releaseResult.error };
|
||||
}
|
||||
}
|
||||
|
||||
return { data: revokeResult.value, error: null };
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -95,3 +95,10 @@ Update rule:
|
||||
|
||||
- Added `adb` installation to `dockerfiles/orchestrator.Dockerfile` so session-prepare no longer fails with `spawn adb ENOENT` in production containers.
|
||||
- Kept runtime behavior unchanged otherwise; this is strictly a missing binary/runtime dependency fix.
|
||||
|
||||
### 14 — Session Safety: No-Touch Polling + Release Hooks
|
||||
|
||||
- Added read-only link validation in logic (`validateReadOnly`) and wired a dedicated frontend validity query for high-frequency polling without updating `lastAccessedAt`.
|
||||
- Updated frontend session polling to use the new no-touch validity path and avoid stale query cache via forced refresh behavior.
|
||||
- Added frontend session end command to explicitly release the linked device on session teardown/ejection.
|
||||
- Updated admin link revoke flow to release the linked device after revocation so revoked sessions do not leave devices stuck `inUse`.
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
UpdateLink,
|
||||
} from "./data";
|
||||
import { FlowExecCtx } from "@core/flow.execution.context";
|
||||
import { errAsync, ResultAsync } from "neverthrow";
|
||||
import { errAsync, okAsync, ResultAsync } from "neverthrow";
|
||||
import { LinkRepository } from "./repository";
|
||||
import { type Err } from "@pkg/result";
|
||||
import { linkErrors } from "./errors";
|
||||
@@ -54,6 +54,25 @@ export class LinkController {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a token without updating lastAccessedAt.
|
||||
* Useful for high-frequency polling paths.
|
||||
*/
|
||||
validateReadOnly(
|
||||
fctx: FlowExecCtx,
|
||||
token: string,
|
||||
): ResultAsync<LinkWithDevice, Err> {
|
||||
return this.repo.getByToken(fctx, token).andThen((l) => {
|
||||
if (l.status !== LinkStatus.ACTIVE) {
|
||||
return errAsync(linkErrors.linkNotActive(fctx, token));
|
||||
}
|
||||
if (l.expiresAt && l.expiresAt < new Date()) {
|
||||
return errAsync(linkErrors.linkExpired(fctx, token));
|
||||
}
|
||||
return okAsync(l);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new link. Token is auto-generated as a URL-safe nanoid.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user