@roster-lock/match-agent 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +11 -0
- package/dist/authentication.js +53 -0
- package/dist/authentication.js.map +1 -0
- package/dist/config/constants.js +12 -0
- package/dist/config/constants.js.map +1 -0
- package/dist/config/findFile.js +32 -0
- package/dist/config/findFile.js.map +1 -0
- package/dist/config/index.js +29 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/manage.js +41 -0
- package/dist/config/manage.js.map +1 -0
- package/dist/cors.js +20 -0
- package/dist/cors.js.map +1 -0
- package/dist/editor-support/index.js +247 -0
- package/dist/editor-support/index.js.map +1 -0
- package/dist/handle-room/version-1/file-routes.js +53 -0
- package/dist/handle-room/version-1/file-routes.js.map +1 -0
- package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/MultiAbort.js +76 -0
- package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/MultiAbort.js.map +1 -0
- package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/ensurePieceExists.js +218 -0
- package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/ensurePieceExists.js.map +1 -0
- package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/getVersions.js +18 -0
- package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/getVersions.js.map +1 -0
- package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/index.js +16 -0
- package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/index.js.map +1 -0
- package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/schema.js +370 -0
- package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/schema.js.map +1 -0
- package/dist/handle-room/version-1/globals/FolderDB/index.js +18 -0
- package/dist/handle-room/version-1/globals/FolderDB/index.js.map +1 -0
- package/dist/handle-room/version-1/globals/FolderDB/types.js +3 -0
- package/dist/handle-room/version-1/globals/FolderDB/types.js.map +1 -0
- package/dist/handle-room/version-1/globals/Room/WebsocketRoom.js +95 -0
- package/dist/handle-room/version-1/globals/Room/WebsocketRoom.js.map +1 -0
- package/dist/handle-room/version-1/globals/Room/index.js +18 -0
- package/dist/handle-room/version-1/globals/Room/index.js.map +1 -0
- package/dist/handle-room/version-1/globals/Room/room.js +3 -0
- package/dist/handle-room/version-1/globals/Room/room.js.map +1 -0
- package/dist/handle-room/version-1/globals/index.js +20 -0
- package/dist/handle-room/version-1/globals/index.js.map +1 -0
- package/dist/handle-room/version-1/globals/types.js +3 -0
- package/dist/handle-room/version-1/globals/types.js.map +1 -0
- package/dist/handle-room/version-1/handleDownloads/DownloadTracker.js +83 -0
- package/dist/handle-room/version-1/handleDownloads/DownloadTracker.js.map +1 -0
- package/dist/handle-room/version-1/handleDownloads/index.js +35 -0
- package/dist/handle-room/version-1/handleDownloads/index.js.map +1 -0
- package/dist/handle-room/version-1/handleDownloads/types.js +3 -0
- package/dist/handle-room/version-1/handleDownloads/types.js.map +1 -0
- package/dist/handle-room/version-1/handleRoomSelections/encryption.js +21 -0
- package/dist/handle-room/version-1/handleRoomSelections/encryption.js.map +1 -0
- package/dist/handle-room/version-1/handleRoomSelections/random.js +12 -0
- package/dist/handle-room/version-1/handleRoomSelections/random.js.map +1 -0
- package/dist/handle-room/version-1/index.js +28 -0
- package/dist/handle-room/version-1/index.js.map +1 -0
- package/dist/handle-room/version-1/piece-sort.js +71 -0
- package/dist/handle-room/version-1/piece-sort.js.map +1 -0
- package/dist/handle-room/version-1/room-handler-bridge/exchange-and-download-selections.js +89 -0
- package/dist/handle-room/version-1/room-handler-bridge/exchange-and-download-selections.js.map +1 -0
- package/dist/handle-room/version-1/room-handler-bridge/http-handler.js +19 -0
- package/dist/handle-room/version-1/room-handler-bridge/http-handler.js.map +1 -0
- package/dist/handle-room/version-1/room-handler-bridge/steps.js +170 -0
- package/dist/handle-room/version-1/room-handler-bridge/steps.js.map +1 -0
- package/dist/handle-room/version-1/room-handler-bridge/ws-handler.js +28 -0
- package/dist/handle-room/version-1/room-handler-bridge/ws-handler.js.map +1 -0
- package/dist/handle-room/version-1/schema/count.js +16 -0
- package/dist/handle-room/version-1/schema/count.js.map +1 -0
- package/dist/handle-room/version-1/schema/lock.js +46 -0
- package/dist/handle-room/version-1/schema/lock.js.map +1 -0
- package/dist/handle-room/version-1/schema/selected.js +13 -0
- package/dist/handle-room/version-1/schema/selected.js.map +1 -0
- package/dist/handle-room/version-1/select.js +94 -0
- package/dist/handle-room/version-1/select.js.map +1 -0
- package/dist/index.js +180 -0
- package/dist/index.js.map +1 -0
- package/dist/server.js +88 -0
- package/dist/server.js.map +1 -0
- package/dist/utils/fs.js +32 -0
- package/dist/utils/fs.js.map +1 -0
- package/dist/utils/http-router.js +49 -0
- package/dist/utils/http-router.js.map +1 -0
- package/dist/utils/pagination.js +23 -0
- package/dist/utils/pagination.js.map +1 -0
- package/dist/utils/websocket-router.js +15 -0
- package/dist/utils/websocket-router.js.map +1 -0
- package/package.json +66 -0
- package/src/authentication.ts +48 -0
- package/src/config/constants.ts +7 -0
- package/src/config/findFile.ts +34 -0
- package/src/config/index.ts +17 -0
- package/src/config/manage.ts +48 -0
- package/src/cors.ts +23 -0
- package/src/editor-support/index.ts +289 -0
- package/src/handle-room/version-0/piece-download/availableGamePiece/index.ts +109 -0
- package/src/handle-room/version-0/piece-download/availableGamePiece/isUpToDate.ts +78 -0
- package/src/handle-room/version-0/piece-download/downloadPieceAssets/downloadSingleAsset.ts +129 -0
- package/src/handle-room/version-0/piece-download/downloadPieceAssets/example.ts +116 -0
- package/src/handle-room/version-0/piece-download/downloadPieceAssets/index.ts +144 -0
- package/src/handle-room/version-0/piece-download/downloadPieceAssets/types.ts +32 -0
- package/src/handle-room/version-0/piece-download/downloadPieceAssets/validateAssetFile.ts +54 -0
- package/src/handle-room/version-0/piece-download/index.ts +5 -0
- package/src/handle-room/version-0/piece-selection/constants.ts +39 -0
- package/src/handle-room/version-0/piece-selection/handleRoomSelections.ts +202 -0
- package/src/handle-room/version-0/piece-selection/index.ts +31 -0
- package/src/handle-room/version-0/piece-selection/roomErrors.ts +25 -0
- package/src/handle-room/version-0/piece-selection/steps/encrypton.ts +70 -0
- package/src/handle-room/version-0/piece-selection/steps/final-selection.ts +72 -0
- package/src/handle-room/version-0/piece-selection/steps/player-selection.ts +100 -0
- package/src/handle-room/version-1/constants.ts +44 -0
- package/src/handle-room/version-1/file-routes.ts +68 -0
- package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/MultiAbort.ts +83 -0
- package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/ensurePieceExists.ts +317 -0
- package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/getVersions.ts +31 -0
- package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/index.ts +13 -0
- package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/schema.ts +547 -0
- package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/types.ts +6 -0
- package/src/handle-room/version-1/globals/FolderDB/index.ts +3 -0
- package/src/handle-room/version-1/globals/FolderDB/types.ts +72 -0
- package/src/handle-room/version-1/globals/Room/WebsocketRoom.ts +118 -0
- package/src/handle-room/version-1/globals/Room/index.ts +3 -0
- package/src/handle-room/version-1/globals/Room/room.ts +8 -0
- package/src/handle-room/version-1/globals/index.ts +4 -0
- package/src/handle-room/version-1/globals/types.ts +7 -0
- package/src/handle-room/version-1/handleDownloads/DownloadTracker.ts +98 -0
- package/src/handle-room/version-1/handleDownloads/index.ts +41 -0
- package/src/handle-room/version-1/handleDownloads/types.ts +9 -0
- package/src/handle-room/version-1/handleRoomSelections/encryption.ts +29 -0
- package/src/handle-room/version-1/handleRoomSelections/random.ts +9 -0
- package/src/handle-room/version-1/index.ts +33 -0
- package/src/handle-room/version-1/piece-sort.ts +96 -0
- package/src/handle-room/version-1/room-handler-bridge/exchange-and-download-selections.ts +125 -0
- package/src/handle-room/version-1/room-handler-bridge/http-handler.ts +16 -0
- package/src/handle-room/version-1/room-handler-bridge/server.pseudo.ts +85 -0
- package/src/handle-room/version-1/room-handler-bridge/steps.ts +255 -0
- package/src/handle-room/version-1/room-handler-bridge/ws-handler.ts +30 -0
- package/src/handle-room/version-1/room-handler-state/RoomState.ts +132 -0
- package/src/handle-room/version-1/room-handler-state/bind-to-websocket.ts +93 -0
- package/src/handle-room/version-1/room-handler-state/handleRoomSelections.ts +71 -0
- package/src/handle-room/version-1/room-handler-state/handleRoomWithErrors.ts +55 -0
- package/src/handle-room/version-1/room-handler-state/roomErrors.ts +35 -0
- package/src/handle-room/version-1/room-handler-state/user-selection.ts +98 -0
- package/src/handle-room/version-1/schema/count.ts +13 -0
- package/src/handle-room/version-1/schema/lock.ts +47 -0
- package/src/handle-room/version-1/schema/selected.ts +14 -0
- package/src/handle-room/version-1/select.ts +130 -0
- package/src/handle-room/version-1/validateUnknown.ts +11 -0
- package/src/index.old.ts +71 -0
- package/src/index.ts +231 -0
- package/src/server.ts +85 -0
- package/src/utils/fs.ts +43 -0
- package/src/utils/http-router.ts +55 -0
- package/src/utils/pagination.ts +17 -0
- package/src/utils/websocket-router.ts +23 -0
- package/tests/integration/editor-pieces-search.test.ts +147 -0
- package/tests/integration/game-complete.test.ts +72 -0
- package/tests/integration/handle-full-selection.test.ts +143 -0
- package/tests/integration/helpers/bridge.ts +22 -0
- package/tests/integration/helpers/fakeFolderDB.ts +37 -0
- package/tests/integration/helpers/fakePluginRuntime.ts +46 -0
- package/tests/integration/helpers/lockConfig.ts +71 -0
- package/tests/integration/helpers/make-tar-gz.ts +41 -0
- package/tests/integration/helpers/piece.ts +98 -0
- package/tests/integration/helpers/plugin-dir.ts +26 -0
- package/tests/integration/helpers/room.ts +42 -0
- package/tests/integration/helpers/server.ts +51 -0
- package/tests/integration/helpers/validLockConfig.ts +80 -0
- package/tests/integration/piece-download-and-list.test.ts +194 -0
- package/tests/integration/piece-ensure-ws.test.ts +256 -0
- package/tests/integration/piece-files.test.ts +139 -0
- package/tests/integration/piece-sort-list.test.ts +113 -0
- package/tests/integration/protocol.test.ts +129 -0
- package/tests/integration/root-and-auth.test.ts +100 -0
- package/tests/integration/sync-dl.test.ts +62 -0
- package/tests/tsconfig.json +14 -0
- package/tsconfig.json +22 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { tmpdir } from "node:os";
|
|
2
|
+
import { mkdtemp, readdir, rm, stat } from "node:fs/promises";
|
|
3
|
+
import { createReadStream } from "node:fs";
|
|
4
|
+
import { pipeline } from "node:stream/promises";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { join as pathJoin } from "node:path";
|
|
7
|
+
import { z, ZodType } from "zod";
|
|
8
|
+
import { PluginManager } from "@roster-lock/plugin-runtime";
|
|
9
|
+
import { getAssetsOfFiles, calculatePieceVersion } from "@roster-lock/shared";
|
|
10
|
+
import type { ScriptStarter } from "@roster-lock/types";
|
|
11
|
+
import { HTTPRouter, HTTPError, jsonBody, HTTPRequest } from "../utils/http-router";
|
|
12
|
+
import type { IFolderDB } from "../handle-room/version-1/globals/FolderDB";
|
|
13
|
+
|
|
14
|
+
// Editor-support API consumed by the config-editor gui's host.plugins
|
|
15
|
+
// capability (the PWA host talks to this; the Tauri app runs the same
|
|
16
|
+
// operations locally via its sidecar). Mounted at /editor/v1 - its own
|
|
17
|
+
// namespace and version axis, deliberately separate from the room-handling
|
|
18
|
+
// /v1 protocol so the two can evolve independently.
|
|
19
|
+
|
|
20
|
+
function sendJSON({ res }: HTTPRequest, value: unknown){
|
|
21
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
22
|
+
res.end(JSON.stringify(value));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Mirrors the node-sidecar's run-script input validation so both plugin
|
|
26
|
+
// backends accept the same payloads.
|
|
27
|
+
const PurposeSchema = z.discriminatedUnion("type", [
|
|
28
|
+
z.object({
|
|
29
|
+
type: z.literal("piece-user-validation"),
|
|
30
|
+
pieceType: z.string(),
|
|
31
|
+
userId: z.string(),
|
|
32
|
+
input: z.array(z.any()),
|
|
33
|
+
}),
|
|
34
|
+
z.object({
|
|
35
|
+
type: z.literal("piece-merge"),
|
|
36
|
+
pieceType: z.string(),
|
|
37
|
+
users: z.array(z.string()),
|
|
38
|
+
input: z.record(z.string(), z.array(z.any())),
|
|
39
|
+
}),
|
|
40
|
+
z.object({
|
|
41
|
+
type: z.literal("global-validation"),
|
|
42
|
+
pieceTypes: z.array(z.string()),
|
|
43
|
+
users: z.array(z.string()),
|
|
44
|
+
input: z.record(z.string(), z.any()),
|
|
45
|
+
}),
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
const ScriptConfigSchema: ZodType<Omit<ScriptStarter, "debugLog">> = z.object({
|
|
49
|
+
randomSeeds: z.array(z.string()),
|
|
50
|
+
purpose: PurposeSchema,
|
|
51
|
+
config: z.any(),
|
|
52
|
+
entryScript: z.object({
|
|
53
|
+
src: z.string(),
|
|
54
|
+
method: z.string().optional()
|
|
55
|
+
})
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const DownloadSourceVersionSchema = z.object({
|
|
59
|
+
source: z.string(),
|
|
60
|
+
pathVariables: z.record(z.string(), z.string()),
|
|
61
|
+
pieceDefinition: z.any(),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// A kept-around download the client can list and read - deliberately the
|
|
65
|
+
// only way the editor reads files off this machine. Lookups go through the
|
|
66
|
+
// collected file map (client input is never joined into a path), so reads
|
|
67
|
+
// stay scoped to what this download produced.
|
|
68
|
+
type DownloadSession = {
|
|
69
|
+
dir: string,
|
|
70
|
+
files: Map<string, { fullPath: string, size: number }>,
|
|
71
|
+
expiry: NodeJS.Timeout,
|
|
72
|
+
};
|
|
73
|
+
const DOWNLOAD_SESSION_TTL = 30 * 60_000;
|
|
74
|
+
|
|
75
|
+
export function createEditorV1Router(plugins: PluginManager, folderDB: IFolderDB){
|
|
76
|
+
const router = new HTTPRouter();
|
|
77
|
+
|
|
78
|
+
const downloadSessions = new Map<string, DownloadSession>();
|
|
79
|
+
|
|
80
|
+
async function closeDownloadSession(sessionId: string){
|
|
81
|
+
const session = downloadSessions.get(sessionId);
|
|
82
|
+
if(!session) return;
|
|
83
|
+
downloadSessions.delete(sessionId);
|
|
84
|
+
clearTimeout(session.expiry);
|
|
85
|
+
await rm(session.dir, { recursive: true, force: true }).catch((e)=>{
|
|
86
|
+
console.error("Failed to clean up editor download session", session.dir, e);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function touchDownloadSession(sessionId: string, session: DownloadSession){
|
|
91
|
+
clearTimeout(session.expiry);
|
|
92
|
+
session.expiry = setTimeout(()=>{ closeDownloadSession(sessionId); }, DOWNLOAD_SESSION_TTL);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
router.get("/plugins/download", async (request)=>{
|
|
96
|
+
const [protocol, compression, archive] = await Promise.all([
|
|
97
|
+
listPluginsOfType(plugins, "dl-protocol"),
|
|
98
|
+
listPluginsOfType(plugins, "dl-compression"),
|
|
99
|
+
listPluginsOfType(plugins, "dl-archive"),
|
|
100
|
+
]);
|
|
101
|
+
sendJSON(request, { protocol, compression, archive });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
router.get("/plugins/script", async (request)=>{
|
|
105
|
+
sendJSON(request, await listPluginsOfType(plugins, "untrusted-script"));
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
router.post("/plugins/match-download-protocols", async (request)=>{
|
|
109
|
+
const body = z.object({ url: z.string() }).safeParse(await jsonBody(request.req));
|
|
110
|
+
if(!body.success) throw new HTTPError(400, "Bad body", body.error.issues);
|
|
111
|
+
|
|
112
|
+
const matching = [];
|
|
113
|
+
for(const protocol of await plugins.getPluginFullOfType("dl-protocol")){
|
|
114
|
+
try {
|
|
115
|
+
if(protocol.module.validateURL(body.data.url)){
|
|
116
|
+
matching.push(pluginInfo(protocol));
|
|
117
|
+
}
|
|
118
|
+
}catch(e){
|
|
119
|
+
// a protocol that can't judge the url just doesn't match it
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
sendJSON(request, matching);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
router.post("/run-script", async (request)=>{
|
|
126
|
+
const body = ScriptConfigSchema.safeParse(await jsonBody(request.req));
|
|
127
|
+
if(!body.success) throw new HTTPError(400, "Bad body", body.error.issues);
|
|
128
|
+
|
|
129
|
+
const scriptConfig: ScriptStarter = { ...body.data, debugLog: [] };
|
|
130
|
+
try {
|
|
131
|
+
const result = await plugins.runUntrustedScript(scriptConfig);
|
|
132
|
+
sendJSON(request, { debugLog: scriptConfig.debugLog, status: "success", result });
|
|
133
|
+
}catch(error){
|
|
134
|
+
// Script-level failures still resolve the request - status carries it,
|
|
135
|
+
// matching the sidecar's behavior.
|
|
136
|
+
sendJSON(request, {
|
|
137
|
+
debugLog: scriptConfig.debugLog,
|
|
138
|
+
status: "fail",
|
|
139
|
+
result: (
|
|
140
|
+
error instanceof Error ? error.message :
|
|
141
|
+
typeof error === "string" ? error :
|
|
142
|
+
JSON.stringify(error)
|
|
143
|
+
),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
router.post("/download-source-version", async (request)=>{
|
|
149
|
+
const body = DownloadSourceVersionSchema.safeParse(await jsonBody(request.req));
|
|
150
|
+
if(!body.success) throw new HTTPError(400, "Bad body", body.error.issues);
|
|
151
|
+
const { source, pathVariables, pieceDefinition } = body.data;
|
|
152
|
+
|
|
153
|
+
const downloadDir = await mkdtemp(pathJoin(tmpdir(), "roster-lock-editor-"));
|
|
154
|
+
try {
|
|
155
|
+
const abortController = new AbortController();
|
|
156
|
+
const download = await plugins.downloadToFolder({
|
|
157
|
+
url: source,
|
|
158
|
+
destinationFolder: downloadDir,
|
|
159
|
+
processHandlers: { abortSignal: abortController.signal },
|
|
160
|
+
});
|
|
161
|
+
await download.finishPromise;
|
|
162
|
+
|
|
163
|
+
const files = await collectFiles(downloadDir);
|
|
164
|
+
const { filesWithAssets } = await getAssetsOfFiles(
|
|
165
|
+
files.keys(), pathVariables, pieceDefinition
|
|
166
|
+
);
|
|
167
|
+
const version = await calculatePieceVersion(filesWithAssets, async (relativePath)=>{
|
|
168
|
+
const file = files.get(relativePath);
|
|
169
|
+
if(!file) throw new Error("File is not in the download: " + relativePath);
|
|
170
|
+
return { byteSize: file.size, stream: createReadStream(file.fullPath) };
|
|
171
|
+
});
|
|
172
|
+
sendJSON(request, version);
|
|
173
|
+
}finally{
|
|
174
|
+
await rm(downloadDir, { recursive: true, force: true }).catch((e)=>{
|
|
175
|
+
console.error("Failed to clean up editor download dir", downloadDir, e);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
router.post("/download-session", async (request)=>{
|
|
181
|
+
const body = z.object({ source: z.string() }).safeParse(await jsonBody(request.req));
|
|
182
|
+
if(!body.success) throw new HTTPError(400, "Bad body", body.error.issues);
|
|
183
|
+
|
|
184
|
+
const downloadDir = await mkdtemp(pathJoin(tmpdir(), "roster-lock-editor-"));
|
|
185
|
+
let files: DownloadSession["files"];
|
|
186
|
+
try {
|
|
187
|
+
const abortController = new AbortController();
|
|
188
|
+
const download = await plugins.downloadToFolder({
|
|
189
|
+
url: body.data.source,
|
|
190
|
+
destinationFolder: downloadDir,
|
|
191
|
+
processHandlers: { abortSignal: abortController.signal },
|
|
192
|
+
});
|
|
193
|
+
await download.finishPromise;
|
|
194
|
+
files = await collectFiles(downloadDir);
|
|
195
|
+
}catch(e){
|
|
196
|
+
await rm(downloadDir, { recursive: true, force: true }).catch(()=>{});
|
|
197
|
+
throw e;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const sessionId = randomUUID();
|
|
201
|
+
const session: DownloadSession = {
|
|
202
|
+
dir: downloadDir,
|
|
203
|
+
files,
|
|
204
|
+
expiry: setTimeout(()=>{ closeDownloadSession(sessionId); }, DOWNLOAD_SESSION_TTL),
|
|
205
|
+
};
|
|
206
|
+
downloadSessions.set(sessionId, session);
|
|
207
|
+
sendJSON(request, {
|
|
208
|
+
sessionId,
|
|
209
|
+
entries: [...files].map(([relativePath, file]) => ({ relativePath, size: file.size })),
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
router.post("/download-session/file", async (request)=>{
|
|
214
|
+
const body = z.object({
|
|
215
|
+
sessionId: z.string(),
|
|
216
|
+
relativePath: z.string(),
|
|
217
|
+
}).safeParse(await jsonBody(request.req));
|
|
218
|
+
if(!body.success) throw new HTTPError(400, "Bad body", body.error.issues);
|
|
219
|
+
|
|
220
|
+
const session = downloadSessions.get(body.data.sessionId);
|
|
221
|
+
if(!session) throw new HTTPError(404, "Unknown download session");
|
|
222
|
+
const file = session.files.get(body.data.relativePath);
|
|
223
|
+
if(!file) throw new HTTPError(404, "File is not in the download");
|
|
224
|
+
touchDownloadSession(body.data.sessionId, session);
|
|
225
|
+
|
|
226
|
+
request.res.writeHead(200, {
|
|
227
|
+
"Content-Type": "application/octet-stream",
|
|
228
|
+
"Content-Length": String(file.size),
|
|
229
|
+
});
|
|
230
|
+
await pipeline(createReadStream(file.fullPath), request.res);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// Browse every piece the agent has ever completed downloading (they came
|
|
234
|
+
// in through matches via FolderDB.ensurePieceExists) so the editor can add
|
|
235
|
+
// them to a roster without re-downloading anything.
|
|
236
|
+
router.post("/pieces/search", async (request)=>{
|
|
237
|
+
const body = z.object({
|
|
238
|
+
engineName: z.string(),
|
|
239
|
+
pieceType: z.string().optional(),
|
|
240
|
+
search: z.string().optional(),
|
|
241
|
+
page: z.number().int().min(0).default(0),
|
|
242
|
+
limit: z.number().int().min(1).max(100).default(20),
|
|
243
|
+
}).safeParse(await jsonBody(request.req));
|
|
244
|
+
if(!body.success) throw new HTTPError(400, "Bad body", body.error.issues);
|
|
245
|
+
|
|
246
|
+
sendJSON(request, await folderDB.searchPieces(body.data));
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
router.post("/download-session/close", async (request)=>{
|
|
250
|
+
const body = z.object({ sessionId: z.string() }).safeParse(await jsonBody(request.req));
|
|
251
|
+
if(!body.success) throw new HTTPError(400, "Bad body", body.error.issues);
|
|
252
|
+
await closeDownloadSession(body.data.sessionId);
|
|
253
|
+
sendJSON(request, true);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
return router;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function pluginInfo(plugin: { module: { name: string, extensions?: Array<string> }, package: { name: string, version: string } }){
|
|
260
|
+
return {
|
|
261
|
+
name: plugin.module.name,
|
|
262
|
+
...(plugin.module.extensions ? { extensions: plugin.module.extensions } : {}),
|
|
263
|
+
package: { name: plugin.package.name, version: plugin.package.version },
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function listPluginsOfType(plugins: PluginManager, type: Parameters<PluginManager["getPluginFullOfType"]>[0]){
|
|
268
|
+
const found = await plugins.getPluginFullOfType(type);
|
|
269
|
+
return found.map(pluginInfo);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// relativePath (posix-separated) -> file, for asset matching + hashing
|
|
273
|
+
async function collectFiles(root: string){
|
|
274
|
+
const files = new Map<string, { fullPath: string, size: number }>();
|
|
275
|
+
async function walk(dir: string, prefix: string){
|
|
276
|
+
for(const entry of await readdir(dir, { withFileTypes: true })){
|
|
277
|
+
const fullPath = pathJoin(dir, entry.name);
|
|
278
|
+
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
279
|
+
if(entry.isDirectory()){
|
|
280
|
+
await walk(fullPath, relativePath);
|
|
281
|
+
} else if(entry.isFile()){
|
|
282
|
+
const info = await stat(fullPath);
|
|
283
|
+
files.set(relativePath, { fullPath, size: info.size });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
await walk(root, "");
|
|
288
|
+
return files;
|
|
289
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
|
|
2
|
+
import { MatchLockPublishPiece } from "@match-lock/shared";
|
|
3
|
+
import { resolveCompression } from "@match-lock/shared";
|
|
4
|
+
import { resolveStream } from "../../tools/download-methods";
|
|
5
|
+
import { join as pathJoin } from "node:path";
|
|
6
|
+
|
|
7
|
+
type ProgressListener = (value: { pieceId: string, part: "logic" | "media", progress: number })=>any
|
|
8
|
+
|
|
9
|
+
export async function availableGamePiece(
|
|
10
|
+
piece: MatchLockPublishPiece, parentFolder: string, onProgress: ProgressListener
|
|
11
|
+
){
|
|
12
|
+
const pieceFolder = resolvePieceFolder(piece, parentFolder);
|
|
13
|
+
if(!)
|
|
14
|
+
if(await isUpToDate(piece, piece.logic, pathJoin(pieceFolder, "logic"))){
|
|
15
|
+
return pieceFolder;
|
|
16
|
+
}
|
|
17
|
+
return await Promise.all([
|
|
18
|
+
handleAsset(piece, pieceFolder, piece.logic, "logic", onProgress),
|
|
19
|
+
handleAsset(piece, pieceFolder, piece.media, "media", onProgress),
|
|
20
|
+
]);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function resolvePieceFolder(piece: MatchLockPublishPiece, parentFolder: string){
|
|
24
|
+
return pathJoin(parentFolder, piece.author, piece.version.logic);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
import { mkdir as fsMkdir, rm as fsRm } from "node:fs/promises";
|
|
28
|
+
import { isUpToDate } from "./isUpToDate";
|
|
29
|
+
import { pipeline } from "node:stream/promises";
|
|
30
|
+
import { x as tarExtract } from "tar";
|
|
31
|
+
|
|
32
|
+
async function handleAsset(
|
|
33
|
+
piece: MatchLockPublishPiece,
|
|
34
|
+
pieceFolder: string,
|
|
35
|
+
assetBundle: MatchLockAssetBundle,
|
|
36
|
+
assetType: "logic" | "media",
|
|
37
|
+
onProgress: ProgressListener
|
|
38
|
+
){
|
|
39
|
+
if(assetType !== assetBundle.type){
|
|
40
|
+
throw new Error("Asset Type Mismatch");
|
|
41
|
+
}
|
|
42
|
+
const assetFolder = pathJoin(pieceFolder, `${assetType}-${assetBundle.version.toString()}`);
|
|
43
|
+
if(await isUpToDate(piece, assetBundle, assetFolder)){
|
|
44
|
+
return assetFolder;
|
|
45
|
+
}
|
|
46
|
+
const assetSource = await resolveStream(assetBundle);
|
|
47
|
+
const fileInfo = new FileInfo((progress)=>(onProgress({
|
|
48
|
+
pieceId: piece.id,
|
|
49
|
+
part: assetType,
|
|
50
|
+
progress,
|
|
51
|
+
})));
|
|
52
|
+
const decompressor = resolveCompression(assetSource.filename).createDecompressor();
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
await fsMkdir(assetFolder, { recursive: true });
|
|
56
|
+
await pipeline(
|
|
57
|
+
assetSource.stream,
|
|
58
|
+
fileInfo,
|
|
59
|
+
decompressor,
|
|
60
|
+
tarExtract({ cwd: assetFolder }),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
if(fileInfo.byteSize !== assetBundle.sizeBytes){
|
|
64
|
+
throw new Error("Asset Size Mismatch");
|
|
65
|
+
}
|
|
66
|
+
if(fileInfo.hash !== assetBundle.sha256){
|
|
67
|
+
throw new Error("Asset Hash Mismatch");
|
|
68
|
+
}
|
|
69
|
+
if(!await isUpToDate(piece, assetBundle, assetFolder)){
|
|
70
|
+
throw new Error("Asset Not Up To Date");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return assetFolder;
|
|
74
|
+
|
|
75
|
+
}catch(e){
|
|
76
|
+
try {
|
|
77
|
+
await fsRm(assetFolder, { recursive: true, force: true });
|
|
78
|
+
}catch(e){
|
|
79
|
+
console.log("Failed To Clean Up Asset Folder", e);
|
|
80
|
+
}
|
|
81
|
+
throw new Error("Failed To Download Asset");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
import { createHash } from 'crypto';
|
|
87
|
+
import { Transform } from "node:stream";
|
|
88
|
+
class FileInfo extends Transform {
|
|
89
|
+
private hasher = createHash("sha256");
|
|
90
|
+
public hash = "";
|
|
91
|
+
public byteSize = 0;
|
|
92
|
+
constructor(
|
|
93
|
+
private progressListener: (progress: number)=>any,
|
|
94
|
+
){
|
|
95
|
+
super({ readableObjectMode: false, writableObjectMode: false });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
_transform(chunk: Buffer, encoding: string, callback: (err?: Error, data?: Buffer)=>any) {
|
|
99
|
+
this.byteSize += chunk.length;
|
|
100
|
+
this.hasher.update(chunk);
|
|
101
|
+
this.progressListener(this.byteSize);
|
|
102
|
+
callback(void 0, chunk);
|
|
103
|
+
}
|
|
104
|
+
_flush(callback: (err?: Error, data?: Buffer)=>any) {
|
|
105
|
+
this.hash = this.hasher.digest("hex");
|
|
106
|
+
callback();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
|
|
2
|
+
import { MatchLockPublishPiece } from "@match-lock/shared";
|
|
3
|
+
import { join as pathJoin } from "node:path";
|
|
4
|
+
import { stat as fsStat, readFile as fsReadFile } from "node:fs/promises";
|
|
5
|
+
import { JSON_Unknown } from "@match-lock/shared/src/utils/JSON";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
Object as ObjectCaster,
|
|
9
|
+
String as StringCaster,
|
|
10
|
+
Union as UnionCaster,
|
|
11
|
+
Literal as LiteralCaster,
|
|
12
|
+
} from "runtypes";
|
|
13
|
+
|
|
14
|
+
const AssetConfigCaster = ObjectCaster({
|
|
15
|
+
pieceId: StringCaster,
|
|
16
|
+
authorId: StringCaster,
|
|
17
|
+
version: StringCaster,
|
|
18
|
+
assetType: UnionCaster(
|
|
19
|
+
LiteralCaster("logic"),
|
|
20
|
+
LiteralCaster("media"),
|
|
21
|
+
)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
export async function isUpToDate(
|
|
25
|
+
piece: MatchLockPublishPiece,
|
|
26
|
+
assetFolder: string
|
|
27
|
+
){
|
|
28
|
+
const stat = await fsExists(assetFolder);
|
|
29
|
+
if(!stat) return false;
|
|
30
|
+
if(!stat.isDirectory()){
|
|
31
|
+
throw new Error("File exists where an asset folder should be");
|
|
32
|
+
}
|
|
33
|
+
const assetConfigPath = pathJoin(assetFolder, "publish.matchlock.json");
|
|
34
|
+
const assetConfig = await fsExists(assetConfigPath);
|
|
35
|
+
if(!assetConfig){
|
|
36
|
+
throw new Error("Folder exists but is not fully configured");
|
|
37
|
+
}
|
|
38
|
+
if(assetConfig.isDirectory()){
|
|
39
|
+
throw new Error("File exists where an asset config should be");
|
|
40
|
+
}
|
|
41
|
+
const contents = await readJSON(assetConfigPath);
|
|
42
|
+
|
|
43
|
+
if(!AssetConfigCaster.guard(contents)){
|
|
44
|
+
throw new Error("Asset Config is not valid");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if(contents.pieceId !== piece.id){
|
|
48
|
+
throw new Error("Asset Config is not valid");
|
|
49
|
+
}
|
|
50
|
+
if(contents.authorId !== piece.authorId){
|
|
51
|
+
throw new Error("Asset Config is not valid");
|
|
52
|
+
}
|
|
53
|
+
if(contents.version !== assetBundle.version){
|
|
54
|
+
throw new Error("Asset Config is not valid");
|
|
55
|
+
}
|
|
56
|
+
if(contents.assetType !== assetBundle.type){
|
|
57
|
+
throw new Error("Asset Config is not valid");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function fsExists(path: string){
|
|
64
|
+
try {
|
|
65
|
+
return await fsStat(path);
|
|
66
|
+
}catch(e){
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function readJSON(path: string): Promise<JSON_Unknown>{
|
|
72
|
+
const contents = await fsReadFile(path, "utf8");
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(contents);
|
|
75
|
+
}catch(e){
|
|
76
|
+
throw new Error("Asset Config is not valid JSON");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { createWriteStream } from "node:fs";
|
|
2
|
+
import { mkdir as fsMkdir, rm as fsRm } from "node:fs/promises";
|
|
3
|
+
import { join as pathJoin, dirname } from "node:path";
|
|
4
|
+
import { pipeline } from "node:stream/promises";
|
|
5
|
+
import { Transform } from "node:stream";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
|
|
8
|
+
import { MatchLockResolvable } from "@match-lock/shared";
|
|
9
|
+
import { httpStream } from "../../tools/download-methods/http";
|
|
10
|
+
import { torrentStream } from "../../tools/download-methods/torrent";
|
|
11
|
+
import { validateAssetIntegrity } from "./validateAsset";
|
|
12
|
+
import { AssetDownloadOptions, AssetDownloadResult } from "./types";
|
|
13
|
+
|
|
14
|
+
// Direct resolvers without the MatchLockAssetBundle dependency
|
|
15
|
+
const RESOLVERS: Record<MatchLockResolvable["type"], any> = {
|
|
16
|
+
http: httpStream,
|
|
17
|
+
torrent: torrentStream,
|
|
18
|
+
git: () => Promise.reject("Git download not implemented"),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
async function resolveAssetStream(sources: MatchLockResolvable[]) {
|
|
22
|
+
for (const source of sources) {
|
|
23
|
+
try {
|
|
24
|
+
const resolver = RESOLVERS[source.type];
|
|
25
|
+
if (!resolver) throw new Error("Unknown Resolvable Type");
|
|
26
|
+
const result = await resolver(source);
|
|
27
|
+
return result;
|
|
28
|
+
} catch (e) {
|
|
29
|
+
console.log("Failed To Resolve", e);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
throw new Error("Failed To Resolve");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function downloadSingleAsset(
|
|
36
|
+
options: AssetDownloadOptions
|
|
37
|
+
): Promise<AssetDownloadResult> {
|
|
38
|
+
const { piece, filePath, asset, targetDirectory, onProgress } = options;
|
|
39
|
+
|
|
40
|
+
// Create target file path preserving the original file extension
|
|
41
|
+
const targetFilePath = pathJoin(targetDirectory, filePath);
|
|
42
|
+
const targetDir = dirname(targetFilePath);
|
|
43
|
+
|
|
44
|
+
// Ensure target directory exists
|
|
45
|
+
await fsMkdir(targetDir, { recursive: true });
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
// Get the download stream directly from piece sources
|
|
49
|
+
const assetSource = await resolveAssetStream(piece.sources);
|
|
50
|
+
|
|
51
|
+
// Create progress tracking and hash validation transform
|
|
52
|
+
const progressTracker = new ProgressTracker(
|
|
53
|
+
asset.sizeBytes,
|
|
54
|
+
(bytesDownloaded, progress) => {
|
|
55
|
+
if (onProgress) {
|
|
56
|
+
onProgress({
|
|
57
|
+
pieceId: piece.name, // Using name as ID for now
|
|
58
|
+
filePath,
|
|
59
|
+
bytesDownloaded,
|
|
60
|
+
totalBytes: asset.sizeBytes,
|
|
61
|
+
progress
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
// Create write stream
|
|
68
|
+
const writeStream = createWriteStream(targetFilePath);
|
|
69
|
+
|
|
70
|
+
// Download directly without compression handling for now
|
|
71
|
+
// Individual assets are expected to be uncompressed files
|
|
72
|
+
await pipeline(
|
|
73
|
+
assetSource.stream,
|
|
74
|
+
progressTracker,
|
|
75
|
+
writeStream
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// Validate the downloaded file
|
|
79
|
+
validateAssetIntegrity(
|
|
80
|
+
progressTracker.totalBytes,
|
|
81
|
+
progressTracker.hash,
|
|
82
|
+
asset
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
filePath,
|
|
87
|
+
localPath: targetFilePath,
|
|
88
|
+
asset
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
} catch (error) {
|
|
92
|
+
// Clean up on failure
|
|
93
|
+
try {
|
|
94
|
+
await fsRm(targetFilePath, { force: true });
|
|
95
|
+
} catch (cleanupError) {
|
|
96
|
+
console.warn("Failed to clean up file after download error:", cleanupError);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
throw new Error(`Failed to download asset ${filePath}: ${error}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
class ProgressTracker extends Transform {
|
|
104
|
+
private hasher = createHash("sha256");
|
|
105
|
+
public hash = "";
|
|
106
|
+
public totalBytes = 0;
|
|
107
|
+
|
|
108
|
+
constructor(
|
|
109
|
+
private expectedSize: number,
|
|
110
|
+
private progressCallback: (bytesDownloaded: number, progress: number) => void
|
|
111
|
+
) {
|
|
112
|
+
super({ readableObjectMode: false, writableObjectMode: false });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
_transform(chunk: Buffer, encoding: string, callback: (err?: Error, data?: Buffer) => any) {
|
|
116
|
+
this.totalBytes += chunk.length;
|
|
117
|
+
this.hasher.update(chunk);
|
|
118
|
+
|
|
119
|
+
const progress = this.totalBytes / this.expectedSize;
|
|
120
|
+
this.progressCallback(this.totalBytes, Math.min(progress, 1));
|
|
121
|
+
|
|
122
|
+
callback(undefined, chunk);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
_flush(callback: (err?: Error, data?: Buffer) => any) {
|
|
126
|
+
this.hash = this.hasher.digest("hex");
|
|
127
|
+
callback();
|
|
128
|
+
}
|
|
129
|
+
}
|