Something went wrong. Try again.
APNs relay for Prefect for iOS: pushes a flow run's pause to your phone
Something went wrong. Try again.
5.3 kB · 142 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143import { digest, type Pause } from "./context.ts";import type { Run } from "./prefect.ts";
/** * What the app's live activity draws. Times are epoch milliseconds: the props * cross to the widget extension as a JSON string. Keep in step with * `WaitingProps` in prefect-ios (src/push/waitingActivity.tsx). */export type WaitingProps = { runId: string; serverId: string; flow: string; run: string; ask: string; since: number; /** when the pause times out, or 0 when the run did not say */ until: number; input: boolean; /** the state the run moved to once it stopped waiting, else "" */ ended: string;};
/** expo-widgets' one ActivityAttributes type, and the name the app registered its layout under. */export const ATTRIBUTES_TYPE = "LiveActivityAttributes";export const ACTIVITY_NAME = "WaitingRun";
/** Notification categories the app registers: all open the run; `waiting` resumes with a tap, `waiting-reply` sends what was typed. */export const CATEGORY = { resume: "waiting", input: "waiting-input", reply: "waiting-reply" } as const;
function clip(text: string, max: number): string { const flat = text.replace(/\s+/g, " ").trim(); return flat.length > max ? `${flat.slice(0, max - 1).trimEnd()}…` : flat;}
function millis(iso: string | null): number { const t = iso ? Date.parse(iso) : NaN; return Number.isFinite(t) ? t : 0;}
export type Waiting = { run: Run; flow: string | null; pause: Pause };
/** One sentence for what the run wants: the same headline its waiting card leads with. */export function askLine({ pause }: Waiting): string { return clip(pause.headline, 160);}
/** What the run made for this pause, then what it asks, as a notification can carry them. */export function alertBody({ run, pause }: Waiting): string { const question = pause.question && pause.question.text !== pause.headline ? clip(pause.question.text, 110) : ""; const made = pause.body ? clip(digest(pause.body), 230 - question.length) : ""; return [made, question].filter(Boolean).join(" ") || run.name;}
export function categoryOf({ run, pause }: Waiting): string { return pause.question ? CATEGORY.reply : run.wantsInput ? CATEGORY.input : CATEGORY.resume;}
export function waitingProps(waiting: Waiting, serverId: string, now: number): WaitingProps { const { run, flow } = waiting; return { runId: run.id, serverId, flow: flow ?? run.name, run: run.name, ask: askLine(waiting), since: millis(run.stateTime) || now, until: millis(run.pauseTimeout), input: run.wantsInput, ended: "", };}
export function runUrl(runId: string, serverId: string): string { return `prefect://run/${runId}?server=${encodeURIComponent(serverId)}`;}
/** `minutesLeft` makes it the reminder sent shortly before the pause times out. */export function alertPayload(waiting: Waiting, serverId: string, minutesLeft?: number): object { const { run, flow } = waiting; const body = minutesLeft === undefined ? alertBody(waiting) : `Times out in ${minutesLeft} min. ${alertBody(waiting)}`; return { aps: { alert: { title: askLine(waiting), subtitle: flow ?? run.name, body }, sound: "default", category: categoryOf(waiting), "thread-id": run.id, }, // expo-notifications hands `body` to the app as the notification's data body: { runId: run.id, serverId, url: runUrl(run.id, serverId) }, };}
function contentState(props: WaitingProps) { return { name: ACTIVITY_NAME, props: JSON.stringify(props) };}
export function startPayload(waiting: Waiting, serverId: string, now: number): object { const props = waitingProps(waiting, serverId, now); return { aps: { timestamp: Math.floor(now / 1000), event: "start", "attributes-type": ATTRIBUTES_TYPE, attributes: { url: runUrl(props.runId, serverId) }, "content-state": contentState(props), ...(props.until ? { "stale-date": Math.floor(props.until / 1000) } : {}), alert: { title: props.flow, body: props.ask }, }, };}
/** Ends a run's activity, leaving its last state on the lock screen for a moment. */export function endPayload(props: WaitingProps, now: number): object { return { aps: { timestamp: Math.floor(now / 1000), event: "end", "dismissal-date": Math.floor(now / 1000) + 60, "content-state": contentState(props), }, };}
export function testPayload(): object { return { aps: { alert: { title: "Prefect", body: "Notifications are on for this server." }, sound: "default" } };}
/** Reads back props the relay stored with a wait. */export function parseProps(json: string): WaitingProps | null { let v: unknown; try { v = JSON.parse(json); } catch { return null; } if (typeof v !== "object" || v === null) return null; const p = v as Record<string, unknown>; const { runId, serverId, flow, run, ask, since, until, input, ended } = p; if (typeof runId !== "string" || typeof serverId !== "string" || typeof flow !== "string" || typeof run !== "string" || typeof ask !== "string") return null; if (typeof since !== "number" || typeof until !== "number" || typeof input !== "boolean" || typeof ended !== "string") return null; return { runId, serverId, flow, run, ask, since, until, input, ended };}