@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.
Files changed (174) hide show
  1. package/LICENSE.md +11 -0
  2. package/dist/authentication.js +53 -0
  3. package/dist/authentication.js.map +1 -0
  4. package/dist/config/constants.js +12 -0
  5. package/dist/config/constants.js.map +1 -0
  6. package/dist/config/findFile.js +32 -0
  7. package/dist/config/findFile.js.map +1 -0
  8. package/dist/config/index.js +29 -0
  9. package/dist/config/index.js.map +1 -0
  10. package/dist/config/manage.js +41 -0
  11. package/dist/config/manage.js.map +1 -0
  12. package/dist/cors.js +20 -0
  13. package/dist/cors.js.map +1 -0
  14. package/dist/editor-support/index.js +247 -0
  15. package/dist/editor-support/index.js.map +1 -0
  16. package/dist/handle-room/version-1/file-routes.js +53 -0
  17. package/dist/handle-room/version-1/file-routes.js.map +1 -0
  18. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/MultiAbort.js +76 -0
  19. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/MultiAbort.js.map +1 -0
  20. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/ensurePieceExists.js +218 -0
  21. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/ensurePieceExists.js.map +1 -0
  22. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/getVersions.js +18 -0
  23. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/getVersions.js.map +1 -0
  24. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/index.js +16 -0
  25. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/index.js.map +1 -0
  26. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/schema.js +370 -0
  27. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/schema.js.map +1 -0
  28. package/dist/handle-room/version-1/globals/FolderDB/index.js +18 -0
  29. package/dist/handle-room/version-1/globals/FolderDB/index.js.map +1 -0
  30. package/dist/handle-room/version-1/globals/FolderDB/types.js +3 -0
  31. package/dist/handle-room/version-1/globals/FolderDB/types.js.map +1 -0
  32. package/dist/handle-room/version-1/globals/Room/WebsocketRoom.js +95 -0
  33. package/dist/handle-room/version-1/globals/Room/WebsocketRoom.js.map +1 -0
  34. package/dist/handle-room/version-1/globals/Room/index.js +18 -0
  35. package/dist/handle-room/version-1/globals/Room/index.js.map +1 -0
  36. package/dist/handle-room/version-1/globals/Room/room.js +3 -0
  37. package/dist/handle-room/version-1/globals/Room/room.js.map +1 -0
  38. package/dist/handle-room/version-1/globals/index.js +20 -0
  39. package/dist/handle-room/version-1/globals/index.js.map +1 -0
  40. package/dist/handle-room/version-1/globals/types.js +3 -0
  41. package/dist/handle-room/version-1/globals/types.js.map +1 -0
  42. package/dist/handle-room/version-1/handleDownloads/DownloadTracker.js +83 -0
  43. package/dist/handle-room/version-1/handleDownloads/DownloadTracker.js.map +1 -0
  44. package/dist/handle-room/version-1/handleDownloads/index.js +35 -0
  45. package/dist/handle-room/version-1/handleDownloads/index.js.map +1 -0
  46. package/dist/handle-room/version-1/handleDownloads/types.js +3 -0
  47. package/dist/handle-room/version-1/handleDownloads/types.js.map +1 -0
  48. package/dist/handle-room/version-1/handleRoomSelections/encryption.js +21 -0
  49. package/dist/handle-room/version-1/handleRoomSelections/encryption.js.map +1 -0
  50. package/dist/handle-room/version-1/handleRoomSelections/random.js +12 -0
  51. package/dist/handle-room/version-1/handleRoomSelections/random.js.map +1 -0
  52. package/dist/handle-room/version-1/index.js +28 -0
  53. package/dist/handle-room/version-1/index.js.map +1 -0
  54. package/dist/handle-room/version-1/piece-sort.js +71 -0
  55. package/dist/handle-room/version-1/piece-sort.js.map +1 -0
  56. package/dist/handle-room/version-1/room-handler-bridge/exchange-and-download-selections.js +89 -0
  57. package/dist/handle-room/version-1/room-handler-bridge/exchange-and-download-selections.js.map +1 -0
  58. package/dist/handle-room/version-1/room-handler-bridge/http-handler.js +19 -0
  59. package/dist/handle-room/version-1/room-handler-bridge/http-handler.js.map +1 -0
  60. package/dist/handle-room/version-1/room-handler-bridge/steps.js +170 -0
  61. package/dist/handle-room/version-1/room-handler-bridge/steps.js.map +1 -0
  62. package/dist/handle-room/version-1/room-handler-bridge/ws-handler.js +28 -0
  63. package/dist/handle-room/version-1/room-handler-bridge/ws-handler.js.map +1 -0
  64. package/dist/handle-room/version-1/schema/count.js +16 -0
  65. package/dist/handle-room/version-1/schema/count.js.map +1 -0
  66. package/dist/handle-room/version-1/schema/lock.js +46 -0
  67. package/dist/handle-room/version-1/schema/lock.js.map +1 -0
  68. package/dist/handle-room/version-1/schema/selected.js +13 -0
  69. package/dist/handle-room/version-1/schema/selected.js.map +1 -0
  70. package/dist/handle-room/version-1/select.js +94 -0
  71. package/dist/handle-room/version-1/select.js.map +1 -0
  72. package/dist/index.js +180 -0
  73. package/dist/index.js.map +1 -0
  74. package/dist/server.js +88 -0
  75. package/dist/server.js.map +1 -0
  76. package/dist/utils/fs.js +32 -0
  77. package/dist/utils/fs.js.map +1 -0
  78. package/dist/utils/http-router.js +49 -0
  79. package/dist/utils/http-router.js.map +1 -0
  80. package/dist/utils/pagination.js +23 -0
  81. package/dist/utils/pagination.js.map +1 -0
  82. package/dist/utils/websocket-router.js +15 -0
  83. package/dist/utils/websocket-router.js.map +1 -0
  84. package/package.json +66 -0
  85. package/src/authentication.ts +48 -0
  86. package/src/config/constants.ts +7 -0
  87. package/src/config/findFile.ts +34 -0
  88. package/src/config/index.ts +17 -0
  89. package/src/config/manage.ts +48 -0
  90. package/src/cors.ts +23 -0
  91. package/src/editor-support/index.ts +289 -0
  92. package/src/handle-room/version-0/piece-download/availableGamePiece/index.ts +109 -0
  93. package/src/handle-room/version-0/piece-download/availableGamePiece/isUpToDate.ts +78 -0
  94. package/src/handle-room/version-0/piece-download/downloadPieceAssets/downloadSingleAsset.ts +129 -0
  95. package/src/handle-room/version-0/piece-download/downloadPieceAssets/example.ts +116 -0
  96. package/src/handle-room/version-0/piece-download/downloadPieceAssets/index.ts +144 -0
  97. package/src/handle-room/version-0/piece-download/downloadPieceAssets/types.ts +32 -0
  98. package/src/handle-room/version-0/piece-download/downloadPieceAssets/validateAssetFile.ts +54 -0
  99. package/src/handle-room/version-0/piece-download/index.ts +5 -0
  100. package/src/handle-room/version-0/piece-selection/constants.ts +39 -0
  101. package/src/handle-room/version-0/piece-selection/handleRoomSelections.ts +202 -0
  102. package/src/handle-room/version-0/piece-selection/index.ts +31 -0
  103. package/src/handle-room/version-0/piece-selection/roomErrors.ts +25 -0
  104. package/src/handle-room/version-0/piece-selection/steps/encrypton.ts +70 -0
  105. package/src/handle-room/version-0/piece-selection/steps/final-selection.ts +72 -0
  106. package/src/handle-room/version-0/piece-selection/steps/player-selection.ts +100 -0
  107. package/src/handle-room/version-1/constants.ts +44 -0
  108. package/src/handle-room/version-1/file-routes.ts +68 -0
  109. package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/MultiAbort.ts +83 -0
  110. package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/ensurePieceExists.ts +317 -0
  111. package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/getVersions.ts +31 -0
  112. package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/index.ts +13 -0
  113. package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/schema.ts +547 -0
  114. package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/types.ts +6 -0
  115. package/src/handle-room/version-1/globals/FolderDB/index.ts +3 -0
  116. package/src/handle-room/version-1/globals/FolderDB/types.ts +72 -0
  117. package/src/handle-room/version-1/globals/Room/WebsocketRoom.ts +118 -0
  118. package/src/handle-room/version-1/globals/Room/index.ts +3 -0
  119. package/src/handle-room/version-1/globals/Room/room.ts +8 -0
  120. package/src/handle-room/version-1/globals/index.ts +4 -0
  121. package/src/handle-room/version-1/globals/types.ts +7 -0
  122. package/src/handle-room/version-1/handleDownloads/DownloadTracker.ts +98 -0
  123. package/src/handle-room/version-1/handleDownloads/index.ts +41 -0
  124. package/src/handle-room/version-1/handleDownloads/types.ts +9 -0
  125. package/src/handle-room/version-1/handleRoomSelections/encryption.ts +29 -0
  126. package/src/handle-room/version-1/handleRoomSelections/random.ts +9 -0
  127. package/src/handle-room/version-1/index.ts +33 -0
  128. package/src/handle-room/version-1/piece-sort.ts +96 -0
  129. package/src/handle-room/version-1/room-handler-bridge/exchange-and-download-selections.ts +125 -0
  130. package/src/handle-room/version-1/room-handler-bridge/http-handler.ts +16 -0
  131. package/src/handle-room/version-1/room-handler-bridge/server.pseudo.ts +85 -0
  132. package/src/handle-room/version-1/room-handler-bridge/steps.ts +255 -0
  133. package/src/handle-room/version-1/room-handler-bridge/ws-handler.ts +30 -0
  134. package/src/handle-room/version-1/room-handler-state/RoomState.ts +132 -0
  135. package/src/handle-room/version-1/room-handler-state/bind-to-websocket.ts +93 -0
  136. package/src/handle-room/version-1/room-handler-state/handleRoomSelections.ts +71 -0
  137. package/src/handle-room/version-1/room-handler-state/handleRoomWithErrors.ts +55 -0
  138. package/src/handle-room/version-1/room-handler-state/roomErrors.ts +35 -0
  139. package/src/handle-room/version-1/room-handler-state/user-selection.ts +98 -0
  140. package/src/handle-room/version-1/schema/count.ts +13 -0
  141. package/src/handle-room/version-1/schema/lock.ts +47 -0
  142. package/src/handle-room/version-1/schema/selected.ts +14 -0
  143. package/src/handle-room/version-1/select.ts +130 -0
  144. package/src/handle-room/version-1/validateUnknown.ts +11 -0
  145. package/src/index.old.ts +71 -0
  146. package/src/index.ts +231 -0
  147. package/src/server.ts +85 -0
  148. package/src/utils/fs.ts +43 -0
  149. package/src/utils/http-router.ts +55 -0
  150. package/src/utils/pagination.ts +17 -0
  151. package/src/utils/websocket-router.ts +23 -0
  152. package/tests/integration/editor-pieces-search.test.ts +147 -0
  153. package/tests/integration/game-complete.test.ts +72 -0
  154. package/tests/integration/handle-full-selection.test.ts +143 -0
  155. package/tests/integration/helpers/bridge.ts +22 -0
  156. package/tests/integration/helpers/fakeFolderDB.ts +37 -0
  157. package/tests/integration/helpers/fakePluginRuntime.ts +46 -0
  158. package/tests/integration/helpers/lockConfig.ts +71 -0
  159. package/tests/integration/helpers/make-tar-gz.ts +41 -0
  160. package/tests/integration/helpers/piece.ts +98 -0
  161. package/tests/integration/helpers/plugin-dir.ts +26 -0
  162. package/tests/integration/helpers/room.ts +42 -0
  163. package/tests/integration/helpers/server.ts +51 -0
  164. package/tests/integration/helpers/validLockConfig.ts +80 -0
  165. package/tests/integration/piece-download-and-list.test.ts +194 -0
  166. package/tests/integration/piece-ensure-ws.test.ts +256 -0
  167. package/tests/integration/piece-files.test.ts +139 -0
  168. package/tests/integration/piece-sort-list.test.ts +113 -0
  169. package/tests/integration/protocol.test.ts +129 -0
  170. package/tests/integration/root-and-auth.test.ts +100 -0
  171. package/tests/integration/sync-dl.test.ts +62 -0
  172. package/tests/tsconfig.json +14 -0
  173. package/tsconfig.json +22 -0
  174. 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
+ }