narraeon 0.5.5 → 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 (49) 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 +48 -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/HistoryBackground.js +84 -0
  15. package/dist/node/runtime/history/HistoryQuery.js +7 -2
  16. package/dist/node/runtime/history/HistoryRendering.js +31 -4
  17. package/dist/node/runtime/history/StoryExport.js +33 -0
  18. package/dist/node/runtime/model/FileNativeModelAdapters.js +34 -2
  19. package/dist/node/runtime/model/ModelHost.js +12 -1
  20. package/dist/node/runtime/model/ModelHostStream.js +11 -3
  21. package/dist/node/runtime/play/FileNativeContinuityCorrection.js +2 -0
  22. package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +5 -0
  23. package/dist/node/runtime/play/FileNativePlayTimelineStore.js +91 -4
  24. package/dist/node/runtime/play/LegacyModelBindingFailure.js +10 -0
  25. package/dist/node/runtime/play/PlayCallChain.js +212 -61
  26. package/dist/node/runtime/play/PlayCallChainError.js +3 -1
  27. package/dist/node/runtime/play/PlayContinuity.js +3 -20
  28. package/dist/node/runtime/play/PlayFailure.js +20 -0
  29. package/dist/node/runtime/play/PlayTraceRetention.js +10 -11
  30. package/dist/node/runtime/prompt/FileNativePromptCompiler.js +12 -59
  31. package/dist/node/runtime/prompt/FileNativeToolRegistry.js +2 -2
  32. package/dist/node/runtime/world/FileNativeAuthorityV3.js +7 -4
  33. package/dist/node/runtime/world/FileNativeHistoryProjection.js +11 -0
  34. package/dist/node/runtime/world/FileNativeWorldStore.js +250 -55
  35. package/dist/node/runtime/world/WorldActivity.js +143 -0
  36. package/dist/node/runtime/world/WorldYamlRendering.js +28 -0
  37. package/dist/node/server/createServer.js +48 -0
  38. package/dist/node/shared/default-preset-host.js +18 -8
  39. package/dist/node/shared/default-setting-improvement-prompt.js +4 -0
  40. package/dist/node/shared/history-use-guidance.js +2 -2
  41. package/dist/node/shared/ordered-play-prompts.js +2 -2
  42. package/dist/node/shared/sample-packages/index.js +10 -0
  43. package/dist/node/shared/sample-packages/rainy-inn-en.js +194 -0
  44. package/dist/node/shared/sample-packages/rainy-inn-zh.js +194 -0
  45. package/dist/web/assets/index-DxNtxzLR.js +1255 -0
  46. package/dist/web/assets/{index-CVN48xrw.css → index-DzOnu9uv.css} +1 -1
  47. package/dist/web/index.html +2 -2
  48. package/package.json +6 -2
  49. package/dist/web/assets/index-DTFZQan4.js +0 -1223
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,
@@ -116,6 +128,7 @@ export class V1Runtime {
116
128
  controlFingerprint: fingerprintControl(Object.fromEntries(snapshot.files.map(({ path, contents }) => [path, contents]))),
117
129
  documentSnapshot: snapshot,
118
130
  history: structuredClone(binding.history),
131
+ historyBackgrounds: structuredClone(binding.historyBackgrounds ?? {}),
119
132
  narrativeCheckpoint: binding.narrativeCheckpoint,
120
133
  ...maintenance,
121
134
  additionalMaterials: structuredClone(binding.additionalMaterials),
@@ -132,16 +145,28 @@ export class V1Runtime {
132
145
  this.#locale = (await this.#preferences.view()).locale;
133
146
  this.#compiler.setLocale(this.#locale);
134
147
  await this.#playPresets.initialize();
148
+ await this.worldArchives.initialize();
135
149
  }
136
150
  async handle(request, playCallChainObserver) {
137
151
  try {
138
152
  const result = await this.#dispatch(request, playCallChainObserver);
139
153
  return { protocol: v1Protocol, result };
140
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
+ }
141
165
  finally {
142
166
  // Explicit commands may partially settle before failing. Readers recover
143
167
  // through the same Runtime projection, never infer success from this signal.
144
- 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)) {
145
170
  if ("packageId" in request)
146
171
  this.#conversationChanges.publish(`setting:${request.packageId}`);
147
172
  if ("worldId" in request) {
@@ -212,6 +237,14 @@ export class V1Runtime {
212
237
  await this.#playPresets.syncBuiltinDefaultLocale();
213
238
  return preferences;
214
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);
215
248
  case "model.read":
216
249
  return this.#models.view();
217
250
  case "model.save":
@@ -226,6 +259,8 @@ export class V1Runtime {
226
259
  return this.#models.listModels(request);
227
260
  case "content.create":
228
261
  return this.#content.createCurrentTreeContentPackage();
262
+ case "content.create-sample":
263
+ return this.#content.createSampleContentPackage(request.locale);
229
264
  case "content.scripts.read":
230
265
  return this.#content.packageScriptPermissions(request.packageId);
231
266
  case "content.scripts.set":
@@ -401,12 +436,18 @@ export class V1Runtime {
401
436
  return this.#worlds.getCreationOutcome(request.operationId);
402
437
  case "world.rename":
403
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);
404
441
  case "world.delete":
405
442
  return playCall(async () => {
406
443
  await this.#worlds.assertWorldRevisionUnlocked(request.worldId);
407
444
  this.#playCallChains.forgetWorld(request.worldId);
408
445
  return this.#worlds.deleteWorld(request.worldId);
409
446
  });
447
+ case "world.archive.export":
448
+ return this.worldArchives.exportWorld(request);
449
+ case "world.archive.import":
450
+ return this.worldArchives.importWorld(request);
410
451
  case "world.read": {
411
452
  const head = await this.#worlds.currentHead(request.worldId);
412
453
  const playerViews = await this.#worlds.renderPlayerViewsAtHead(request.worldId, head);
@@ -434,6 +475,7 @@ export class V1Runtime {
434
475
  playCallChain: null,
435
476
  playTimeline,
436
477
  worldRevision: revisionEpoch,
478
+ archiveNeedsFreshContext: await this.#worlds.archiveNeedsFreshContext(request.worldId),
437
479
  };
438
480
  }
439
481
  case "world.surface.read":
@@ -541,7 +583,7 @@ export class V1Runtime {
541
583
  hostPresetId: preset.id,
542
584
  });
543
585
  await this.#reconcileResultArtifacts(result);
544
- return result;
586
+ return { outcome: result.outcome, world: result.world };
545
587
  }
546
588
  case "play.chain.revise-player": {
547
589
  const base = {
@@ -633,8 +675,7 @@ export class V1Runtime {
633
675
  const frozenBinding = persisted?.value.modelBinding;
634
676
  const continueContext = currentContext !== null &&
635
677
  !currentContext.stale &&
636
- frozenBinding !== undefined &&
637
- equalModelHostBinding(frozenBinding, modelBinding);
678
+ frozenBinding?.provider === modelBinding.provider;
638
679
  const baseline = continueContext
639
680
  ? await this.#worlds.bindPlayCallChainAt(request.worldId, currentContext.baselineHead)
640
681
  : binding;
@@ -653,6 +694,7 @@ export class V1Runtime {
653
694
  }),
654
695
  additionalMaterials: structuredClone(binding.additionalMaterials),
655
696
  history: structuredClone(binding.history),
697
+ historyBackgrounds: structuredClone(binding.historyBackgrounds ?? {}),
656
698
  narrativeCheckpoint: baseline.narrativeCheckpoint,
657
699
  historyAlreadyAppended: Object.keys(binding.history).filter((key) => !(key in baseline.history)),
658
700
  ...(await this.#worlds.inspectDocumentMaintenance(request.worldId, binding.parentHead)),
@@ -926,7 +968,7 @@ async function playCall(operation) {
926
968
  }
927
969
  catch (error) {
928
970
  if (error instanceof PlayCallChainError)
929
- throw new V1ProtocolError("invalid_request", error.message, {
971
+ throw new V1ProtocolError(error.code, error.message, {
930
972
  cause: error,
931
973
  });
932
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
+ }