narraeon 0.5.6 → 0.5.7

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 (39) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/dist/node/protocol/appPreferences.js +4 -2
  4. package/dist/node/protocol/playFailure.js +13 -0
  5. package/dist/node/protocol/protagonistTemplates.js +13 -0
  6. package/dist/node/protocol/v1.js +25 -19
  7. package/dist/node/protocol/worldArchives.js +7 -0
  8. package/dist/node/runtime/V1Runtime.js +46 -6
  9. package/dist/node/runtime/archive/WorldArchiveZip.js +202 -0
  10. package/dist/node/runtime/archive/WorldArchives.js +389 -0
  11. package/dist/node/runtime/config/AppPreferencesStore.js +23 -10
  12. package/dist/node/runtime/config/ProtagonistTemplateStore.js +134 -0
  13. package/dist/node/runtime/content/ContentWorkspace.js +5 -0
  14. package/dist/node/runtime/history/StoryExport.js +33 -0
  15. package/dist/node/runtime/model/FileNativeModelAdapters.js +34 -2
  16. package/dist/node/runtime/model/ModelHost.js +12 -1
  17. package/dist/node/runtime/model/ModelHostStream.js +11 -3
  18. package/dist/node/runtime/play/FileNativePlayTimelineStore.js +80 -4
  19. package/dist/node/runtime/play/LegacyModelBindingFailure.js +10 -0
  20. package/dist/node/runtime/play/PlayCallChain.js +144 -52
  21. package/dist/node/runtime/play/PlayCallChainError.js +3 -1
  22. package/dist/node/runtime/play/PlayContinuity.js +3 -20
  23. package/dist/node/runtime/play/PlayFailure.js +20 -0
  24. package/dist/node/runtime/play/PlayTraceRetention.js +3 -11
  25. package/dist/node/runtime/prompt/FileNativePromptCompiler.js +4 -22
  26. package/dist/node/runtime/prompt/FileNativeToolRegistry.js +2 -2
  27. package/dist/node/runtime/world/FileNativeWorldStore.js +172 -39
  28. package/dist/node/runtime/world/WorldActivity.js +143 -0
  29. package/dist/node/server/createServer.js +48 -0
  30. package/dist/node/shared/default-preset-host.js +10 -6
  31. package/dist/node/shared/ordered-play-prompts.js +2 -2
  32. package/dist/node/shared/sample-packages/index.js +10 -0
  33. package/dist/node/shared/sample-packages/rainy-inn-en.js +194 -0
  34. package/dist/node/shared/sample-packages/rainy-inn-zh.js +194 -0
  35. package/dist/web/assets/index-DxNtxzLR.js +1255 -0
  36. package/dist/web/assets/{index-CVN48xrw.css → index-DzOnu9uv.css} +1 -1
  37. package/dist/web/index.html +2 -2
  38. package/package.json +6 -2
  39. package/dist/web/assets/index-BH8NyuE4.js +0 -1233
package/README.md CHANGED
@@ -178,7 +178,7 @@ TMPDIR=/tmp npm run test:package
178
178
 
179
179
  Project contracts and architecture:
180
180
 
181
- - [CONTEXT.md](CONTEXT.md) — domain vocabulary
181
+ - [GLOSSARY.md](GLOSSARY.md) — domain vocabulary
182
182
  - [docs/product-foundation.md](docs/product-foundation.md) — current V1 product contract
183
183
  - [docs/adr/](docs/adr/) — architectural decisions
184
184
 
package/README.zh-CN.md CHANGED
@@ -168,7 +168,7 @@ TMPDIR=/tmp npm run test:package
168
168
 
169
169
  项目契约与架构文档:
170
170
 
171
- - [CONTEXT.md](CONTEXT.md) —— 领域词汇
171
+ - [GLOSSARY.md](GLOSSARY.md) —— 领域词汇
172
172
  - [docs/product-foundation.md](docs/product-foundation.md) —— 当前 V1 产品契约
173
173
  - [docs/adr/](docs/adr/) —— 架构决策
174
174
 
@@ -7,6 +7,7 @@ export const defaultAppReadingPreferences = {
7
7
  lineHeight: 1.7,
8
8
  letterSpacing: 0.01,
9
9
  measure: 48,
10
+ showTechnicalInfo: false,
10
11
  };
11
12
  export function isAppLocale(value) {
12
13
  return appLocales.includes(value);
@@ -15,12 +16,13 @@ export function isAppReadingPreferences(value) {
15
16
  if (typeof value !== "object" || value === null || Array.isArray(value))
16
17
  return false;
17
18
  const candidate = value;
18
- return (Object.keys(candidate).length === 5 &&
19
+ return (Object.keys(candidate).length === 6 &&
19
20
  readingDensities.includes(candidate.density) &&
20
21
  isNumberInRange(candidate.fontSize, 15, 24) &&
21
22
  isNumberInRange(candidate.lineHeight, 1.4, 2.4) &&
22
23
  isNumberInRange(candidate.letterSpacing, 0, 0.12) &&
23
- isNumberInRange(candidate.measure, 32, 72));
24
+ isNumberInRange(candidate.measure, 32, 72) &&
25
+ typeof candidate.showTechnicalInfo === "boolean");
24
26
  }
25
27
  function isNumberInRange(value, minimum, maximum) {
26
28
  return (typeof value === "number" &&
@@ -0,0 +1,13 @@
1
+ export const playFailureKinds = [
2
+ "outcome_unknown",
3
+ "context_limit",
4
+ "output_limit",
5
+ "context_unavailable",
6
+ "retryable",
7
+ "provider_error",
8
+ "other",
9
+ "configuration_mismatch",
10
+ ];
11
+ export function isPlayFailureKind(value) {
12
+ return playFailureKinds.some((kind) => kind === value);
13
+ }
@@ -0,0 +1,13 @@
1
+ export function isProtagonistTemplateId(value) {
2
+ return (typeof value === "string" &&
3
+ /^template-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u.test(value));
4
+ }
5
+ export function isProtagonistTemplateContent(name, body) {
6
+ return (typeof name === "string" &&
7
+ name.trim().length > 0 &&
8
+ [...name].length <= 80 &&
9
+ !/[\r\n\u0085\u2028\u2029]/u.test(name) &&
10
+ typeof body === "string" &&
11
+ body.trim().length > 0 &&
12
+ new TextEncoder().encode(body).byteLength <= 16 * 1024);
13
+ }
@@ -1,3 +1,4 @@
1
+ import { isAppReadingPreferences, } from "./appPreferences.js";
1
2
  import { maxPortableContentArchiveBase64Characters } from "./contentTree.js";
2
3
  export const v1Protocol = "narraeon.runtime/v1";
3
4
  export class V1ProtocolError extends Error {
@@ -20,11 +21,15 @@ export function parseV1Envelope(value) {
20
21
  }
21
22
  const requiredFields = {
22
23
  "preferences.save": {},
24
+ "protagonist-templates.save": { name: "string", body: "string" },
25
+ "protagonist-templates.copy": { id: "string" },
26
+ "protagonist-templates.delete": { id: "string" },
23
27
  "model.save": { connection: "object" },
24
28
  "model.copy": { connectionId: "string", name: "string" },
25
29
  "model.select": { connectionId: "string" },
26
30
  "model.delete": { connectionId: "string" },
27
31
  "model.models": { provider: "string", baseUrl: "string" },
32
+ "content.create-sample": { locale: "string" },
28
33
  "content.scripts.read": { packageId: "string" },
29
34
  "content.scripts.set": { packageId: "string", enabled: "boolean" },
30
35
  "world.package-scripts.read": { worldId: "string" },
@@ -82,8 +87,11 @@ const requiredFields = {
82
87
  packageId: "string",
83
88
  model: "object",
84
89
  },
90
+ "world.archive.export": { worldId: "string", operationId: "string" },
91
+ "world.archive.import": { operationId: "string", uploadId: "string" },
85
92
  "world.read": { worldId: "string" },
86
93
  "world.rename": { worldId: "string", name: "string" },
94
+ "world.story.export": { worldId: "string", format: "string" },
87
95
  "world.delete": { worldId: "string" },
88
96
  "artifacts.read": { worldId: "string" },
89
97
  "artifacts.debug": { worldId: "string" },
@@ -204,6 +212,14 @@ function validateRequestFields(request) {
204
212
  if (!valid || (kind === "string" && value === "" && !allowsEmptyString))
205
213
  throw new V1ProtocolError("invalid_request", `${String(request.type)}.${field} is invalid`);
206
214
  }
215
+ if (request.type === "world.story.export" &&
216
+ request.format !== "markdown" &&
217
+ request.format !== "text")
218
+ throw new V1ProtocolError("invalid_request", "world.story.export.format must be markdown or text");
219
+ if (request.type === "content.create-sample" &&
220
+ (Object.keys(request).some((key) => key !== "type" && key !== "locale") ||
221
+ (request.locale !== "en" && request.locale !== "zh-CN")))
222
+ throw new V1ProtocolError("invalid_request", "content.create-sample.locale must be en or zh-CN, with no extra fields");
207
223
  if (request.type === "world.extensions.set" &&
208
224
  !["on", "off", "default"].includes(String(request.value)))
209
225
  throw new V1ProtocolError("invalid_request", "Invalid world extension choice");
@@ -216,7 +232,7 @@ function validateRequestFields(request) {
216
232
  request.locale !== "zh-CN")
217
233
  throw new V1ProtocolError("invalid_request", "preferences.save.locale must be en or zh-CN");
218
234
  if (request.reading !== undefined &&
219
- !isValidReadingPreferences(request.reading))
235
+ !isAppReadingPreferences(request.reading))
220
236
  throw new V1ProtocolError("invalid_request", "preferences.save.reading is invalid");
221
237
  }
222
238
  if (request.type === "content.import") {
@@ -359,6 +375,10 @@ function validateRequestFields(request) {
359
375
  const requestTypes = new Set([
360
376
  "workspace.read",
361
377
  "preferences.read",
378
+ "protagonist-templates.read",
379
+ "protagonist-templates.save",
380
+ "protagonist-templates.copy",
381
+ "protagonist-templates.delete",
362
382
  "preferences.save",
363
383
  "model.read",
364
384
  "model.save",
@@ -367,6 +387,7 @@ const requestTypes = new Set([
367
387
  "model.delete",
368
388
  "model.models",
369
389
  "content.create",
390
+ "content.create-sample",
370
391
  "content.scripts.read",
371
392
  "content.scripts.set",
372
393
  "world.package-scripts.read",
@@ -401,8 +422,11 @@ const requestTypes = new Set([
401
422
  "play.workbench.read",
402
423
  "prompt.preview",
403
424
  "world.create",
425
+ "world.archive.export",
426
+ "world.archive.import",
404
427
  "world.read",
405
428
  "world.rename",
429
+ "world.story.export",
406
430
  "world.delete",
407
431
  "artifacts.read",
408
432
  "artifacts.debug",
@@ -447,21 +471,3 @@ const requestTypes = new Set([
447
471
  function isRecord(value) {
448
472
  return typeof value === "object" && value !== null && !Array.isArray(value);
449
473
  }
450
- function isValidReadingPreferences(value) {
451
- if (!isRecord(value))
452
- return false;
453
- return (Object.keys(value).length === 5 &&
454
- (value.density === "compact" ||
455
- value.density === "standard" ||
456
- value.density === "relaxed") &&
457
- isFiniteRange(value.fontSize, 15, 24) &&
458
- isFiniteRange(value.lineHeight, 1.4, 2.4) &&
459
- isFiniteRange(value.letterSpacing, 0, 0.12) &&
460
- isFiniteRange(value.measure, 32, 72));
461
- }
462
- function isFiniteRange(value, minimum, maximum) {
463
- return (typeof value === "number" &&
464
- Number.isFinite(value) &&
465
- value >= minimum &&
466
- value <= maximum);
467
- }
@@ -0,0 +1,7 @@
1
+ export const worldArchiveFormat = "narraeon.world-archive/v1";
2
+ export const worldArchiveLimits = {
3
+ archiveBytes: 1024 ** 3,
4
+ extractedBytes: 2 * 1024 ** 3,
5
+ entries: 200_000,
6
+ entryBytes: 256 * 1024 ** 2,
7
+ };
@@ -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,14 @@ 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 { ProtagonistTemplateStore } from "./config/ProtagonistTemplateStore.js";
27
31
  import { AppPreferencesStore } from "./config/AppPreferencesStore.js";
28
32
  import { FileNativeWorldRevisionStore } from "./world-revision/FileNativeWorldRevisionStore.js";
29
33
  import { WorldRevisionSession } from "./world-revision/WorldRevisionSession.js";
30
34
  import { WorldRevisionWorkspace, WorldRevisionWorkspaceError, } from "./world-revision/WorldRevisionWorkspace.js";
31
35
  import { ConversationChanges } from "./ConversationChanges.js";
32
36
  export class V1Runtime {
37
+ worldArchives;
33
38
  #conversationChanges = new ConversationChanges();
34
39
  #content;
35
40
  #playPresets;
@@ -40,12 +45,14 @@ export class V1Runtime {
40
45
  #playCallChains;
41
46
  #failureLog;
42
47
  #preferences;
48
+ #protagonistTemplates;
43
49
  #settingImprovements;
44
50
  #worldRevisions;
45
51
  #compiler = new FileNativePromptCompiler();
46
52
  #locale = defaultAppLocale;
47
53
  constructor(input) {
48
54
  this.#preferences = new AppPreferencesStore(input.configRoot);
55
+ this.#protagonistTemplates = new ProtagonistTemplateStore(input.configRoot);
49
56
  this.#content = new ContentWorkspace(input.dataRoot, {
50
57
  locale: () => this.#locale,
51
58
  });
@@ -59,6 +66,11 @@ export class V1Runtime {
59
66
  ? undefined
60
67
  : new FileNativeAiFailureLog(join(input.logRoot, "ai-failures"));
61
68
  this.#playCallChains = new PlayCallChain(this.#worlds, this.#compiler, this.#artifacts, this.#failureLog, (id) => this.#conversationChanges.publish(`play:${id}`));
69
+ this.worldArchives = new WorldArchives({
70
+ dataRoot: input.dataRoot,
71
+ worlds: this.#worlds,
72
+ chains: this.#playCallChains,
73
+ });
62
74
  this.#models = new ModelConnectionStore(input.configRoot);
63
75
  this.#corrections = new FileNativeContinuityCorrection(this.#worlds, {
64
76
  compiler: this.#compiler,
@@ -133,16 +145,28 @@ export class V1Runtime {
133
145
  this.#locale = (await this.#preferences.view()).locale;
134
146
  this.#compiler.setLocale(this.#locale);
135
147
  await this.#playPresets.initialize();
148
+ await this.worldArchives.initialize();
136
149
  }
137
150
  async handle(request, playCallChainObserver) {
138
151
  try {
139
152
  const result = await this.#dispatch(request, playCallChainObserver);
140
153
  return { protocol: v1Protocol, result };
141
154
  }
155
+ catch (error) {
156
+ if (error instanceof WorldArchiveError)
157
+ throw new V1ProtocolError(error.code, error.message, { cause: error });
158
+ if (request.type === "world.archive.export" &&
159
+ error instanceof FileNativeWorldCreationError &&
160
+ (error.code === "operation_conflict" ||
161
+ error.code === "inconsistent_materialization"))
162
+ throw new V1ProtocolError(error.code, error.message, { cause: error });
163
+ throw error;
164
+ }
142
165
  finally {
143
166
  // Explicit commands may partially settle before failing. Readers recover
144
167
  // through the same Runtime projection, never infer success from this signal.
145
- if (!/\.(read|status|overview|inspect|page|detail|list|preview)$/.test(request.type)) {
168
+ if (request.type !== "world.story.export" &&
169
+ !/\.(read|status|overview|inspect|page|detail|list|preview)$/.test(request.type)) {
146
170
  if ("packageId" in request)
147
171
  this.#conversationChanges.publish(`setting:${request.packageId}`);
148
172
  if ("worldId" in request) {
@@ -213,6 +237,14 @@ export class V1Runtime {
213
237
  await this.#playPresets.syncBuiltinDefaultLocale();
214
238
  return preferences;
215
239
  }
240
+ case "protagonist-templates.read":
241
+ return this.#protagonistTemplates.view();
242
+ case "protagonist-templates.save":
243
+ return this.#protagonistTemplates.save(request);
244
+ case "protagonist-templates.copy":
245
+ return this.#protagonistTemplates.copy(request.id);
246
+ case "protagonist-templates.delete":
247
+ return this.#protagonistTemplates.delete(request.id);
216
248
  case "model.read":
217
249
  return this.#models.view();
218
250
  case "model.save":
@@ -227,6 +259,8 @@ export class V1Runtime {
227
259
  return this.#models.listModels(request);
228
260
  case "content.create":
229
261
  return this.#content.createCurrentTreeContentPackage();
262
+ case "content.create-sample":
263
+ return this.#content.createSampleContentPackage(request.locale);
230
264
  case "content.scripts.read":
231
265
  return this.#content.packageScriptPermissions(request.packageId);
232
266
  case "content.scripts.set":
@@ -402,12 +436,18 @@ export class V1Runtime {
402
436
  return this.#worlds.getCreationOutcome(request.operationId);
403
437
  case "world.rename":
404
438
  return this.#worlds.renameWorld(request.worldId, request.name);
439
+ case "world.story.export":
440
+ return this.#worlds.exportStory(request.worldId, request.format, this.#locale);
405
441
  case "world.delete":
406
442
  return playCall(async () => {
407
443
  await this.#worlds.assertWorldRevisionUnlocked(request.worldId);
408
444
  this.#playCallChains.forgetWorld(request.worldId);
409
445
  return this.#worlds.deleteWorld(request.worldId);
410
446
  });
447
+ case "world.archive.export":
448
+ return this.worldArchives.exportWorld(request);
449
+ case "world.archive.import":
450
+ return this.worldArchives.importWorld(request);
411
451
  case "world.read": {
412
452
  const head = await this.#worlds.currentHead(request.worldId);
413
453
  const playerViews = await this.#worlds.renderPlayerViewsAtHead(request.worldId, head);
@@ -435,6 +475,7 @@ export class V1Runtime {
435
475
  playCallChain: null,
436
476
  playTimeline,
437
477
  worldRevision: revisionEpoch,
478
+ archiveNeedsFreshContext: await this.#worlds.archiveNeedsFreshContext(request.worldId),
438
479
  };
439
480
  }
440
481
  case "world.surface.read":
@@ -542,7 +583,7 @@ export class V1Runtime {
542
583
  hostPresetId: preset.id,
543
584
  });
544
585
  await this.#reconcileResultArtifacts(result);
545
- return result;
586
+ return { outcome: result.outcome, world: result.world };
546
587
  }
547
588
  case "play.chain.revise-player": {
548
589
  const base = {
@@ -634,8 +675,7 @@ export class V1Runtime {
634
675
  const frozenBinding = persisted?.value.modelBinding;
635
676
  const continueContext = currentContext !== null &&
636
677
  !currentContext.stale &&
637
- frozenBinding !== undefined &&
638
- equalModelHostBinding(frozenBinding, modelBinding);
678
+ frozenBinding?.provider === modelBinding.provider;
639
679
  const baseline = continueContext
640
680
  ? await this.#worlds.bindPlayCallChainAt(request.worldId, currentContext.baselineHead)
641
681
  : binding;
@@ -928,7 +968,7 @@ async function playCall(operation) {
928
968
  }
929
969
  catch (error) {
930
970
  if (error instanceof PlayCallChainError)
931
- throw new V1ProtocolError("invalid_request", error.message, {
971
+ throw new V1ProtocolError(error.code, error.message, {
932
972
  cause: error,
933
973
  });
934
974
  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
+ }