mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-16 01:31:19 +10:00
task API
The Task API allows for an easy way to create long-lived tasks that require reporting back to user with progress/logs. It will be used in the upcoming game importing.
This commit is contained in:
64
composables/task.ts
Normal file
64
composables/task.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import type { TaskMessage } from "~/server/internal/tasks";
|
||||||
|
|
||||||
|
let ws: WebSocket | undefined;
|
||||||
|
const msgQueue: Array<string> = [];
|
||||||
|
|
||||||
|
const useTaskStates = () =>
|
||||||
|
useState<{ [key: string]: Ref<TaskMessage> }>("task-states", () => ({
|
||||||
|
connect: useState<TaskMessage>("task-connect", () => ({
|
||||||
|
id: "connect",
|
||||||
|
success: false,
|
||||||
|
progress: 0,
|
||||||
|
log: [],
|
||||||
|
error: undefined,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function initWs() {
|
||||||
|
const isSecure = location.protocol === "https:";
|
||||||
|
const url = (isSecure ? "wss://" : "ws://") + location.host + "/api/v1/task";
|
||||||
|
ws = new WebSocket(url);
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
const msg = JSON.parse(e.data) as TaskMessage;
|
||||||
|
console.log(msg);
|
||||||
|
const taskStates = useTaskStates();
|
||||||
|
const state = taskStates.value[msg.id];
|
||||||
|
if (!state) return;
|
||||||
|
state.value = msg;
|
||||||
|
};
|
||||||
|
ws.onopen = () => {
|
||||||
|
for (const message of msgQueue) {
|
||||||
|
ws?.send(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return ws;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendMessage(msg: string) {
|
||||||
|
if (!ws) return msgQueue.push(msg);
|
||||||
|
if (ws.readyState == 0) return msgQueue.push(msg);
|
||||||
|
return ws.send(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTaskReady = () => {
|
||||||
|
const taskStates = useTaskStates();
|
||||||
|
return taskStates.value["connect"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useTask = (taskId: string): Ref<TaskMessage> => {
|
||||||
|
if (import.meta.server) return {} as unknown as Ref<TaskMessage>;
|
||||||
|
const taskStates = useTaskStates();
|
||||||
|
if (taskStates.value[taskId]) return taskStates.value[taskId];
|
||||||
|
|
||||||
|
if (!ws) initWs();
|
||||||
|
taskStates.value[taskId] = useState(`task-${taskId}`, () => ({
|
||||||
|
id: taskId,
|
||||||
|
success: false,
|
||||||
|
progress: 0,
|
||||||
|
error: undefined,
|
||||||
|
log: [],
|
||||||
|
}));
|
||||||
|
sendMessage(`connect/${taskId}`);
|
||||||
|
return taskStates.value[taskId];
|
||||||
|
};
|
||||||
@ -3,6 +3,7 @@
|
|||||||
<UserHeader />
|
<UserHeader />
|
||||||
<div class="grow flex">
|
<div class="grow flex">
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
|
{{ test }}
|
||||||
</div>
|
</div>
|
||||||
<UserFooter />
|
<UserFooter />
|
||||||
</content>
|
</content>
|
||||||
@ -15,4 +16,6 @@ useHead({
|
|||||||
return `Drop`;
|
return `Drop`;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const test = useTask("test");
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -16,4 +16,10 @@ export default defineNuxtConfig({
|
|||||||
link: [{ rel: "icon", href: "/favicon.ico" }],
|
link: [{ rel: "icon", href: "/favicon.ico" }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
nitro: {
|
||||||
|
experimental: {
|
||||||
|
websocket: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -35,7 +35,7 @@
|
|||||||
"@types/turndown": "^5.0.5",
|
"@types/turndown": "^5.0.5",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"h3": "^1.12.0",
|
"nitropack": "^2.9.7",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"sass": "^1.79.4",
|
"sass": "^1.79.4",
|
||||||
"tailwindcss": "^3.4.13"
|
"tailwindcss": "^3.4.13"
|
||||||
|
|||||||
@ -86,9 +86,24 @@ model Game {
|
|||||||
mArt String[] // linked to objects in s3
|
mArt String[] // linked to objects in s3
|
||||||
mScreenshots String[] // linked to objects in s3
|
mScreenshots String[] // linked to objects in s3
|
||||||
|
|
||||||
|
versionOrder String
|
||||||
|
versions GameVersion[]
|
||||||
|
libraryBasePath String // Base dir for all the game versions
|
||||||
|
|
||||||
@@unique([metadataSource, metadataId], name: "metadataKey")
|
@@unique([metadataSource, metadataId], name: "metadataKey")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A particular set of files that relate to the version
|
||||||
|
model GameVersion {
|
||||||
|
gameId String
|
||||||
|
game Game @relation(fields: [gameId], references: [id])
|
||||||
|
versionName String // Sub directory for the game files
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@@id([gameId, versionName])
|
||||||
|
}
|
||||||
|
|
||||||
model Developer {
|
model Developer {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
|
|
||||||
|
|||||||
44
server/api/v1/task/index.get.ts
Normal file
44
server/api/v1/task/index.get.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { H3Event } from "h3";
|
||||||
|
import session from "~/server/internal/session";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import taskHandler, { TaskMessage } from "~/server/internal/tasks";
|
||||||
|
|
||||||
|
export default defineWebSocketHandler({
|
||||||
|
open(peer) {
|
||||||
|
const dummyEvent = {
|
||||||
|
node: {
|
||||||
|
req: {
|
||||||
|
headers: peer.headers,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as H3Event;
|
||||||
|
const userId = session.getUserId(dummyEvent);
|
||||||
|
if (!userId) {
|
||||||
|
peer.send("unauthenticated");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const peerId = uuidv4();
|
||||||
|
peer.ctx.id = peerId;
|
||||||
|
|
||||||
|
const rtMsg: TaskMessage = {
|
||||||
|
id: "connect",
|
||||||
|
success: true,
|
||||||
|
progress: 0,
|
||||||
|
error: undefined,
|
||||||
|
log: [],
|
||||||
|
};
|
||||||
|
peer.send(rtMsg);
|
||||||
|
},
|
||||||
|
message(peer, message) {
|
||||||
|
if (!peer.ctx.id) return;
|
||||||
|
const text = message.text();
|
||||||
|
if (text.startsWith("connect/")) {
|
||||||
|
const id = text.substring("connect/".length);
|
||||||
|
taskHandler.connect(peer.ctx.id, id, peer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
close(peer, details) {
|
||||||
|
if (!peer.ctx.id) return;
|
||||||
|
},
|
||||||
|
});
|
||||||
11
server/internal/library/README.md
Normal file
11
server/internal/library/README.md
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# Library Format
|
||||||
|
|
||||||
|
Drop uses a filesystem-based library format, as it targets homelabs and not enterprise-grade solutions. The format works as follows:
|
||||||
|
|
||||||
|
## /{game name}
|
||||||
|
|
||||||
|
The game name is only used for initial matching, and doesn't affect actual metadata. Metadata is linked to the game's database entry, which is linked to it's filesystem name (they, however, can be completely different).
|
||||||
|
|
||||||
|
## /{game name}/{version name}
|
||||||
|
|
||||||
|
The version name can be anything. Versions have to manually imported within the web UI. There, you can change the order of the updates and mark them as deltas. Delta updates apply files over the previous versions.
|
||||||
145
server/internal/tasks/index.ts
Normal file
145
server/internal/tasks/index.ts
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* The TaskHandler setups up two-way connections to web clients and manages the state for them
|
||||||
|
* This allows long-running tasks (like game imports and such) to report progress, success and error states
|
||||||
|
* easily without re-inventing the wheel every time.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type TaskRegistryEntry = {
|
||||||
|
runPromise: Promise<void>;
|
||||||
|
success: boolean;
|
||||||
|
progress: number;
|
||||||
|
log: string[];
|
||||||
|
error: string | undefined;
|
||||||
|
clients: { [key: string]: boolean };
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
class TaskHandler {
|
||||||
|
private taskRegistry: { [key: string]: TaskRegistryEntry } = {};
|
||||||
|
private clientRegistry: { [key: string]: PeerImpl } = {};
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
create(task: Task) {
|
||||||
|
let updateCollectTimeout: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
|
const updateAllClients = () => {
|
||||||
|
if (updateCollectTimeout) return;
|
||||||
|
updateCollectTimeout = setTimeout(() => {
|
||||||
|
const taskEntry = this.taskRegistry[task.id];
|
||||||
|
if (!taskEntry) return;
|
||||||
|
const taskMessage: TaskMessage = {
|
||||||
|
id: task.id,
|
||||||
|
success: taskEntry.success,
|
||||||
|
progress: taskEntry.progress,
|
||||||
|
error: taskEntry.error,
|
||||||
|
log: taskEntry.log,
|
||||||
|
};
|
||||||
|
for (const client of Object.keys(taskEntry.clients)) {
|
||||||
|
if (!this.clientRegistry[client]) continue;
|
||||||
|
this.clientRegistry[client].send(taskMessage);
|
||||||
|
}
|
||||||
|
updateCollectTimeout = undefined;
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const progress = (progress: number) => {
|
||||||
|
const taskEntry = this.taskRegistry[task.id];
|
||||||
|
if (!taskEntry) return;
|
||||||
|
this.taskRegistry[task.id].progress = progress;
|
||||||
|
updateAllClients();
|
||||||
|
};
|
||||||
|
|
||||||
|
const log = (entry: string) => {
|
||||||
|
const taskEntry = this.taskRegistry[task.id];
|
||||||
|
if (!taskEntry) return;
|
||||||
|
this.taskRegistry[task.id].log.push(entry);
|
||||||
|
updateAllClients();
|
||||||
|
};
|
||||||
|
|
||||||
|
const promiseRun = task.run({ progress, log });
|
||||||
|
promiseRun.then(() => {
|
||||||
|
const taskEntry = this.taskRegistry[task.id];
|
||||||
|
if (!taskEntry) return;
|
||||||
|
this.taskRegistry[task.id].success = true;
|
||||||
|
updateAllClients();
|
||||||
|
});
|
||||||
|
promiseRun.catch((error) => {
|
||||||
|
const taskEntry = this.taskRegistry[task.id];
|
||||||
|
if (!taskEntry) return;
|
||||||
|
this.taskRegistry[task.id].success = false;
|
||||||
|
this.taskRegistry[task.id].error = error;
|
||||||
|
updateAllClients();
|
||||||
|
});
|
||||||
|
this.taskRegistry[task.id] = {
|
||||||
|
name: task.name,
|
||||||
|
runPromise: promiseRun,
|
||||||
|
success: false,
|
||||||
|
progress: 0,
|
||||||
|
error: undefined,
|
||||||
|
log: [],
|
||||||
|
clients: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(id: string, taskId: string, peer: PeerImpl) {
|
||||||
|
const task = this.taskRegistry[taskId];
|
||||||
|
if (!task) return false;
|
||||||
|
|
||||||
|
this.clientRegistry[id] = peer;
|
||||||
|
this.taskRegistry[taskId].clients[id] = true; // Uniquely insert client to avoid sending duplicate traffic
|
||||||
|
|
||||||
|
const catchupMessage: TaskMessage = {
|
||||||
|
id: taskId,
|
||||||
|
success: task.success,
|
||||||
|
error: task.error,
|
||||||
|
log: task.log,
|
||||||
|
progress: task.progress,
|
||||||
|
};
|
||||||
|
peer.send(catchupMessage);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(id: string, taskId: string) {
|
||||||
|
if (!this.taskRegistry[taskId]) return false;
|
||||||
|
|
||||||
|
delete this.taskRegistry[taskId].clients[id];
|
||||||
|
|
||||||
|
const allClientIds = Object.values(this.taskRegistry)
|
||||||
|
.map((_) => Object.keys(_.clients))
|
||||||
|
.flat();
|
||||||
|
|
||||||
|
if (!allClientIds.includes(id)) {
|
||||||
|
delete this.clientRegistry[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TaskRunContext = {
|
||||||
|
progress: (progress: number) => void;
|
||||||
|
log: (message: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Task {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
run: (context: TaskRunContext) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TaskMessage = {
|
||||||
|
id: string;
|
||||||
|
success: boolean;
|
||||||
|
progress: number;
|
||||||
|
error: undefined | string;
|
||||||
|
log: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PeerImpl = {
|
||||||
|
send: (message: TaskMessage) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const taskHandler = new TaskHandler();
|
||||||
|
export default taskHandler;
|
||||||
Reference in New Issue
Block a user