narraeon 0.5.6 → 0.6.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/README.md +3 -1
- package/README.zh-CN.md +3 -1
- package/dist/node/protocol/appPreferences.js +4 -2
- package/dist/node/protocol/gameplay.js +69 -0
- package/dist/node/protocol/playFailure.js +13 -0
- package/dist/node/protocol/protagonistTemplates.js +13 -0
- package/dist/node/protocol/resolution.js +274 -0
- package/dist/node/protocol/v1.js +40 -19
- package/dist/node/protocol/worldArchives.js +7 -0
- package/dist/node/runtime/RuntimeErrorLog.js +95 -0
- package/dist/node/runtime/V1Runtime.js +103 -8
- package/dist/node/runtime/archive/WorldArchiveZip.js +202 -0
- package/dist/node/runtime/archive/WorldArchives.js +389 -0
- package/dist/node/runtime/config/AppPreferencesStore.js +23 -10
- package/dist/node/runtime/config/ProtagonistTemplateStore.js +134 -0
- package/dist/node/runtime/content/ContentWorkspace.js +15 -2
- package/dist/node/runtime/content/CurrentTreeContentLibrary.js +43 -2
- package/dist/node/runtime/content/FileNativeContentTree.js +8 -1
- package/dist/node/runtime/history/StoryExport.js +33 -0
- package/dist/node/runtime/model/FileNativeModelAdapters.js +34 -2
- package/dist/node/runtime/model/ModelHost.js +12 -1
- package/dist/node/runtime/model/ModelHostStream.js +11 -3
- package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +37 -1
- package/dist/node/runtime/play/FileNativePlayPresetStore.js +48 -3
- package/dist/node/runtime/play/FileNativePlayTimelineStore.js +87 -4
- package/dist/node/runtime/play/LegacyModelBindingFailure.js +10 -0
- package/dist/node/runtime/play/OrderedPlayPrompts.js +68 -6
- package/dist/node/runtime/play/PlayCallChain.js +177 -57
- package/dist/node/runtime/play/PlayCallChainError.js +3 -1
- package/dist/node/runtime/play/PlayContinuity.js +3 -20
- package/dist/node/runtime/play/PlayFailure.js +20 -0
- package/dist/node/runtime/play/PlayTraceRetention.js +3 -11
- package/dist/node/runtime/play/TabletopResolution.js +372 -0
- package/dist/node/runtime/play/TabletopRules.js +141 -0
- package/dist/node/runtime/prompt/FileNativePromptCompiler.js +49 -32
- package/dist/node/runtime/prompt/FileNativeToolRegistry.js +37 -4
- package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +4 -1
- package/dist/node/runtime/setting/SettingAuthoringTransaction.js +56 -13
- package/dist/node/runtime/setting/SettingImprovementSession.js +6 -4
- package/dist/node/runtime/world/FileNativeWorldStore.js +207 -40
- package/dist/node/runtime/world/WorldActivity.js +143 -0
- package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +2 -0
- package/dist/node/runtime/world-revision/WorldRevisionAuthoringTransaction.js +35 -6
- package/dist/node/runtime/world-revision/WorldRevisionSession.js +9 -5
- package/dist/node/server/ConversationStream.js +2 -0
- package/dist/node/server/createServer.js +48 -0
- package/dist/node/shared/default-preset-host.js +10 -6
- package/dist/node/shared/ordered-author-prompts.js +8 -0
- package/dist/node/shared/ordered-play-prompts.js +25 -2
- package/dist/node/shared/sample-packages/index.js +10 -0
- package/dist/node/shared/sample-packages/rainy-inn-en.js +194 -0
- package/dist/node/shared/sample-packages/rainy-inn-zh.js +194 -0
- package/dist/node/shared/tabletop-guidance.js +100 -0
- package/dist/web/assets/index-BJ-1Mb0e.css +1 -0
- package/dist/web/assets/index-BgBL8G-A.js +1323 -0
- package/dist/web/index.html +2 -2
- package/package.json +6 -2
- package/dist/web/assets/index-BH8NyuE4.js +0 -1233
- package/dist/web/assets/index-CVN48xrw.css +0 -1
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { WorldArchives } from "./archive/WorldArchives.js";
|
|
2
|
+
import { WorldArchiveError } from "./archive/WorldArchiveZip.js";
|
|
3
|
+
import { FileNativeWorldCreationError } from "./world/FileNativeWorldStore.js";
|
|
1
4
|
import { PackageScriptPermissions } from "./extension/PackageScriptPermissions.js";
|
|
2
5
|
import { comparePromptPrefixes } from "./prompt/WorldPromptDiagnostics.js";
|
|
3
6
|
import { join } from "node:path";
|
|
@@ -8,7 +11,7 @@ import { ContentPackageImportError, ContentWorkspace, InvalidContentTreeError, }
|
|
|
8
11
|
import { contentTreeFingerprint } from "./content/ContentTreeFingerprint.js";
|
|
9
12
|
import { inspectContentPackageCurrentTree } from "./content/FileNativeContentTree.js";
|
|
10
13
|
import { FileNativeModelHost } from "./model/FileNativeModelAdapters.js";
|
|
11
|
-
import {
|
|
14
|
+
import {} from "./model/ModelHost.js";
|
|
12
15
|
import { ModelConnectionStore } from "./model/ModelConnectionStore.js";
|
|
13
16
|
import { FileNativeContinuityCorrection } from "./play/FileNativeContinuityCorrection.js";
|
|
14
17
|
import { fingerprintControl } from "./play/PlayDocumentTools.js";
|
|
@@ -24,12 +27,15 @@ import { SettingImprovementRollbackError, SettingImprovementSession, } from "./s
|
|
|
24
27
|
import { FileNativeWorldStore } from "./world/FileNativeWorldStore.js";
|
|
25
28
|
import { WorldDocumentStore } from "./world/WorldDocumentStore.js";
|
|
26
29
|
import { FileNativeAiFailureLog } from "./model/AiFailureLog.js";
|
|
30
|
+
import { FileNativeRuntimeErrorLog } from "./RuntimeErrorLog.js";
|
|
31
|
+
import { ProtagonistTemplateStore } from "./config/ProtagonistTemplateStore.js";
|
|
27
32
|
import { AppPreferencesStore } from "./config/AppPreferencesStore.js";
|
|
28
33
|
import { FileNativeWorldRevisionStore } from "./world-revision/FileNativeWorldRevisionStore.js";
|
|
29
34
|
import { WorldRevisionSession } from "./world-revision/WorldRevisionSession.js";
|
|
30
35
|
import { WorldRevisionWorkspace, WorldRevisionWorkspaceError, } from "./world-revision/WorldRevisionWorkspace.js";
|
|
31
36
|
import { ConversationChanges } from "./ConversationChanges.js";
|
|
32
37
|
export class V1Runtime {
|
|
38
|
+
worldArchives;
|
|
33
39
|
#conversationChanges = new ConversationChanges();
|
|
34
40
|
#content;
|
|
35
41
|
#playPresets;
|
|
@@ -39,13 +45,16 @@ export class V1Runtime {
|
|
|
39
45
|
#corrections;
|
|
40
46
|
#playCallChains;
|
|
41
47
|
#failureLog;
|
|
48
|
+
#errorLog;
|
|
42
49
|
#preferences;
|
|
50
|
+
#protagonistTemplates;
|
|
43
51
|
#settingImprovements;
|
|
44
52
|
#worldRevisions;
|
|
45
53
|
#compiler = new FileNativePromptCompiler();
|
|
46
54
|
#locale = defaultAppLocale;
|
|
47
55
|
constructor(input) {
|
|
48
56
|
this.#preferences = new AppPreferencesStore(input.configRoot);
|
|
57
|
+
this.#protagonistTemplates = new ProtagonistTemplateStore(input.configRoot);
|
|
49
58
|
this.#content = new ContentWorkspace(input.dataRoot, {
|
|
50
59
|
locale: () => this.#locale,
|
|
51
60
|
});
|
|
@@ -58,7 +67,16 @@ export class V1Runtime {
|
|
|
58
67
|
input.logRoot === undefined
|
|
59
68
|
? undefined
|
|
60
69
|
: new FileNativeAiFailureLog(join(input.logRoot, "ai-failures"));
|
|
70
|
+
this.#errorLog =
|
|
71
|
+
input.logRoot === undefined
|
|
72
|
+
? undefined
|
|
73
|
+
: new FileNativeRuntimeErrorLog(join(input.logRoot, "runtime-errors"));
|
|
61
74
|
this.#playCallChains = new PlayCallChain(this.#worlds, this.#compiler, this.#artifacts, this.#failureLog, (id) => this.#conversationChanges.publish(`play:${id}`));
|
|
75
|
+
this.worldArchives = new WorldArchives({
|
|
76
|
+
dataRoot: input.dataRoot,
|
|
77
|
+
worlds: this.#worlds,
|
|
78
|
+
chains: this.#playCallChains,
|
|
79
|
+
});
|
|
62
80
|
this.#models = new ModelConnectionStore(input.configRoot);
|
|
63
81
|
this.#corrections = new FileNativeContinuityCorrection(this.#worlds, {
|
|
64
82
|
compiler: this.#compiler,
|
|
@@ -133,16 +151,30 @@ export class V1Runtime {
|
|
|
133
151
|
this.#locale = (await this.#preferences.view()).locale;
|
|
134
152
|
this.#compiler.setLocale(this.#locale);
|
|
135
153
|
await this.#playPresets.initialize();
|
|
154
|
+
await this.worldArchives.initialize();
|
|
136
155
|
}
|
|
137
156
|
async handle(request, playCallChainObserver) {
|
|
138
157
|
try {
|
|
139
158
|
const result = await this.#dispatch(request, playCallChainObserver);
|
|
159
|
+
await this.#recordPlayInterruption(request, result);
|
|
140
160
|
return { protocol: v1Protocol, result };
|
|
141
161
|
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
await this.#errorLog?.recordRequestFailure(request, error);
|
|
164
|
+
if (error instanceof WorldArchiveError)
|
|
165
|
+
throw new V1ProtocolError(error.code, error.message, { cause: error });
|
|
166
|
+
if (request.type === "world.archive.export" &&
|
|
167
|
+
error instanceof FileNativeWorldCreationError &&
|
|
168
|
+
(error.code === "operation_conflict" ||
|
|
169
|
+
error.code === "inconsistent_materialization"))
|
|
170
|
+
throw new V1ProtocolError(error.code, error.message, { cause: error });
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
142
173
|
finally {
|
|
143
174
|
// Explicit commands may partially settle before failing. Readers recover
|
|
144
175
|
// through the same Runtime projection, never infer success from this signal.
|
|
145
|
-
if (
|
|
176
|
+
if (request.type !== "world.story.export" &&
|
|
177
|
+
!/\.(read|status|overview|inspect|page|detail|list|preview)$/.test(request.type)) {
|
|
146
178
|
if ("packageId" in request)
|
|
147
179
|
this.#conversationChanges.publish(`setting:${request.packageId}`);
|
|
148
180
|
if ("worldId" in request) {
|
|
@@ -152,6 +184,32 @@ export class V1Runtime {
|
|
|
152
184
|
}
|
|
153
185
|
}
|
|
154
186
|
}
|
|
187
|
+
async #recordPlayInterruption(request, result) {
|
|
188
|
+
if (this.#errorLog === undefined ||
|
|
189
|
+
(request.type !== "play.chain.start" &&
|
|
190
|
+
request.type !== "play.chain.append"))
|
|
191
|
+
return;
|
|
192
|
+
const chain = result;
|
|
193
|
+
if (chain?.status !== "interrupted" ||
|
|
194
|
+
typeof chain.lastFailure !== "string" ||
|
|
195
|
+
chain.events?.at(-1)?.kind === "cancellation")
|
|
196
|
+
return;
|
|
197
|
+
await this.#errorLog.recordPlayInterruption(request, {
|
|
198
|
+
message: chain.lastFailure,
|
|
199
|
+
...(chain.lastFailureKind === undefined
|
|
200
|
+
? {}
|
|
201
|
+
: { kind: chain.lastFailureKind }),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
async recordConversationFailure(target, error) {
|
|
205
|
+
await this.#errorLog?.recordRequestFailure({
|
|
206
|
+
type: `${target.kind}.observe`,
|
|
207
|
+
targetId: target.id,
|
|
208
|
+
...("sessionId" in target && target.sessionId !== undefined
|
|
209
|
+
? { sessionId: target.sessionId }
|
|
210
|
+
: {}),
|
|
211
|
+
}, error);
|
|
212
|
+
}
|
|
155
213
|
subscribeConversation(target, changed) {
|
|
156
214
|
return this.#conversationChanges.subscribe(`${target.kind}:${target.id}`, changed);
|
|
157
215
|
}
|
|
@@ -213,6 +271,14 @@ export class V1Runtime {
|
|
|
213
271
|
await this.#playPresets.syncBuiltinDefaultLocale();
|
|
214
272
|
return preferences;
|
|
215
273
|
}
|
|
274
|
+
case "protagonist-templates.read":
|
|
275
|
+
return this.#protagonistTemplates.view();
|
|
276
|
+
case "protagonist-templates.save":
|
|
277
|
+
return this.#protagonistTemplates.save(request);
|
|
278
|
+
case "protagonist-templates.copy":
|
|
279
|
+
return this.#protagonistTemplates.copy(request.id);
|
|
280
|
+
case "protagonist-templates.delete":
|
|
281
|
+
return this.#protagonistTemplates.delete(request.id);
|
|
216
282
|
case "model.read":
|
|
217
283
|
return this.#models.view();
|
|
218
284
|
case "model.save":
|
|
@@ -226,7 +292,21 @@ export class V1Runtime {
|
|
|
226
292
|
case "model.models":
|
|
227
293
|
return this.#models.listModels(request);
|
|
228
294
|
case "content.create":
|
|
229
|
-
return this.#content.createCurrentTreeContentPackage();
|
|
295
|
+
return this.#content.createCurrentTreeContentPackage(request.gameplay);
|
|
296
|
+
case "content.gameplay.set": {
|
|
297
|
+
try {
|
|
298
|
+
return await this.#content.setCurrentTreeContentGameplayScheme(request.packageId, request.scheme);
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
if (error instanceof InvalidContentTreeError)
|
|
302
|
+
throw new V1ProtocolError("invalid_request", error.message, {
|
|
303
|
+
cause: error,
|
|
304
|
+
});
|
|
305
|
+
throw error;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
case "content.create-sample":
|
|
309
|
+
return this.#content.createSampleContentPackage(request.locale);
|
|
230
310
|
case "content.scripts.read":
|
|
231
311
|
return this.#content.packageScriptPermissions(request.packageId);
|
|
232
312
|
case "content.scripts.set":
|
|
@@ -238,7 +318,16 @@ export class V1Runtime {
|
|
|
238
318
|
case "content.read":
|
|
239
319
|
return this.#content.readCurrentTreeContentPackage(request.packageId);
|
|
240
320
|
case "content.replace":
|
|
241
|
-
|
|
321
|
+
try {
|
|
322
|
+
return await this.#content.replaceCurrentTreeContentPackage(request.packageId, request.files);
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
if (error instanceof InvalidContentTreeError)
|
|
326
|
+
throw new V1ProtocolError("invalid_request", error.message, {
|
|
327
|
+
cause: error,
|
|
328
|
+
});
|
|
329
|
+
throw error;
|
|
330
|
+
}
|
|
242
331
|
case "content.copy":
|
|
243
332
|
return this.#content.copyCurrentTreeContentPackage(request.packageId);
|
|
244
333
|
case "content.delete":
|
|
@@ -402,12 +491,18 @@ export class V1Runtime {
|
|
|
402
491
|
return this.#worlds.getCreationOutcome(request.operationId);
|
|
403
492
|
case "world.rename":
|
|
404
493
|
return this.#worlds.renameWorld(request.worldId, request.name);
|
|
494
|
+
case "world.story.export":
|
|
495
|
+
return this.#worlds.exportStory(request.worldId, request.format, this.#locale);
|
|
405
496
|
case "world.delete":
|
|
406
497
|
return playCall(async () => {
|
|
407
498
|
await this.#worlds.assertWorldRevisionUnlocked(request.worldId);
|
|
408
499
|
this.#playCallChains.forgetWorld(request.worldId);
|
|
409
500
|
return this.#worlds.deleteWorld(request.worldId);
|
|
410
501
|
});
|
|
502
|
+
case "world.archive.export":
|
|
503
|
+
return this.worldArchives.exportWorld(request);
|
|
504
|
+
case "world.archive.import":
|
|
505
|
+
return this.worldArchives.importWorld(request);
|
|
411
506
|
case "world.read": {
|
|
412
507
|
const head = await this.#worlds.currentHead(request.worldId);
|
|
413
508
|
const playerViews = await this.#worlds.renderPlayerViewsAtHead(request.worldId, head);
|
|
@@ -435,6 +530,7 @@ export class V1Runtime {
|
|
|
435
530
|
playCallChain: null,
|
|
436
531
|
playTimeline,
|
|
437
532
|
worldRevision: revisionEpoch,
|
|
533
|
+
archiveNeedsFreshContext: await this.#worlds.archiveNeedsFreshContext(request.worldId),
|
|
438
534
|
};
|
|
439
535
|
}
|
|
440
536
|
case "world.surface.read":
|
|
@@ -542,7 +638,7 @@ export class V1Runtime {
|
|
|
542
638
|
hostPresetId: preset.id,
|
|
543
639
|
});
|
|
544
640
|
await this.#reconcileResultArtifacts(result);
|
|
545
|
-
return result;
|
|
641
|
+
return { outcome: result.outcome, world: result.world };
|
|
546
642
|
}
|
|
547
643
|
case "play.chain.revise-player": {
|
|
548
644
|
const base = {
|
|
@@ -634,8 +730,7 @@ export class V1Runtime {
|
|
|
634
730
|
const frozenBinding = persisted?.value.modelBinding;
|
|
635
731
|
const continueContext = currentContext !== null &&
|
|
636
732
|
!currentContext.stale &&
|
|
637
|
-
frozenBinding
|
|
638
|
-
equalModelHostBinding(frozenBinding, modelBinding);
|
|
733
|
+
frozenBinding?.provider === modelBinding.provider;
|
|
639
734
|
const baseline = continueContext
|
|
640
735
|
? await this.#worlds.bindPlayCallChainAt(request.worldId, currentContext.baselineHead)
|
|
641
736
|
: binding;
|
|
@@ -928,7 +1023,7 @@ async function playCall(operation) {
|
|
|
928
1023
|
}
|
|
929
1024
|
catch (error) {
|
|
930
1025
|
if (error instanceof PlayCallChainError)
|
|
931
|
-
throw new V1ProtocolError(
|
|
1026
|
+
throw new V1ProtocolError(error.code, error.message, {
|
|
932
1027
|
cause: error,
|
|
933
1028
|
});
|
|
934
1029
|
throw error;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
3
|
+
import { mkdir, readdir, stat } from "node:fs/promises";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { Transform } from "node:stream";
|
|
6
|
+
import { pipeline } from "node:stream/promises";
|
|
7
|
+
import * as yauzl from "yauzl";
|
|
8
|
+
import * as yazl from "yazl";
|
|
9
|
+
export class WorldArchiveError extends Error {
|
|
10
|
+
code;
|
|
11
|
+
constructor(code, message, options) {
|
|
12
|
+
super(message, options);
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.name = "WorldArchiveError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function archivePath(path) {
|
|
18
|
+
if (!path ||
|
|
19
|
+
path.startsWith("/") ||
|
|
20
|
+
Buffer.byteLength(path, "utf8") > 4096 ||
|
|
21
|
+
path.includes("\\") ||
|
|
22
|
+
Array.from(path).some((character) => character.charCodeAt(0) < 32 ||
|
|
23
|
+
character.charCodeAt(0) === 127 ||
|
|
24
|
+
'<>:"|?*'.includes(character)) ||
|
|
25
|
+
path
|
|
26
|
+
.split("/")
|
|
27
|
+
.some((part) => !part ||
|
|
28
|
+
part === "." ||
|
|
29
|
+
part === ".." ||
|
|
30
|
+
/[. ]$/u.test(part) ||
|
|
31
|
+
Buffer.byteLength(part, "utf8") > 255 ||
|
|
32
|
+
/^(?:AUX|CON|NUL|PRN|COM[1-9]|LPT[1-9])$/u.test(part.split(".", 1)[0].toUpperCase())))
|
|
33
|
+
throw new WorldArchiveError("unsafe_path", "The archive contains an unsafe path");
|
|
34
|
+
}
|
|
35
|
+
/** No whole-file buffers: hash, limit and persist in the same backpressured pipeline. */
|
|
36
|
+
export async function streamFile(source, destination, maxBytes) {
|
|
37
|
+
let bytes = 0;
|
|
38
|
+
const hash = createHash("sha256");
|
|
39
|
+
await mkdir(dirname(destination), { recursive: true, mode: 0o700 });
|
|
40
|
+
const counter = new Transform({
|
|
41
|
+
transform(chunk, _encoding, callback) {
|
|
42
|
+
bytes += chunk.length;
|
|
43
|
+
if (bytes > maxBytes)
|
|
44
|
+
callback(new WorldArchiveError("limit_exceeded", "The archive exceeds its size limit"));
|
|
45
|
+
else {
|
|
46
|
+
hash.update(chunk);
|
|
47
|
+
callback(null, chunk);
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
await pipeline(source, counter, createWriteStream(destination, { flags: "wx", mode: 0o600 }));
|
|
52
|
+
return { bytes, sha256: hash.digest("hex") };
|
|
53
|
+
}
|
|
54
|
+
export async function inventory(root, prefix = "world") {
|
|
55
|
+
const result = [];
|
|
56
|
+
async function visit(directory, path) {
|
|
57
|
+
for (const entry of (await readdir(directory, { withFileTypes: true })).sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
58
|
+
const full = join(directory, entry.name);
|
|
59
|
+
const relative = `${path}/${entry.name}`;
|
|
60
|
+
archivePath(relative);
|
|
61
|
+
if (entry.isDirectory())
|
|
62
|
+
await visit(full, relative);
|
|
63
|
+
else if (entry.isFile()) {
|
|
64
|
+
const hash = createHash("sha256");
|
|
65
|
+
let bytes = 0;
|
|
66
|
+
for await (const chunk of createReadStream(full)) {
|
|
67
|
+
const buffer = chunk;
|
|
68
|
+
if (!Buffer.isBuffer(buffer))
|
|
69
|
+
throw new Error("Archive file stream must be binary");
|
|
70
|
+
bytes += buffer.length;
|
|
71
|
+
hash.update(buffer);
|
|
72
|
+
}
|
|
73
|
+
result.push({ path: relative, bytes, sha256: hash.digest("hex") });
|
|
74
|
+
}
|
|
75
|
+
else
|
|
76
|
+
throw new WorldArchiveError("unsafe_path", "Only files and directories may be archived");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
await visit(root, prefix);
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
export async function writeArchive(root, files, destination, limits) {
|
|
83
|
+
const manifestBytes = (await stat(join(root, "narraeon-world.json"))).size;
|
|
84
|
+
if (manifestBytes > limits.entryBytes ||
|
|
85
|
+
files.length + 1 > limits.entries ||
|
|
86
|
+
files.some((file) => file.bytes > limits.entryBytes) ||
|
|
87
|
+
files.reduce((sum, file) => sum + file.bytes, manifestBytes) >
|
|
88
|
+
limits.extractedBytes)
|
|
89
|
+
throw new WorldArchiveError("limit_exceeded", "The world exceeds archive limits");
|
|
90
|
+
const zip = new yazl.ZipFile();
|
|
91
|
+
const output = streamFile(zip.outputStream, destination, limits.archiveBytes);
|
|
92
|
+
zip.on("error", (error) => zip.outputStream.destroy(error));
|
|
93
|
+
zip.addFile(join(root, "narraeon-world.json"), "narraeon-world.json");
|
|
94
|
+
for (const file of files)
|
|
95
|
+
zip.addFile(join(root, file.path), file.path);
|
|
96
|
+
zip.end();
|
|
97
|
+
return (await output).bytes;
|
|
98
|
+
}
|
|
99
|
+
export async function extractArchive(archive, destination, limits) {
|
|
100
|
+
if ((await stat(archive)).size > limits.archiveBytes)
|
|
101
|
+
throw new WorldArchiveError("limit_exceeded", "The ZIP exceeds its size limit");
|
|
102
|
+
let zip;
|
|
103
|
+
try {
|
|
104
|
+
zip = await new Promise((resolve, reject) => yauzl.open(archive, { lazyEntries: true, decodeStrings: false, validateEntrySizes: true }, (error, value) => (error ? reject(error) : resolve(value))));
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
throw new WorldArchiveError("invalid_archive", "Invalid ZIP archive", {
|
|
108
|
+
cause: error,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
const files = new Map();
|
|
112
|
+
const paths = new Map();
|
|
113
|
+
let total = 0;
|
|
114
|
+
let count = 0;
|
|
115
|
+
const register = (path, directory, explicit) => {
|
|
116
|
+
const key = path.normalize("NFKC").toLowerCase();
|
|
117
|
+
const previous = paths.get(key);
|
|
118
|
+
if (previous &&
|
|
119
|
+
(previous.path !== path ||
|
|
120
|
+
previous.directory !== directory ||
|
|
121
|
+
(explicit && previous.explicit)))
|
|
122
|
+
throw new WorldArchiveError("unsafe_path", "Duplicate or conflicting archive paths");
|
|
123
|
+
paths.set(key, {
|
|
124
|
+
path,
|
|
125
|
+
directory,
|
|
126
|
+
explicit: explicit || previous?.explicit === true,
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
try {
|
|
130
|
+
if (zip.entryCount > limits.entries)
|
|
131
|
+
throw new WorldArchiveError("limit_exceeded", "Too many ZIP entries");
|
|
132
|
+
await new Promise((resolve, reject) => {
|
|
133
|
+
zip.on("error", reject);
|
|
134
|
+
zip.on("end", resolve);
|
|
135
|
+
zip.on("entry", (entry) => {
|
|
136
|
+
void (async () => {
|
|
137
|
+
if (++count > limits.entries)
|
|
138
|
+
throw new WorldArchiveError("limit_exceeded", "Too many ZIP entries");
|
|
139
|
+
let raw;
|
|
140
|
+
try {
|
|
141
|
+
raw = new TextDecoder("utf-8", { fatal: true }).decode(entry.fileNameRaw);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
throw new WorldArchiveError("unsafe_path", "ZIP paths must be UTF-8");
|
|
145
|
+
}
|
|
146
|
+
const directory = raw.endsWith("/");
|
|
147
|
+
const path = directory ? raw.slice(0, -1) : raw;
|
|
148
|
+
archivePath(path);
|
|
149
|
+
const mode = (entry.externalFileAttributes >>> 16) & 0o170000;
|
|
150
|
+
if ((mode !== 0 && mode !== (directory ? 0o040000 : 0o100000)) ||
|
|
151
|
+
((entry.externalFileAttributes & 0x10) !== 0 && !directory))
|
|
152
|
+
throw new WorldArchiveError("unsafe_path", "Only regular ZIP files and directories are allowed");
|
|
153
|
+
if (entry.isEncrypted() || ![0, 8].includes(entry.compressionMethod))
|
|
154
|
+
throw new WorldArchiveError("invalid_archive", "Unsupported ZIP encoding");
|
|
155
|
+
const segments = path.split("/");
|
|
156
|
+
for (let index = 1; index < segments.length; index++)
|
|
157
|
+
register(segments.slice(0, index).join("/"), true, false);
|
|
158
|
+
register(path, directory, true);
|
|
159
|
+
if (path !== "world" &&
|
|
160
|
+
!path.startsWith("world/") &&
|
|
161
|
+
path !== "narraeon-world.json")
|
|
162
|
+
throw new WorldArchiveError("invalid_archive", "Unexpected archive entry");
|
|
163
|
+
if ((path === "world" && !directory) ||
|
|
164
|
+
(path === "narraeon-world.json" && directory))
|
|
165
|
+
throw new WorldArchiveError("invalid_archive", "Invalid archive layout");
|
|
166
|
+
if (entry.uncompressedSize > limits.entryBytes ||
|
|
167
|
+
total + entry.uncompressedSize > limits.extractedBytes)
|
|
168
|
+
throw new WorldArchiveError("limit_exceeded", "Extracted ZIP exceeds its size limit");
|
|
169
|
+
if (directory) {
|
|
170
|
+
if (entry.uncompressedSize !== 0)
|
|
171
|
+
throw new WorldArchiveError("invalid_archive", "ZIP directory contains data");
|
|
172
|
+
await mkdir(join(destination, path), {
|
|
173
|
+
recursive: true,
|
|
174
|
+
mode: 0o700,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
const stream = await new Promise((resolveStream, rejectStream) => zip.openReadStream(entry, (error, value) => error ? rejectStream(error) : resolveStream(value)));
|
|
179
|
+
const result = await streamFile(stream, join(destination, path), Math.min(limits.entryBytes, limits.extractedBytes - total));
|
|
180
|
+
if (result.bytes !== entry.uncompressedSize)
|
|
181
|
+
throw new WorldArchiveError("integrity_failed", "ZIP entry size does not match");
|
|
182
|
+
total += result.bytes;
|
|
183
|
+
files.set(path, { path, ...result });
|
|
184
|
+
}
|
|
185
|
+
zip.readEntry();
|
|
186
|
+
})().catch(reject);
|
|
187
|
+
});
|
|
188
|
+
zip.readEntry();
|
|
189
|
+
});
|
|
190
|
+
return files;
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
if (error instanceof WorldArchiveError)
|
|
194
|
+
throw error;
|
|
195
|
+
throw new WorldArchiveError("invalid_archive", "Invalid ZIP archive", {
|
|
196
|
+
cause: error,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
zip.close();
|
|
201
|
+
}
|
|
202
|
+
}
|