diff --git a/.dockerignore b/.dockerignore index 09292fa2..ed763eb8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,6 +11,7 @@ !**/components.json !app/** +!apps/agent/** !packages/** !public/** diff --git a/Dockerfile b/Dockerfile index e2be2862..ee1bd691 100644 --- a/Dockerfile +++ b/Dockerfile @@ -64,6 +64,8 @@ COPY --from=deps /deps/shoutrrr /usr/local/bin/shoutrrr COPY ./package.json ./bun.lock ./ COPY ./packages/core/package.json ./packages/core/package.json +COPY ./packages/contracts/package.json ./packages/contracts/package.json +COPY ./apps/agent/package.json ./apps/agent/package.json RUN bun install --frozen-lockfile --ignore-scripts @@ -86,6 +88,8 @@ WORKDIR /app COPY ./package.json ./bun.lock ./ COPY ./packages/core/package.json ./packages/core/package.json +COPY ./packages/contracts/package.json ./packages/contracts/package.json +COPY ./apps/agent/package.json ./apps/agent/package.json RUN bun install --frozen-lockfile COPY . . diff --git a/app/server/modules/agents/__tests__/agents-manager.test.ts b/app/server/modules/agents/__tests__/agents-manager.test.ts deleted file mode 100644 index c8dc4af6..00000000 --- a/app/server/modules/agents/__tests__/agents-manager.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { afterEach, describe, expect, mock, test } from "bun:test"; - -const loadAgentsManagerModule = async () => { - const moduleUrl = new URL("../agents-manager.ts", import.meta.url); - moduleUrl.searchParams.set("test", crypto.randomUUID()); - return import(moduleUrl.href); -}; - -afterEach(() => { - delete (globalThis as Record).__agentManager; - mock.restore(); -}); - -describe("agents-manager module", () => { - test("reuses the existing global agent manager without stopping it", async () => { - const existingManager = { - start: mock(() => {}), - sendBackup: mock(() => false), - cancelBackup: mock(() => false), - setBackupEventHandlers: mock(() => {}), - getBackupEventHandlers: mock(() => ({})), - stop: mock(() => {}), - }; - - (globalThis as Record).__agentManager = existingManager; - - const { agentManager } = await loadAgentsManagerModule(); - - expect(agentManager).toBe(existingManager); - expect(existingManager.stop).not.toHaveBeenCalled(); - expect((globalThis as Record).__agentManager).toBe(existingManager); - }); -}); diff --git a/app/server/modules/agents/agents-manager.ts b/app/server/modules/agents/agents-manager.ts index 67ee95ce..21a5efbb 100644 --- a/app/server/modules/agents/agents-manager.ts +++ b/app/server/modules/agents/agents-manager.ts @@ -14,14 +14,11 @@ import type { } from "@zerobyte/contracts/agent-protocol"; import { config } from "../../core/config"; import { validateAgentToken, deriveLocalAgentToken } from "./agent-tokens"; -import { createControllerAgentSession, type ControllerAgentSession } from "./controller-agent-session"; - -type AgentConnectionData = { - id: string; - agentId: string; - organizationId: string | null; - agentName: string; -}; +import { + createControllerAgentSession, + type AgentConnectionData, + type ControllerAgentSession, +} from "./controller-agent-session"; type AgentBackupEventContext = { agentId: string; @@ -42,12 +39,38 @@ export type AgentBackupEventHandlers = { onBackupCancelled?: (context: AgentBackupEventContext & { payload: BackupCancelledPayload }) => void; }; -export const spawnLocalAgent = async () => { - const previousAgent = (globalThis as Record).__localAgent as ChildProcess | undefined; - if (previousAgent) { - previousAgent.kill(); +type AgentManagerRuntime = ReturnType; +type AgentRuntimeState = { + agentManager: AgentManagerRuntime; + localAgent: ChildProcess | null; +}; + +type ProcessWithAgentRuntime = NodeJS.Process & { + __zerobyteAgentRuntime?: AgentRuntimeState; +}; + +const getAgentRuntimeState = () => { + const runtimeProcess = process as ProcessWithAgentRuntime; + const existingRuntime = runtimeProcess.__zerobyteAgentRuntime; + + if (existingRuntime) { + return existingRuntime; } + const runtime = { + agentManager: createAgentManagerRuntime(), + localAgent: null, + }; + + runtimeProcess.__zerobyteAgentRuntime = runtime; + return runtime; +}; + +const getAgentManagerRuntime = () => getAgentRuntimeState().agentManager; + +export const spawnLocalAgent = async () => { + await stopLocalAgent(); + const sourceEntryPoint = path.join(process.cwd(), "apps", "agent", "src", "index.ts"); const productionEntryPoint = path.join(process.cwd(), ".output", "agent", "index.mjs"); @@ -59,7 +82,8 @@ export const spawnLocalAgent = async () => { const agentToken = await deriveLocalAgentToken(); const args = config.__prod__ ? ["run", agentEntryPoint] : ["run", "--watch", agentEntryPoint]; - const localAgent = spawn("bun", args, { + const runtime = getAgentRuntimeState(); + const agentProcess = spawn("bun", args, { env: { ...process.env, ZEROBYTE_CONTROLLER_URL: "ws://localhost:3001", @@ -68,23 +92,49 @@ export const spawnLocalAgent = async () => { stdio: ["ignore", "pipe", "pipe"], }); - (globalThis as Record).__localAgent = localAgent; + runtime.localAgent = agentProcess; - localAgent.stdout?.on("data", (data: Buffer) => { + agentProcess.stdout?.on("data", (data: Buffer) => { const line = data.toString().trim(); if (line) logger.info(`[agent] ${line}`); }); - localAgent.stderr?.on("data", (data: Buffer) => { + agentProcess.stderr?.on("data", (data: Buffer) => { const line = data.toString().trim(); if (line) logger.error(`[agent] ${line}`); }); - localAgent.on("exit", (code, signal) => { + agentProcess.on("exit", (code, signal) => { + if (runtime.localAgent === agentProcess) { + runtime.localAgent = null; + } logger.info(`Agent process exited with code ${code} and signal ${signal}`); }); }; +export const stopLocalAgent = async () => { + const runtime = getAgentRuntimeState(); + if (!runtime.localAgent) { + return; + } + + const agentProcess = runtime.localAgent; + runtime.localAgent = null; + + if (agentProcess.exitCode !== null || agentProcess.signalCode !== null) { + return; + } + + const exited = new Promise((resolve) => { + agentProcess.once("exit", () => { + resolve(); + }); + }); + + agentProcess.kill(); + await exited; +}; + const createAgentManagerRuntime = () => { const sessionsRef = Effect.runSync(Ref.make>(new Map())); const backupHandlersRef = Effect.runSync(Ref.make({})); @@ -290,9 +340,9 @@ const createAgentManagerRuntime = () => { }; }; -const previous = (globalThis as Record).__agentManager as - | ReturnType - | undefined; +export const agentManager = getAgentManagerRuntime(); -export const agentManager = previous ?? createAgentManagerRuntime(); -(globalThis as Record).__agentManager = agentManager; +export const stopAgentRuntime = async () => { + getAgentManagerRuntime().stop(); + await stopLocalAgent(); +}; diff --git a/app/server/modules/agents/controller-agent-session.ts b/app/server/modules/agents/controller-agent-session.ts index 7c8f0479..ad2d67bf 100644 --- a/app/server/modules/agents/controller-agent-session.ts +++ b/app/server/modules/agents/controller-agent-session.ts @@ -15,7 +15,7 @@ import { import { logger } from "@zerobyte/core/node"; import { toMessage } from "@zerobyte/core/utils"; -type AgentConnectionData = { +export type AgentConnectionData = { id: string; agentId: string; organizationId: string | null; @@ -123,14 +123,15 @@ export const createControllerAgentSession = ( ); const handleAgentMessage = (message: AgentMessage) => { + updateState((current) => ({ ...current, lastSeenAt: Date.now() })); + switch (message.type) { case "agent.ready": { - updateState((current) => ({ ...current, isReady: true, lastSeenAt: Date.now() })); + updateState((current) => ({ ...current, isReady: true })); logger.info(`Agent "${socket.data.agentName}" (${socket.data.agentId}) is ready`); break; } case "backup.started": { - updateState((current) => ({ ...current, lastSeenAt: Date.now() })); setActiveBackupJob(message.payload.jobId, message.payload.scheduleId); logger.info( `Backup ${message.payload.jobId} started on agent ${socket.data.agentId} for schedule ${message.payload.scheduleId}`, @@ -139,34 +140,26 @@ export const createControllerAgentSession = ( break; } case "backup.progress": { - updateState((current) => ({ ...current, lastSeenAt: Date.now() })); handlers.onBackupProgress?.(message.payload); break; } case "backup.completed": { - updateState((current) => ({ ...current, lastSeenAt: Date.now() })); deleteActiveBackupJob(message.payload.jobId); handlers.onBackupCompleted?.(message.payload); break; } case "backup.failed": { - updateState((current) => ({ ...current, lastSeenAt: Date.now() })); deleteActiveBackupJob(message.payload.jobId); handlers.onBackupFailed?.(message.payload); break; } case "backup.cancelled": { - updateState((current) => ({ ...current, lastSeenAt: Date.now() })); deleteActiveBackupJob(message.payload.jobId); handlers.onBackupCancelled?.(message.payload); break; } case "heartbeat.pong": { - updateState((current) => ({ - ...current, - lastSeenAt: Date.now(), - lastPongAt: message.payload.sentAt, - })); + updateState((current) => ({ ...current, lastPongAt: message.payload.sentAt })); break; } } @@ -190,19 +183,11 @@ export const createControllerAgentSession = ( handleAgentMessage(parsed.data); }, sendBackup: (payload) => { - offerOutbound( - createControllerMessage("backup.run", { - ...payload, - }), - ); + offerOutbound(createControllerMessage("backup.run", payload)); return payload.jobId; }, sendBackupCancel: (payload) => { - offerOutbound( - createControllerMessage("backup.cancel", { - ...payload, - }), - ); + offerOutbound(createControllerMessage("backup.cancel", payload)); }, isReady: () => Effect.runSync(Ref.get(state)).isReady, close: () => { diff --git a/app/server/modules/backups/__tests__/backups.execution.test.ts b/app/server/modules/backups/__tests__/backups.execution.test.ts index b6bbb83a..8f7ff478 100644 --- a/app/server/modules/backups/__tests__/backups.execution.test.ts +++ b/app/server/modules/backups/__tests__/backups.execution.test.ts @@ -16,7 +16,7 @@ import { NotFoundError, BadRequestError } from "http-errors-enhanced"; import { repositoriesService } from "~/server/modules/repositories/repositories.service"; import { repoMutex } from "~/server/core/repository-mutex"; import { agentManager } from "~/server/modules/agents/agents-manager"; -import { fromAny } from "@total-typescript/shoehorn"; +import { createAgentBackupMocks } from "~/test/helpers/agent-mock"; const setup = () => { const resticBackupMock = vi.fn((_: SafeSpawnParams) => @@ -24,94 +24,7 @@ const setup = () => { ); const resticForgetMock = vi.fn(() => Promise.resolve({ success: true, data: null })); const resticCopyMock = vi.fn(() => Promise.resolve({ success: true, output: "" })); - const runningJobs = new Map(); - const sendBackupMock = vi.fn((_agentId: string, payload: { jobId: string; scheduleId: string }) => { - const handlers = agentManager.getBackupEventHandlers(); - - runningJobs.set(payload.jobId, { scheduleId: payload.scheduleId, cancelled: false }); - - handlers.onBackupStarted?.({ - agentId: "local", - agentName: "local", - payload: { jobId: payload.jobId, scheduleId: payload.scheduleId }, - }); - - void (async () => { - const stderrLines: string[] = []; - const result = await resticBackupMock( - fromAny({ - onStderr: (line: string) => { - stderrLines.push(line); - }, - } satisfies Partial), - ); - const running = runningJobs.get(payload.jobId); - if (!running || running.cancelled) { - return; - } - - if (result.exitCode === 0 || result.exitCode === 3) { - let parsedResult: Record | null = null; - if (result.summary) { - try { - parsedResult = JSON.parse(result.summary) as Record; - } catch { - parsedResult = null; - } - } - - handlers.onBackupCompleted?.({ - agentId: "local", - agentName: "local", - payload: { - jobId: payload.jobId, - scheduleId: payload.scheduleId, - exitCode: result.exitCode, - result: fromAny(parsedResult), - warningDetails: stderrLines.join("\n") || undefined, - }, - }); - } else { - const resultWithStderr = result as typeof result & { stderr?: string }; - const errorDetails = stderrLines.join("\n") || resultWithStderr.stderr || result.error; - - handlers.onBackupFailed?.({ - agentId: "local", - agentName: "local", - payload: { - jobId: payload.jobId, - scheduleId: payload.scheduleId, - error: result.error || `Backup failed with code ${result.exitCode}`, - errorDetails, - }, - }); - } - - runningJobs.delete(payload.jobId); - })().catch(() => {}); - - return true; - }); - const cancelBackupMock = vi.fn((_agentId: string, payload: { jobId: string; scheduleId: string }) => { - const running = runningJobs.get(payload.jobId); - if (!running) { - return false; - } - - running.cancelled = true; - const handlers = agentManager.getBackupEventHandlers(); - handlers.onBackupCancelled?.({ - agentId: "local", - agentName: "local", - payload: { - jobId: payload.jobId, - scheduleId: payload.scheduleId, - message: "Backup was stopped by user", - }, - }); - runningJobs.delete(payload.jobId); - return true; - }); + const { sendBackupMock, cancelBackupMock } = createAgentBackupMocks(resticBackupMock); const refreshStatsMock = vi.fn(() => Promise.resolve({ total_size: 0, diff --git a/app/server/modules/backups/__tests__/backups.service.test.ts b/app/server/modules/backups/__tests__/backups.service.test.ts index 32b31d97..6918b066 100644 --- a/app/server/modules/backups/__tests__/backups.service.test.ts +++ b/app/server/modules/backups/__tests__/backups.service.test.ts @@ -15,98 +15,11 @@ import * as context from "~/server/core/request-context"; import { backupsExecutionService } from "../backups.execution"; import { repositoriesService } from "~/server/modules/repositories/repositories.service"; import { agentManager } from "~/server/modules/agents/agents-manager"; -import { fromAny } from "@total-typescript/shoehorn"; +import { createAgentBackupMocks } from "~/test/helpers/agent-mock"; const setup = () => { const resticBackupMock = vi.fn((_: unknown) => Promise.resolve({ exitCode: 0, summary: "", error: "" })); - const runningJobs = new Map(); - const sendBackupMock = vi.fn((_agentId: string, payload: { jobId: string; scheduleId: string }) => { - const handlers = agentManager.getBackupEventHandlers(); - - runningJobs.set(payload.jobId, { scheduleId: payload.scheduleId, cancelled: false }); - - handlers.onBackupStarted?.({ - agentId: "local", - agentName: "local", - payload: { jobId: payload.jobId, scheduleId: payload.scheduleId }, - }); - - void (async () => { - const stderrLines: string[] = []; - const result = await resticBackupMock( - fromAny({ - onStderr: (line: string) => { - stderrLines.push(line); - }, - }), - ); - const running = runningJobs.get(payload.jobId); - if (!running || running.cancelled) { - return; - } - - if (result.exitCode === 0 || result.exitCode === 3) { - let parsedResult: Record | null = null; - if (result.summary) { - try { - parsedResult = JSON.parse(result.summary) as Record; - } catch { - parsedResult = null; - } - } - - handlers.onBackupCompleted?.({ - agentId: "local", - agentName: "local", - payload: { - jobId: payload.jobId, - scheduleId: payload.scheduleId, - exitCode: result.exitCode, - result: fromAny(parsedResult), - warningDetails: stderrLines.join("\n") || undefined, - }, - }); - } else { - const resultWithStderr = result as typeof result & { stderr?: string }; - const errorDetails = stderrLines.join("\n") || resultWithStderr.stderr || result.error; - - handlers.onBackupFailed?.({ - agentId: "local", - agentName: "local", - payload: { - jobId: payload.jobId, - scheduleId: payload.scheduleId, - error: result.error || `Backup failed with code ${result.exitCode}`, - errorDetails, - }, - }); - } - - runningJobs.delete(payload.jobId); - })().catch(() => {}); - - return true; - }); - const cancelBackupMock = vi.fn((_agentId: string, payload: { jobId: string; scheduleId: string }) => { - const running = runningJobs.get(payload.jobId); - if (!running) { - return false; - } - - running.cancelled = true; - const handlers = agentManager.getBackupEventHandlers(); - handlers.onBackupCancelled?.({ - agentId: "local", - agentName: "local", - payload: { - jobId: payload.jobId, - scheduleId: payload.scheduleId, - message: "Backup was stopped by user", - }, - }); - runningJobs.delete(payload.jobId); - return true; - }); + const { sendBackupMock, cancelBackupMock } = createAgentBackupMocks(resticBackupMock); const refreshStatsMock = vi.fn(() => Promise.resolve({ total_size: 0, diff --git a/app/server/modules/lifecycle/__tests__/shutdown.test.ts b/app/server/modules/lifecycle/__tests__/shutdown.test.ts new file mode 100644 index 00000000..f60839c5 --- /dev/null +++ b/app/server/modules/lifecycle/__tests__/shutdown.test.ts @@ -0,0 +1,58 @@ +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { Scheduler } from "../../../core/scheduler"; +import * as agentsManagerModule from "../../agents/agents-manager"; +import * as backendModule from "../../backends/backend"; +import type { VolumeBackend } from "../../backends/backend"; +import { createTestVolume } from "~/test/helpers/volume"; + +const loadShutdownModule = async () => { + const moduleUrl = new URL("../shutdown.ts", import.meta.url); + moduleUrl.searchParams.set("test", crypto.randomUUID()); + return import(moduleUrl.href); +}; + +afterEach(() => { + mock.restore(); +}); + +describe("shutdown", () => { + test("stops the agent runtime before unmounting mounted volumes", async () => { + const events: string[] = []; + const stopScheduler = mock(async () => { + events.push("scheduler.stop"); + }); + const stopAgentRuntime = mock(async () => { + events.push("agents.stop"); + }); + const unmountVolume = mock(async () => { + events.push("backend.unmount"); + return { status: "unmounted" as const }; + }); + + await createTestVolume({ + name: "Shutdown test volume", + config: { + backend: "directory", + path: "/Applications", + }, + status: "mounted", + }); + + spyOn(Scheduler, "stop").mockImplementation(stopScheduler); + spyOn(agentsManagerModule, "stopAgentRuntime").mockImplementation(stopAgentRuntime); + spyOn(backendModule, "createVolumeBackend").mockImplementation( + () => + ({ + mount: async () => ({ status: "mounted" as const }), + unmount: unmountVolume, + checkHealth: async () => ({ status: "mounted" as const }), + }) satisfies VolumeBackend, + ); + + const { shutdown } = await loadShutdownModule(); + + await shutdown(); + + expect(events).toEqual(["scheduler.stop", "agents.stop", "backend.unmount"]); + }); +}); diff --git a/app/server/modules/lifecycle/shutdown.ts b/app/server/modules/lifecycle/shutdown.ts index 3bb76073..78c0ef21 100644 --- a/app/server/modules/lifecycle/shutdown.ts +++ b/app/server/modules/lifecycle/shutdown.ts @@ -2,9 +2,11 @@ import { Scheduler } from "../../core/scheduler"; import { db } from "../../db/db"; import { logger } from "@zerobyte/core/node"; import { createVolumeBackend } from "../backends/backend"; +import { stopAgentRuntime } from "../agents/agents-manager"; export const shutdown = async () => { await Scheduler.stop(); + await stopAgentRuntime(); const volumes = await db.query.volumesTable.findMany({ where: { status: "mounted" }, diff --git a/app/server/plugins/bootstrap.ts b/app/server/plugins/bootstrap.ts index 4dbc7c9e..c661205f 100644 --- a/app/server/plugins/bootstrap.ts +++ b/app/server/plugins/bootstrap.ts @@ -2,8 +2,20 @@ import { definePlugin } from "nitro"; import { bootstrapApplication } from "../modules/lifecycle/bootstrap"; import { logger } from "@zerobyte/core/node"; import { toMessage } from "../utils/errors"; +import { stopAgentRuntime } from "../modules/agents/agents-manager"; + +type ProcessWithAgentCloseHook = NodeJS.Process & { + __zerobyteAgentRuntimeCloseHookRegistered?: boolean; +}; + +export default definePlugin(async (nitroApp) => { + const runtimeProcess = process as ProcessWithAgentCloseHook; + + if (!runtimeProcess.__zerobyteAgentRuntimeCloseHookRegistered) { + nitroApp.hooks.hook("close", stopAgentRuntime); + runtimeProcess.__zerobyteAgentRuntimeCloseHookRegistered = true; + } -export default definePlugin(async () => { await bootstrapApplication().catch((err) => { logger.error(`Bootstrap failed: ${toMessage(err)}`); process.exit(1); diff --git a/app/test/helpers/agent-mock.ts b/app/test/helpers/agent-mock.ts new file mode 100644 index 00000000..bee28505 --- /dev/null +++ b/app/test/helpers/agent-mock.ts @@ -0,0 +1,105 @@ +import { mock } from "bun:test"; +import { fromAny } from "@total-typescript/shoehorn"; +import { agentManager } from "~/server/modules/agents/agents-manager"; + +export const createAgentBackupMocks = ( + resticBackupMock: (params: never) => Promise<{ + exitCode: number; + summary: string; + error: string; + stderr?: string; + }>, +) => { + const runningJobs = new Map(); + + const sendBackupMock = mock((_agentId: string, payload: { jobId: string; scheduleId: string }) => { + const handlers = agentManager.getBackupEventHandlers(); + + runningJobs.set(payload.jobId, { scheduleId: payload.scheduleId, cancelled: false }); + + handlers.onBackupStarted?.({ + agentId: "local", + agentName: "local", + payload: { jobId: payload.jobId, scheduleId: payload.scheduleId }, + }); + + void (async () => { + const stderrLines: string[] = []; + const result = await resticBackupMock( + fromAny({ + onStderr: (line: string) => { + stderrLines.push(line); + }, + }), + ); + const running = runningJobs.get(payload.jobId); + if (!running || running.cancelled) { + return; + } + + if (result.exitCode === 0 || result.exitCode === 3) { + let parsedResult: Record | null = null; + if (result.summary) { + try { + parsedResult = JSON.parse(result.summary) as Record; + } catch { + parsedResult = null; + } + } + + handlers.onBackupCompleted?.({ + agentId: "local", + agentName: "local", + payload: { + jobId: payload.jobId, + scheduleId: payload.scheduleId, + exitCode: result.exitCode, + result: fromAny(parsedResult), + warningDetails: stderrLines.join("\n") || undefined, + }, + }); + } else { + const resultWithStderr = result as typeof result & { stderr?: string }; + const errorDetails = stderrLines.join("\n") || resultWithStderr.stderr || result.error; + + handlers.onBackupFailed?.({ + agentId: "local", + agentName: "local", + payload: { + jobId: payload.jobId, + scheduleId: payload.scheduleId, + error: result.error || `Backup failed with code ${result.exitCode}`, + errorDetails, + }, + }); + } + + runningJobs.delete(payload.jobId); + })().catch(() => {}); + + return true; + }); + + const cancelBackupMock = mock((_agentId: string, payload: { jobId: string; scheduleId: string }) => { + const running = runningJobs.get(payload.jobId); + if (!running) { + return false; + } + + running.cancelled = true; + const handlers = agentManager.getBackupEventHandlers(); + handlers.onBackupCancelled?.({ + agentId: "local", + agentName: "local", + payload: { + jobId: payload.jobId, + scheduleId: payload.scheduleId, + message: "Backup was stopped by user", + }, + }); + runningJobs.delete(payload.jobId); + return true; + }); + + return { sendBackupMock, cancelBackupMock }; +}; diff --git a/packages/contracts/src/agent-protocol.ts b/packages/contracts/src/agent-protocol.ts index 4e98a091..06b2f775 100644 --- a/packages/contracts/src/agent-protocol.ts +++ b/packages/contracts/src/agent-protocol.ts @@ -168,10 +168,6 @@ type Brand = TValue & { readonly __brand: TBrand; }; -type MessageSender = { - send(message: string): unknown; -}; - export type ControllerWireMessage = Brand; export type AgentWireMessage = Brand; @@ -221,13 +217,3 @@ export const createAgentMessage = ( payload, }), ) as AgentWireMessage; - -export const sendControllerMessage = (target: MessageSender, message: ControllerWireMessage) => { - target.send(message); -}; - -export const sendAgentMessage = (target: MessageSender, message: AgentWireMessage) => { - target.send(message); -}; - -export type ControllerData = MessageEvent;