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.
Files changed (59) hide show
  1. package/README.md +3 -1
  2. package/README.zh-CN.md +3 -1
  3. package/dist/node/protocol/appPreferences.js +4 -2
  4. package/dist/node/protocol/gameplay.js +69 -0
  5. package/dist/node/protocol/playFailure.js +13 -0
  6. package/dist/node/protocol/protagonistTemplates.js +13 -0
  7. package/dist/node/protocol/resolution.js +274 -0
  8. package/dist/node/protocol/v1.js +40 -19
  9. package/dist/node/protocol/worldArchives.js +7 -0
  10. package/dist/node/runtime/RuntimeErrorLog.js +95 -0
  11. package/dist/node/runtime/V1Runtime.js +103 -8
  12. package/dist/node/runtime/archive/WorldArchiveZip.js +202 -0
  13. package/dist/node/runtime/archive/WorldArchives.js +389 -0
  14. package/dist/node/runtime/config/AppPreferencesStore.js +23 -10
  15. package/dist/node/runtime/config/ProtagonistTemplateStore.js +134 -0
  16. package/dist/node/runtime/content/ContentWorkspace.js +15 -2
  17. package/dist/node/runtime/content/CurrentTreeContentLibrary.js +43 -2
  18. package/dist/node/runtime/content/FileNativeContentTree.js +8 -1
  19. package/dist/node/runtime/history/StoryExport.js +33 -0
  20. package/dist/node/runtime/model/FileNativeModelAdapters.js +34 -2
  21. package/dist/node/runtime/model/ModelHost.js +12 -1
  22. package/dist/node/runtime/model/ModelHostStream.js +11 -3
  23. package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +37 -1
  24. package/dist/node/runtime/play/FileNativePlayPresetStore.js +48 -3
  25. package/dist/node/runtime/play/FileNativePlayTimelineStore.js +87 -4
  26. package/dist/node/runtime/play/LegacyModelBindingFailure.js +10 -0
  27. package/dist/node/runtime/play/OrderedPlayPrompts.js +68 -6
  28. package/dist/node/runtime/play/PlayCallChain.js +177 -57
  29. package/dist/node/runtime/play/PlayCallChainError.js +3 -1
  30. package/dist/node/runtime/play/PlayContinuity.js +3 -20
  31. package/dist/node/runtime/play/PlayFailure.js +20 -0
  32. package/dist/node/runtime/play/PlayTraceRetention.js +3 -11
  33. package/dist/node/runtime/play/TabletopResolution.js +372 -0
  34. package/dist/node/runtime/play/TabletopRules.js +141 -0
  35. package/dist/node/runtime/prompt/FileNativePromptCompiler.js +49 -32
  36. package/dist/node/runtime/prompt/FileNativeToolRegistry.js +37 -4
  37. package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +4 -1
  38. package/dist/node/runtime/setting/SettingAuthoringTransaction.js +56 -13
  39. package/dist/node/runtime/setting/SettingImprovementSession.js +6 -4
  40. package/dist/node/runtime/world/FileNativeWorldStore.js +207 -40
  41. package/dist/node/runtime/world/WorldActivity.js +143 -0
  42. package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +2 -0
  43. package/dist/node/runtime/world-revision/WorldRevisionAuthoringTransaction.js +35 -6
  44. package/dist/node/runtime/world-revision/WorldRevisionSession.js +9 -5
  45. package/dist/node/server/ConversationStream.js +2 -0
  46. package/dist/node/server/createServer.js +48 -0
  47. package/dist/node/shared/default-preset-host.js +10 -6
  48. package/dist/node/shared/ordered-author-prompts.js +8 -0
  49. package/dist/node/shared/ordered-play-prompts.js +25 -2
  50. package/dist/node/shared/sample-packages/index.js +10 -0
  51. package/dist/node/shared/sample-packages/rainy-inn-en.js +194 -0
  52. package/dist/node/shared/sample-packages/rainy-inn-zh.js +194 -0
  53. package/dist/node/shared/tabletop-guidance.js +100 -0
  54. package/dist/web/assets/index-BJ-1Mb0e.css +1 -0
  55. package/dist/web/assets/index-BgBL8G-A.js +1323 -0
  56. package/dist/web/index.html +2 -2
  57. package/package.json +6 -2
  58. package/dist/web/assets/index-BH8NyuE4.js +0 -1233
  59. 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 { equalModelHostBinding, } from "./model/ModelHost.js";
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 (!/\.(read|status|overview|inspect|page|detail|list|preview)$/.test(request.type)) {
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
- return this.#content.replaceCurrentTreeContentPackage(request.packageId, request.files);
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 !== undefined &&
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("invalid_request", error.message, {
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
+ }