@cjhyy/code-shell-server 0.9.10 → 0.9.11
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/dist/index.panels.d.ts +8 -0
- package/dist/index.panels.js +8 -0
- package/dist/panels/bridge-contract.d.ts +72 -0
- package/dist/panels/bridge-contract.js +92 -0
- package/dist/panels/connections.d.ts +31 -0
- package/dist/panels/connections.js +118 -0
- package/dist/panels/process-entry.d.ts +2 -0
- package/dist/panels/process-entry.js +52 -0
- package/dist/panels/process-service.d.ts +61 -0
- package/dist/panels/process-service.js +367 -33
- package/dist/panels/process-state.d.ts +58 -0
- package/dist/panels/process-state.js +87 -0
- package/dist/panels/resources/directories.d.ts +12 -0
- package/dist/panels/resources/directories.js +89 -0
- package/dist/panels/resources/library.d.ts +63 -0
- package/dist/panels/resources/library.js +423 -0
- package/dist/panels/resources/service.d.ts +45 -0
- package/dist/panels/resources/service.js +317 -0
- package/dist/panels/resources/storage.d.ts +10 -0
- package/dist/panels/resources/storage.js +102 -0
- package/dist/panels/resources/types.d.ts +16 -0
- package/dist/panels/resources/types.js +1 -0
- package/dist/panels/resources/uploads.d.ts +64 -0
- package/dist/panels/resources/uploads.js +527 -0
- package/dist/panels/runtime.d.ts +1 -1
- package/dist/panels/runtime.js +137 -15
- package/dist/panels/tool-executor.d.ts +58 -0
- package/dist/panels/tool-executor.js +421 -0
- package/dist/panels/tool-jobs-storage.d.ts +16 -0
- package/dist/panels/tool-jobs-storage.js +169 -0
- package/dist/panels/tool-jobs.d.ts +103 -0
- package/dist/panels/tool-jobs.js +530 -0
- package/package.json +5 -5
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { open, readdir, rm } from "node:fs/promises";
|
|
4
|
+
import { basename, join } from "node:path";
|
|
5
|
+
import { mediaSourceIdentity } from "./library.js";
|
|
6
|
+
import { mediaDirectory, mediaScopeKey, normalizeMediaScope, readMediaJson, writeMediaJson, } from "./storage.js";
|
|
7
|
+
const SESSION_ID = /^upload-[a-f0-9-]{36}$/;
|
|
8
|
+
const MAX_FILE_BYTES = 20 * 1024 ** 3;
|
|
9
|
+
const CHUNK_BYTES = 32 * 1024;
|
|
10
|
+
function object(value, allowed) {
|
|
11
|
+
if (!value ||
|
|
12
|
+
typeof value !== "object" ||
|
|
13
|
+
Array.isArray(value) ||
|
|
14
|
+
Object.keys(value).some((key) => !allowed.includes(key)))
|
|
15
|
+
throw new Error("Invalid resource upload parameters");
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
function sessionId(value) {
|
|
19
|
+
if (typeof value !== "string" || !SESSION_ID.test(value))
|
|
20
|
+
throw new Error("Invalid resource upload session ID");
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
function integer(value, min, max, label) {
|
|
24
|
+
if (!Number.isSafeInteger(value) || value < min || value > max)
|
|
25
|
+
throw new Error(`Invalid resource upload ${label}`);
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
function mimeType(value) {
|
|
29
|
+
if (value === undefined)
|
|
30
|
+
return "application/octet-stream";
|
|
31
|
+
if (typeof value !== "string" ||
|
|
32
|
+
value.length > 200 ||
|
|
33
|
+
!/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/i.test(value))
|
|
34
|
+
throw new Error("Invalid resource MIME type");
|
|
35
|
+
return value.toLowerCase();
|
|
36
|
+
}
|
|
37
|
+
function nameFor(value, _mime) {
|
|
38
|
+
if (typeof value !== "string" || !value || value.length > 240 || /[\x00-\x1f\x7f]/.test(value))
|
|
39
|
+
throw new Error("Invalid resource name");
|
|
40
|
+
return basename(value).replaceAll("\\", "_");
|
|
41
|
+
}
|
|
42
|
+
function active(record) {
|
|
43
|
+
return record.state === "uploading" || record.state === "finishing";
|
|
44
|
+
}
|
|
45
|
+
function invalidMedia() {
|
|
46
|
+
return Object.assign(new Error("Resource upload bytes failed integrity validation"), {
|
|
47
|
+
name: "InvalidResourceUploadError",
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
/** Scope-bound, streaming resource uploads. Acknowledged chunks and final results survive Host restart. */
|
|
51
|
+
export class ResourceUploadIngest {
|
|
52
|
+
options;
|
|
53
|
+
records = new Map();
|
|
54
|
+
locks = new Map();
|
|
55
|
+
hashes = new Map();
|
|
56
|
+
finishes = new Map();
|
|
57
|
+
cancellations = new Set();
|
|
58
|
+
initialization;
|
|
59
|
+
cleanupTimer;
|
|
60
|
+
stopping = false;
|
|
61
|
+
constructor(options) {
|
|
62
|
+
this.options = {
|
|
63
|
+
maxChunkBytes: CHUNK_BYTES,
|
|
64
|
+
maxActivePerScope: 4,
|
|
65
|
+
maxActiveSessions: 64,
|
|
66
|
+
ttlMs: 24 * 60 * 60 * 1000,
|
|
67
|
+
finishTimeoutMs: 10 * 60 * 1000,
|
|
68
|
+
now: Date.now,
|
|
69
|
+
...options,
|
|
70
|
+
maxFileBytes: options.maxFileBytes ?? MAX_FILE_BYTES,
|
|
71
|
+
};
|
|
72
|
+
integer(this.options.maxFileBytes, 1, MAX_FILE_BYTES, "file budget");
|
|
73
|
+
integer(this.options.maxChunkBytes, 1, CHUNK_BYTES, "chunk budget");
|
|
74
|
+
integer(this.options.maxActivePerScope, 1, 16, "scope session budget");
|
|
75
|
+
integer(this.options.maxActiveSessions, 1, 256, "session budget");
|
|
76
|
+
integer(this.options.ttlMs, 1, 7 * 86400 * 1000, "expiry");
|
|
77
|
+
integer(this.options.finishTimeoutMs, 1, 30 * 60 * 1000, "finish timeout");
|
|
78
|
+
}
|
|
79
|
+
key(scope, id) {
|
|
80
|
+
return `${mediaScopeKey(scope)}:${id}`;
|
|
81
|
+
}
|
|
82
|
+
async authorize(scope) {
|
|
83
|
+
normalizeMediaScope(scope);
|
|
84
|
+
if (!(await this.options.isScopeAuthorized(scope)))
|
|
85
|
+
throw new Error("Resource upload access is no longer authorized");
|
|
86
|
+
if (this.stopping)
|
|
87
|
+
throw new Error("Resource upload service is shutting down");
|
|
88
|
+
}
|
|
89
|
+
async directory(scope, id, create = false) {
|
|
90
|
+
return mediaDirectory(this.options.rootDirectory, ["scopes", mediaScopeKey(scope), "uploads", sessionId(id)], create);
|
|
91
|
+
}
|
|
92
|
+
async locked(key, work) {
|
|
93
|
+
const operation = (this.locks.get(key) ?? Promise.resolve()).catch(() => { }).then(work);
|
|
94
|
+
this.locks.set(key, operation);
|
|
95
|
+
try {
|
|
96
|
+
return await operation;
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
if (this.locks.get(key) === operation)
|
|
100
|
+
this.locks.delete(key);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
view(record) {
|
|
104
|
+
return {
|
|
105
|
+
sessionId: record.sessionId,
|
|
106
|
+
mimeType: record.mimeType,
|
|
107
|
+
state: record.state,
|
|
108
|
+
receivedBytes: record.receivedBytes,
|
|
109
|
+
nextSequence: record.nextSequence,
|
|
110
|
+
maxChunkBytes: this.options.maxChunkBytes,
|
|
111
|
+
maxFileBytes: this.options.maxFileBytes,
|
|
112
|
+
expiresAt: record.expiresAt,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
async save(record) {
|
|
116
|
+
await writeMediaJson(join(await this.directory(record.scope, record.sessionId), "session.json"), record);
|
|
117
|
+
this.records.set(this.key(record.scope, record.sessionId), record);
|
|
118
|
+
}
|
|
119
|
+
validateRecord(raw, key, id) {
|
|
120
|
+
const record = raw;
|
|
121
|
+
if (!record ||
|
|
122
|
+
record.schemaVersion !== 1 ||
|
|
123
|
+
record.sessionId !== id ||
|
|
124
|
+
mediaScopeKey(record.scope) !== key ||
|
|
125
|
+
!["uploading", "finishing", "finished", "cancelled", "failed"].includes(record.state) ||
|
|
126
|
+
record.mimeType !== mimeType(record.mimeType) ||
|
|
127
|
+
record.name !== nameFor(record.name, record.mimeType) ||
|
|
128
|
+
!/^[a-f0-9]{64}$/.test(record.contentSha256) ||
|
|
129
|
+
(record.expectedSha256 !== undefined && !/^[a-f0-9]{64}$/.test(record.expectedSha256)))
|
|
130
|
+
throw new Error("Invalid stored resource upload session");
|
|
131
|
+
integer(record.receivedBytes, 0, this.options.maxFileBytes, "stored size");
|
|
132
|
+
integer(record.nextSequence, 0, Number.MAX_SAFE_INTEGER, "stored sequence");
|
|
133
|
+
integer(record.createdAt, 0, Number.MAX_SAFE_INTEGER, "created time");
|
|
134
|
+
integer(record.updatedAt, record.createdAt, Number.MAX_SAFE_INTEGER, "updated time");
|
|
135
|
+
integer(record.expiresAt, record.updatedAt, Number.MAX_SAFE_INTEGER, "stored expiry");
|
|
136
|
+
if (record.expectedBytes !== undefined)
|
|
137
|
+
integer(record.expectedBytes, Math.max(1, record.receivedBytes), this.options.maxFileBytes, "expected size");
|
|
138
|
+
if (record.lastChunk) {
|
|
139
|
+
integer(record.lastChunk.offset, 0, record.receivedBytes, "stored chunk offset");
|
|
140
|
+
integer(record.lastChunk.bytes, 1, CHUNK_BYTES, "stored chunk size");
|
|
141
|
+
if (record.lastChunk.sequence !== record.nextSequence - 1 ||
|
|
142
|
+
record.lastChunk.offset + record.lastChunk.bytes !== record.receivedBytes ||
|
|
143
|
+
!/^[a-f0-9]{64}$/.test(record.lastChunk.sha256))
|
|
144
|
+
throw new Error("Invalid stored resource upload chunk");
|
|
145
|
+
}
|
|
146
|
+
else if (record.nextSequence !== 0 || record.receivedBytes !== 0)
|
|
147
|
+
throw new Error("Stored resource upload lacks its last acknowledged chunk");
|
|
148
|
+
if (record.state === "finished" &&
|
|
149
|
+
(!record.result || !/^asset-[a-f0-9]{64}$/.test(record.result.asset?.id)))
|
|
150
|
+
throw new Error("Invalid finished resource upload");
|
|
151
|
+
return record;
|
|
152
|
+
}
|
|
153
|
+
initialize() {
|
|
154
|
+
this.initialization ??= (async () => {
|
|
155
|
+
const scopes = await mediaDirectory(this.options.rootDirectory, ["scopes"]);
|
|
156
|
+
for (const scopeEntry of await readdir(scopes, { withFileTypes: true })) {
|
|
157
|
+
if (!scopeEntry.isDirectory() || !/^[a-f0-9]{64}$/.test(scopeEntry.name))
|
|
158
|
+
continue;
|
|
159
|
+
const directory = await mediaDirectory(this.options.rootDirectory, ["scopes", scopeEntry.name, "uploads"], false).catch(() => null);
|
|
160
|
+
if (!directory)
|
|
161
|
+
continue;
|
|
162
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
163
|
+
if (!entry.isDirectory() || !SESSION_ID.test(entry.name))
|
|
164
|
+
continue;
|
|
165
|
+
const child = await mediaDirectory(this.options.rootDirectory, ["scopes", scopeEntry.name, "uploads", entry.name], false);
|
|
166
|
+
try {
|
|
167
|
+
const record = this.validateRecord(await readMediaJson(join(child, "session.json")), scopeEntry.name, entry.name);
|
|
168
|
+
if (record.expiresAt <= this.options.now()) {
|
|
169
|
+
await rm(child, { recursive: true, force: true });
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
// Finalization only reads immutable uploaded bytes and publishes by
|
|
173
|
+
// content hash, so an explicit finish retry is safe after a crash.
|
|
174
|
+
if (record.state === "finishing") {
|
|
175
|
+
record.state = "uploading";
|
|
176
|
+
await this.save(record);
|
|
177
|
+
}
|
|
178
|
+
this.records.set(this.key(record.scope, record.sessionId), record);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
await rm(child, { recursive: true, force: true });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (!this.stopping) {
|
|
186
|
+
this.cleanupTimer = setInterval(() => {
|
|
187
|
+
void this.cleanupExpired().catch(() => { });
|
|
188
|
+
}, Math.max(1000, Math.min(60000, this.options.ttlMs)));
|
|
189
|
+
this.cleanupTimer.unref();
|
|
190
|
+
}
|
|
191
|
+
})();
|
|
192
|
+
return this.initialization;
|
|
193
|
+
}
|
|
194
|
+
async record(scope, id) {
|
|
195
|
+
await this.authorize(scope);
|
|
196
|
+
const record = this.records.get(this.key(scope, id));
|
|
197
|
+
if (!record)
|
|
198
|
+
throw new Error("Unknown resource upload session in this project");
|
|
199
|
+
if (record.expiresAt <= this.options.now()) {
|
|
200
|
+
await this.remove(record);
|
|
201
|
+
throw new Error("Resource upload session expired; start another resource upload");
|
|
202
|
+
}
|
|
203
|
+
await this.directory(scope, id);
|
|
204
|
+
return record;
|
|
205
|
+
}
|
|
206
|
+
async remove(record) {
|
|
207
|
+
const key = this.key(record.scope, record.sessionId);
|
|
208
|
+
this.finishes.get(key)?.abort();
|
|
209
|
+
const directory = await this.directory(record.scope, record.sessionId).catch(() => null);
|
|
210
|
+
if (directory)
|
|
211
|
+
await rm(directory, { recursive: true, force: true });
|
|
212
|
+
this.records.delete(key);
|
|
213
|
+
this.hashes.delete(key);
|
|
214
|
+
}
|
|
215
|
+
async cleanupExpired() {
|
|
216
|
+
await this.initialize();
|
|
217
|
+
for (const record of [...this.records.values()])
|
|
218
|
+
if (record.expiresAt <= this.options.now()) {
|
|
219
|
+
const key = this.key(record.scope, record.sessionId);
|
|
220
|
+
this.finishes.get(key)?.abort();
|
|
221
|
+
await this.locked(key, () => this.remove(record));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
async begin(scope, raw) {
|
|
225
|
+
await this.authorize(scope);
|
|
226
|
+
await this.initialize();
|
|
227
|
+
const input = object(raw, ["mimeType", "name", "expectedBytes", "expectedSha256"]);
|
|
228
|
+
const mime = mimeType(input.mimeType), name = nameFor(input.name, mime);
|
|
229
|
+
const expectedBytes = input.expectedBytes === undefined
|
|
230
|
+
? undefined
|
|
231
|
+
: integer(input.expectedBytes, 1, this.options.maxFileBytes, "expected size");
|
|
232
|
+
if (input.expectedSha256 !== undefined &&
|
|
233
|
+
(typeof input.expectedSha256 !== "string" || !/^[a-f0-9]{64}$/.test(input.expectedSha256)))
|
|
234
|
+
throw new Error("Invalid expected resource digest");
|
|
235
|
+
await this.cleanupExpired();
|
|
236
|
+
return this.locked("begin", async () => {
|
|
237
|
+
await this.authorize(scope);
|
|
238
|
+
// Completed/failed receipts are small but also bounded until their TTL.
|
|
239
|
+
if (this.records.size >= 2048)
|
|
240
|
+
throw new Error("Too many retained resource upload sessions; wait for expired sessions to clear");
|
|
241
|
+
const all = [...this.records.values()].filter(active);
|
|
242
|
+
if (all.length >= this.options.maxActiveSessions ||
|
|
243
|
+
all.filter((record) => mediaScopeKey(record.scope) === mediaScopeKey(scope)).length >=
|
|
244
|
+
this.options.maxActivePerScope)
|
|
245
|
+
throw new Error("Too many active resource uploads; finish or cancel one first");
|
|
246
|
+
const id = `upload-${randomUUID()}`, time = this.options.now();
|
|
247
|
+
const directory = await this.directory(scope, id, true);
|
|
248
|
+
const handle = await open(join(directory, "content.partial"), "wx", 0o600);
|
|
249
|
+
await handle.close();
|
|
250
|
+
const record = {
|
|
251
|
+
schemaVersion: 1,
|
|
252
|
+
scope: normalizeMediaScope(scope),
|
|
253
|
+
sessionId: id,
|
|
254
|
+
mimeType: mime,
|
|
255
|
+
name,
|
|
256
|
+
state: "uploading",
|
|
257
|
+
receivedBytes: 0,
|
|
258
|
+
nextSequence: 0,
|
|
259
|
+
contentSha256: createHash("sha256").digest("hex"),
|
|
260
|
+
...(input.expectedSha256 === undefined
|
|
261
|
+
? {}
|
|
262
|
+
: { expectedSha256: input.expectedSha256 }),
|
|
263
|
+
createdAt: time,
|
|
264
|
+
updatedAt: time,
|
|
265
|
+
expiresAt: time + this.options.ttlMs,
|
|
266
|
+
...(expectedBytes === undefined ? {} : { expectedBytes }),
|
|
267
|
+
};
|
|
268
|
+
try {
|
|
269
|
+
await this.authorize(scope);
|
|
270
|
+
await this.save(record);
|
|
271
|
+
await this.authorize(scope);
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
await rm(directory, { recursive: true, force: true });
|
|
275
|
+
this.records.delete(this.key(scope, id));
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
return this.view(record);
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
async get(scope, raw) {
|
|
282
|
+
await this.initialize();
|
|
283
|
+
const id = sessionId(object(raw, ["sessionId"]).sessionId);
|
|
284
|
+
return this.locked(this.key(scope, id), async () => this.view(await this.record(scope, id)));
|
|
285
|
+
}
|
|
286
|
+
async write(scope, raw) {
|
|
287
|
+
await this.authorize(scope);
|
|
288
|
+
await this.initialize();
|
|
289
|
+
const input = object(raw, ["sessionId", "sequence", "offset", "dataBase64"]), id = sessionId(input.sessionId);
|
|
290
|
+
const sequence = integer(input.sequence, 0, Number.MAX_SAFE_INTEGER, "sequence"), offset = integer(input.offset, 0, this.options.maxFileBytes, "offset");
|
|
291
|
+
if (typeof input.dataBase64 !== "string" ||
|
|
292
|
+
input.dataBase64.length > Math.ceil(this.options.maxChunkBytes / 3) * 4 ||
|
|
293
|
+
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(input.dataBase64))
|
|
294
|
+
throw new Error("Resource upload chunks must use bounded canonical base64");
|
|
295
|
+
const bytes = Buffer.from(input.dataBase64, "base64");
|
|
296
|
+
if (!bytes.length ||
|
|
297
|
+
bytes.length > this.options.maxChunkBytes ||
|
|
298
|
+
bytes.toString("base64") !== input.dataBase64)
|
|
299
|
+
throw new Error("Invalid resource upload chunk size or encoding");
|
|
300
|
+
const digest = createHash("sha256").update(bytes).digest("hex"), key = this.key(scope, id);
|
|
301
|
+
return this.locked(key, async () => {
|
|
302
|
+
const record = await this.record(scope, id);
|
|
303
|
+
if (record.state !== "uploading")
|
|
304
|
+
throw new Error("Resource upload no longer accepts chunks");
|
|
305
|
+
if (record.lastChunk?.sequence === sequence &&
|
|
306
|
+
record.lastChunk.offset === offset &&
|
|
307
|
+
record.lastChunk.bytes === bytes.length &&
|
|
308
|
+
record.lastChunk.sha256 === digest)
|
|
309
|
+
return this.view(record);
|
|
310
|
+
if (sequence !== record.nextSequence || offset !== record.receivedBytes)
|
|
311
|
+
throw new Error("Resource upload chunks must arrive in sequence at the acknowledged offset");
|
|
312
|
+
if (offset + bytes.length > (record.expectedBytes ?? this.options.maxFileBytes))
|
|
313
|
+
throw new Error("Resource upload exceeds its file budget");
|
|
314
|
+
const handle = await open(join(await this.directory(scope, id), "content.partial"), constants.O_RDWR | (constants.O_NOFOLLOW ?? 0));
|
|
315
|
+
try {
|
|
316
|
+
const info = await handle.stat();
|
|
317
|
+
if (!info.isFile() || info.size < record.receivedBytes)
|
|
318
|
+
throw new Error("Stored resource upload bytes are incomplete");
|
|
319
|
+
// A crash between data fsync and metadata commit can leave an unacknowledged
|
|
320
|
+
// tail. Discard only that tail before accepting the client's retry.
|
|
321
|
+
if (info.size > record.receivedBytes)
|
|
322
|
+
await handle.truncate(record.receivedBytes);
|
|
323
|
+
let hash = this.hashes.get(key);
|
|
324
|
+
if (!hash || hash.bytes !== offset) {
|
|
325
|
+
hash = { bytes: 0, hash: createHash("sha256") };
|
|
326
|
+
const buffer = Buffer.allocUnsafe(256 * 1024);
|
|
327
|
+
while (hash.bytes < offset) {
|
|
328
|
+
const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, offset - hash.bytes), hash.bytes);
|
|
329
|
+
if (!bytesRead)
|
|
330
|
+
throw new Error("Stored resource upload stopped making progress");
|
|
331
|
+
hash.hash.update(buffer.subarray(0, bytesRead));
|
|
332
|
+
hash.bytes += bytesRead;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (hash.hash.copy().digest("hex") !== record.contentSha256)
|
|
336
|
+
throw invalidMedia();
|
|
337
|
+
let written = 0;
|
|
338
|
+
while (written < bytes.length) {
|
|
339
|
+
await this.authorize(scope);
|
|
340
|
+
const result = await handle.write(bytes, written, bytes.length - written, offset + written);
|
|
341
|
+
if (!result.bytesWritten)
|
|
342
|
+
throw new Error("Resource upload write stopped making progress");
|
|
343
|
+
written += result.bytesWritten;
|
|
344
|
+
}
|
|
345
|
+
await handle.sync();
|
|
346
|
+
await this.authorize(scope);
|
|
347
|
+
const time = this.options.now();
|
|
348
|
+
const next = {
|
|
349
|
+
...record,
|
|
350
|
+
receivedBytes: offset + bytes.length,
|
|
351
|
+
nextSequence: sequence + 1,
|
|
352
|
+
updatedAt: time,
|
|
353
|
+
expiresAt: time + this.options.ttlMs,
|
|
354
|
+
lastChunk: { sequence, offset, bytes: bytes.length, sha256: digest },
|
|
355
|
+
contentSha256: hash.hash.copy().update(bytes).digest("hex"),
|
|
356
|
+
};
|
|
357
|
+
await this.save(next);
|
|
358
|
+
hash.hash.update(bytes);
|
|
359
|
+
hash.bytes += bytes.length;
|
|
360
|
+
this.hashes.set(key, hash);
|
|
361
|
+
return this.view(next);
|
|
362
|
+
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
this.hashes.delete(key);
|
|
365
|
+
await handle.truncate(record.receivedBytes).catch(() => { });
|
|
366
|
+
throw error;
|
|
367
|
+
}
|
|
368
|
+
finally {
|
|
369
|
+
await handle.close();
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
async finish(scope, raw, externalSignal) {
|
|
374
|
+
externalSignal?.throwIfAborted();
|
|
375
|
+
await this.authorize(scope);
|
|
376
|
+
await this.initialize();
|
|
377
|
+
const id = sessionId(object(raw, ["sessionId"]).sessionId), key = this.key(scope, id);
|
|
378
|
+
return this.locked(key, async () => {
|
|
379
|
+
const record = await this.record(scope, id);
|
|
380
|
+
if (record.state === "finished") {
|
|
381
|
+
await this.options.library.get(scope, record.result.asset.id);
|
|
382
|
+
return structuredClone(record.result);
|
|
383
|
+
}
|
|
384
|
+
if (record.state !== "uploading" ||
|
|
385
|
+
!record.receivedBytes ||
|
|
386
|
+
(record.expectedBytes !== undefined && record.expectedBytes !== record.receivedBytes))
|
|
387
|
+
throw new Error("Resource upload is incomplete or no longer accepts finalization");
|
|
388
|
+
const controller = new AbortController();
|
|
389
|
+
this.finishes.set(key, controller);
|
|
390
|
+
// cancel may be queued behind a write before finish acquires the lock.
|
|
391
|
+
// Remember that intent even when there is not yet a running process.
|
|
392
|
+
if (this.cancellations.has(key))
|
|
393
|
+
controller.abort();
|
|
394
|
+
const signal = AbortSignal.any([
|
|
395
|
+
controller.signal,
|
|
396
|
+
...(externalSignal ? [externalSignal] : []),
|
|
397
|
+
AbortSignal.timeout(this.options.finishTimeoutMs),
|
|
398
|
+
]);
|
|
399
|
+
const directory = await this.directory(scope, id), path = join(directory, "content.partial");
|
|
400
|
+
let handle;
|
|
401
|
+
try {
|
|
402
|
+
await this.save({ ...record, state: "finishing" });
|
|
403
|
+
handle = await open(path, constants.O_RDWR | (constants.O_NOFOLLOW ?? 0));
|
|
404
|
+
const info = await handle.stat();
|
|
405
|
+
if (!info.isFile() || info.size < record.receivedBytes)
|
|
406
|
+
throw invalidMedia();
|
|
407
|
+
if (info.size > record.receivedBytes)
|
|
408
|
+
await handle.truncate(record.receivedBytes);
|
|
409
|
+
await handle.sync();
|
|
410
|
+
const identity = mediaSourceIdentity(await handle.stat());
|
|
411
|
+
const digest = createHash("sha256"), buffer = Buffer.allocUnsafe(256 * 1024);
|
|
412
|
+
let read = 0;
|
|
413
|
+
while (read < record.receivedBytes) {
|
|
414
|
+
signal.throwIfAborted();
|
|
415
|
+
await this.authorize(scope);
|
|
416
|
+
const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, record.receivedBytes - read), read);
|
|
417
|
+
if (!bytesRead)
|
|
418
|
+
throw invalidMedia();
|
|
419
|
+
digest.update(buffer.subarray(0, bytesRead));
|
|
420
|
+
read += bytesRead;
|
|
421
|
+
}
|
|
422
|
+
const sha256 = digest.digest("hex");
|
|
423
|
+
if (sha256 !== record.contentSha256 ||
|
|
424
|
+
(record.expectedSha256 !== undefined && sha256 !== record.expectedSha256))
|
|
425
|
+
throw invalidMedia();
|
|
426
|
+
signal.throwIfAborted();
|
|
427
|
+
await this.authorize(scope);
|
|
428
|
+
const asset = await this.options.library.importFile(scope, path, {
|
|
429
|
+
name: record.name,
|
|
430
|
+
mimeType: record.mimeType,
|
|
431
|
+
signal,
|
|
432
|
+
expectedSource: identity,
|
|
433
|
+
expectedBytes: record.receivedBytes,
|
|
434
|
+
expectedSha256: sha256,
|
|
435
|
+
assertAuthorized: () => this.authorize(scope),
|
|
436
|
+
});
|
|
437
|
+
if (asset.sha256 !== sha256 || asset.bytes !== record.receivedBytes)
|
|
438
|
+
throw invalidMedia();
|
|
439
|
+
signal.throwIfAborted();
|
|
440
|
+
await this.authorize(scope);
|
|
441
|
+
const result = { asset };
|
|
442
|
+
const time = this.options.now();
|
|
443
|
+
await this.save({
|
|
444
|
+
...record,
|
|
445
|
+
state: "finished",
|
|
446
|
+
result,
|
|
447
|
+
updatedAt: time,
|
|
448
|
+
expiresAt: time + this.options.ttlMs,
|
|
449
|
+
});
|
|
450
|
+
this.hashes.delete(key);
|
|
451
|
+
await handle.close();
|
|
452
|
+
handle = undefined;
|
|
453
|
+
await rm(path, { force: true });
|
|
454
|
+
return structuredClone(result);
|
|
455
|
+
}
|
|
456
|
+
catch (error) {
|
|
457
|
+
await handle?.close();
|
|
458
|
+
handle = undefined;
|
|
459
|
+
const invalid = error.name === "InvalidResourceUploadError";
|
|
460
|
+
if (invalid) {
|
|
461
|
+
await rm(path, { force: true });
|
|
462
|
+
this.hashes.delete(key);
|
|
463
|
+
}
|
|
464
|
+
await this.save({ ...record, state: invalid ? "failed" : "uploading" });
|
|
465
|
+
throw error;
|
|
466
|
+
}
|
|
467
|
+
finally {
|
|
468
|
+
await handle?.close().catch(() => { });
|
|
469
|
+
this.finishes.delete(key);
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
async cancel(scope, raw) {
|
|
474
|
+
await this.authorize(scope);
|
|
475
|
+
await this.initialize();
|
|
476
|
+
const id = sessionId(object(raw, ["sessionId"]).sessionId), key = this.key(scope, id);
|
|
477
|
+
this.cancellations.add(key);
|
|
478
|
+
this.finishes.get(key)?.abort();
|
|
479
|
+
try {
|
|
480
|
+
return await this.locked(key, async () => {
|
|
481
|
+
const record = await this.record(scope, id);
|
|
482
|
+
if (record.state === "finished")
|
|
483
|
+
throw new Error("Resource upload is already a managed asset");
|
|
484
|
+
await rm(join(await this.directory(scope, id), "content.partial"), { force: true });
|
|
485
|
+
this.hashes.delete(key);
|
|
486
|
+
await this.save({ ...record, state: "cancelled" });
|
|
487
|
+
return { cancelled: true };
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
finally {
|
|
491
|
+
this.cancellations.delete(key);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
async cancelScope(scope) {
|
|
495
|
+
await this.initialize();
|
|
496
|
+
await this.cancelWhere((record) => mediaScopeKey(record.scope) === mediaScopeKey(scope));
|
|
497
|
+
}
|
|
498
|
+
async cancelApp(appId) {
|
|
499
|
+
await this.initialize();
|
|
500
|
+
await this.cancelWhere((record) => record.scope.appId === appId);
|
|
501
|
+
}
|
|
502
|
+
async cancelWhere(matches) {
|
|
503
|
+
const records = [...this.records.values()].filter((record) => active(record) && matches(record));
|
|
504
|
+
for (const record of records) {
|
|
505
|
+
const key = this.key(record.scope, record.sessionId);
|
|
506
|
+
this.cancellations.add(key);
|
|
507
|
+
this.finishes.get(key)?.abort();
|
|
508
|
+
}
|
|
509
|
+
await Promise.all(records.map(async (record) => {
|
|
510
|
+
const key = this.key(record.scope, record.sessionId);
|
|
511
|
+
try {
|
|
512
|
+
await this.locked(key, () => this.remove(record));
|
|
513
|
+
}
|
|
514
|
+
finally {
|
|
515
|
+
this.cancellations.delete(key);
|
|
516
|
+
}
|
|
517
|
+
}));
|
|
518
|
+
}
|
|
519
|
+
async shutdown() {
|
|
520
|
+
this.stopping = true;
|
|
521
|
+
clearInterval(this.cleanupTimer);
|
|
522
|
+
for (const controller of this.finishes.values())
|
|
523
|
+
controller.abort();
|
|
524
|
+
await Promise.allSettled([...this.locks.values()]);
|
|
525
|
+
this.hashes.clear();
|
|
526
|
+
}
|
|
527
|
+
}
|
package/dist/panels/runtime.d.ts
CHANGED
|
@@ -45,7 +45,7 @@ export declare function createPanelRuntime(options: PanelRuntimeOptions): {
|
|
|
45
45
|
ownsAssets(request: IncomingMessage): boolean;
|
|
46
46
|
cancelOwner: (owner: string) => void;
|
|
47
47
|
invalidate(appId?: string): void;
|
|
48
|
-
close():
|
|
48
|
+
close(): Promise<undefined>;
|
|
49
49
|
handle(request: IncomingMessage, response: ServerResponse): Promise<boolean>;
|
|
50
50
|
handleAssets(request: IncomingMessage, response: ServerResponse): Promise<boolean>;
|
|
51
51
|
};
|