@onlyworlds/sdk 4.8.1 → 4.10.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/AGENTS.md CHANGED
@@ -27,6 +27,9 @@ against live data). The v1 surface (`OnlyWorldsClient`, the `ElementType` enum)
27
27
  - Never send a `"world"` field in payloads — world identity comes from the API key (422 otherwise).
28
28
  - v2 link fields use ONE name both directions (no `_ids` suffix — that is v1 dialect only).
29
29
  - PATCH is destructive on sent fields; use `editLinks` (atomic add/remove) for relationships.
30
+ - Offline or stale edits: pass `{ ifMatch: el.change_seq }` to `patch`/`editLinks` (since 4.9.0).
31
+ If the element moved, nothing is written and `OwPreconditionFailedError` (412) carries
32
+ `current`; the server never merges, the client decides.
30
33
  - World-meta changes do NOT appear in `/changes` — poll `GET /world` separately.
31
34
  - Extension fields: `x_<toolname>_*` is the sanctioned namespace for tool-specific state;
32
35
  unknown unprefixed fields 422. Extensions are capped at **64 KB per element** (422,
@@ -37,6 +40,10 @@ against live data). The v1 surface (`OnlyWorldsClient`, the `ElementType` enum)
37
40
  - Images: `uploadImage(bytes)` returns `{ url, ... }`; set `url` as `image_url`. The bytes go
38
41
  to the edge with a single-use ticket from keel, never with the API key. `removeImage(urlOrKey)`
39
42
  takes one back (its uploader or the world's owner); it leaves `image_url` on elements as is.
43
+ - World files (no account needed; since 4.10.0): `readWorldFile(bytes)` opens a `.zip` holding one
44
+ world folder and returns `{ worldId, name, world, files: [{ path, data }] }`;
45
+ `writeWorldFile(files, topDir)` makes one. Refusals throw `OwWorldFileError` with a `reason`.
46
+ `world.json`'s `api` block (link and key) is removed both ways.
40
47
  - Ids: the client mints **UUIDv7** on an id-less `create` (since 4.2.0; keel mints v7 too).
41
48
  A v4 or v7 id you supply is accepted. **Never sort elements by id**: worlds mix v7, v4
42
49
  and legacy `06x…` ids (nibble 7 too, but seconds-first). For creation order use
package/CHANGELOG.md CHANGED
@@ -5,6 +5,43 @@ earlier history lives in git log only.
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [4.10.0] — 2026-10-11
9
+
10
+ ### Added
11
+ - **World files.** `readWorldFile(bytes, { maxBytes, maxEntries, skip })` opens a `.zip` that
12
+ holds one world folder (Folder Format §4.1, a portable copy) and returns
13
+ `{ worldId, name, world, files }`; `writeWorldFile(files, topDir)` makes one. Bytes in,
14
+ bytes out: no DOM or file system, so it runs in the browser and in Node. Absorbed from
15
+ Atlas's reader (Temper's), so Atlas, Lantern and scripts open one file the same way.
16
+ The reader refuses before returning anything, with **`OwWorldFileError`** and a `reason`:
17
+ `unsafe_path` (climbs out, absolute, a drive, a backslash, an empty part, a path that is
18
+ both a file and a folder), `too_big` / `too_many` (1 GiB and 200,000 files uncompressed,
19
+ `WORLD_FILE_MAX_BYTES` / `WORLD_FILE_MAX_ENTRIES`; the inflated bytes are counted again,
20
+ since a declared size can lie), `not_a_world` (no `world.json`, unreadable, no string
21
+ `id`), `more_than_one` (two worlds, loose files beside the folder, a nested world.json),
22
+ `not_openable` (not a zip). Archive junk (`__MACOSX`, `.DS_Store`, `Thumbs.db`) is
23
+ dropped. `world.json`'s `api` (the link and its key) is removed on read and on write; a
24
+ `world.json` without one keeps its exact bytes. The writer refuses a `world.json` that will
25
+ not parse rather than zip it raw. Every world in a real Atlas folder (15, up to 3,832
26
+ files) round-trips byte for byte.
27
+ - **First runtime dependency**: `fflate` (zip, no dependencies of its own, the one Atlas
28
+ uses). Tree-shaken away for code that never calls the world-file functions.
29
+
30
+ ## [4.9.0] — 2026-10-11
31
+
32
+ ### Added
33
+ - **Only if unchanged (keel #210).** `patch(type, id, partial, { ifMatch })` and
34
+ `editLinks(type, id, field, edit, { ifMatch })` send `If-Match: "<change_seq>"`: the write
35
+ lands only if the element is still at that `change_seq`. When it has moved, nothing is
36
+ written and the call throws **`OwPreconditionFailedError`** (412 `precondition_failed`), an
37
+ `OwApiError` whose `current` is the element as it is now, so the client's re-read is free.
38
+ `OwApiError.isPreconditionFailed` names the case. The server never merges; the client
39
+ compares the copy it edited from with `current`. An `ifMatch` that is not a whole number
40
+ throws `TypeError` before anything is sent. Without `ifMatch`, nothing changes.
41
+ Checked live on keel-staging: a matching token lands, a stale one is refused with
42
+ `current` at the live `change_seq`, the refused write changes nothing, and the links
43
+ route behaves the same.
44
+
8
45
  ## [4.8.1] — 2026-10-11
9
46
 
10
47
  ### Changed
package/README.md CHANGED
@@ -119,9 +119,41 @@ for await (const change of client.changesAll(cursor)) {
119
119
 
120
120
  Edits to the world itself (its name, calendar, `public_read`) don't appear in the change feed. Read `client.getWorld()` and compare `updated_at`.
121
121
 
122
+ ### Only if unchanged
123
+
124
+ A client that edits offline, or from a copy it has held a while, can ask for an update to land only if nobody changed the element since it was read: pass the `change_seq` you read as `ifMatch`, on `patch` or `editLinks`.
125
+
126
+ ```typescript
127
+ try {
128
+ await client.patch('character', el.id, { name: 'Mine' }, { ifMatch: el.change_seq });
129
+ } catch (err) {
130
+ if (err instanceof OwPreconditionFailedError) {
131
+ // nothing was written; err.current is the element as it is now
132
+ }
133
+ }
134
+ ```
135
+
136
+ The check is per element and the server never merges. Compare the copy you edited from with `err.current`: when the fields you changed are not the ones that moved, send again with `ifMatch: err.current.change_seq`; when the same field moved on both sides, ask the user which to keep. Without `ifMatch` a write is unconditional, as before.
137
+
138
+ ## World files
139
+
140
+ A world file is one `.zip` holding a whole world folder (`world.json` and `elements/<type>/*.json`, as Atlas keeps a world on disk), the way Atlas downloads and opens one. It needs no account and works in the browser and in Node.
141
+
142
+ ```typescript
143
+ import { readWorldFile, writeWorldFile, OwWorldFileError } from '@onlyworlds/sdk';
144
+
145
+ const world = readWorldFile(bytes); // a Uint8Array
146
+ world.worldId; world.name; world.world; // world.json, parsed
147
+ world.files; // [{ path, data }], relative to the world's folder, sorted
148
+
149
+ const zip = writeWorldFile(world.files, 'hollow-reach-0192a000'); // one top-level folder
150
+ ```
151
+
152
+ The reader refuses a file before returning anything: a path that could land outside the folder, more than 1 GiB or 200,000 files uncompressed (`maxBytes`, `maxEntries` to change them), no `world.json` with an `id`, or more than one world. Check `err.reason` on the `OwWorldFileError` it throws. The world's OnlyWorlds link and key (`world.json`'s `api`) never travel: both functions remove them. A tool's own folder (`.atlas/` and the like) comes back like any other file; pass `skip: path => …` to leave files out.
153
+
122
154
  ## Errors
123
155
 
124
- Every non-2xx response throws `OwApiError` with the API's envelope: `.status`, `.type`, `.code`, `.param` (the field that failed) and `.docUrl`, which is worth showing to your users. A transport failure throws `OwNetworkError`. `err.isValidationError` covers the common case.
156
+ Every non-2xx response throws `OwApiError` with the API's envelope: `.status`, `.type`, `.code`, `.param` (the field that failed) and `.docUrl`, which is worth showing to your users. A transport failure throws `OwNetworkError`. `err.isValidationError` covers the common case. A write sent with `ifMatch` that found the element moved throws `OwPreconditionFailedError` (412, `err.isPreconditionFailed`), an `OwApiError` that also carries `current`.
125
157
 
126
158
  ## Schema constants
127
159
 
package/dist/index.d.ts CHANGED
@@ -2132,6 +2132,25 @@ declare class OwApiError extends Error {
2132
2132
  * key never sees it.
2133
2133
  */
2134
2134
  get isOwnerOnly(): boolean;
2135
+ /**
2136
+ * A `patch` or `editLinks` sent with `ifMatch` found the element moved past that
2137
+ * `change_seq` (412 `precondition_failed`, keel #210): nothing was written and nothing
2138
+ * merged. The error is an OwPreconditionFailedError; its `current` is the element as it
2139
+ * is now, so no second read is needed.
2140
+ */
2141
+ get isPreconditionFailed(): boolean;
2142
+ }
2143
+ /**
2144
+ * 412 `precondition_failed` (keel #210): the element changed since the `change_seq` sent as
2145
+ * `ifMatch`. `current` is the element as it is now, as this key sees it (keel sends it beside
2146
+ * the error envelope; null if a response ever lacks it). The check is per element and the
2147
+ * server never merges: compare the copy you edited from with `current`; when the fields you
2148
+ * changed are not the ones that moved, re-send with `ifMatch: current.change_seq`; when the
2149
+ * same field moved on both sides, ask the user which to keep.
2150
+ */
2151
+ declare class OwPreconditionFailedError extends OwApiError {
2152
+ readonly current: Record<string, unknown> | null;
2153
+ constructor(status: number, code: string | null, message: string, docUrl: string | null, detail: unknown, type?: string | null, param?: string | null, retryAfter?: number | null);
2135
2154
  }
2136
2155
  /** Network-level failure (fetch rejected) -- no envelope to parse. */
2137
2156
  declare class OwNetworkError extends Error {
@@ -2227,8 +2246,15 @@ declare class OwV2Client {
2227
2246
  * PATCH /{type}/{id}/ -- partial update. DESTRUCTIVE on sent fields: arrays
2228
2247
  * replace wholesale, omitted fields stay untouched. For link arrays prefer
2229
2248
  * editLinks() -- atomic server-side merge, no read-before-write.
2249
+ *
2250
+ * `ifMatch` (keel #210): the element's `change_seq` as you read it. The update lands
2251
+ * only if nobody changed the element since; otherwise nothing is written and this throws
2252
+ * an OwPreconditionFailedError (412) whose `current` is the element as it is now. Omit
2253
+ * it for today's unconditional write.
2230
2254
  */
2231
- patch(type: ElementType | string, id: string, partial: Record<string, unknown>): Promise<OwElement>;
2255
+ patch(type: ElementType | string, id: string, partial: Record<string, unknown>, opts?: {
2256
+ ifMatch?: number;
2257
+ }): Promise<OwElement>;
2232
2258
  /**
2233
2259
  * DELETE /{type}/{id}/ -- idempotent (204 on absent). Server writes a
2234
2260
  * tombstone AND scrubs the id from every other element's links in the same
@@ -2239,9 +2265,12 @@ declare class OwV2Client {
2239
2265
  * POST /{type}/{id}/links/{field} with {add, remove} -- atomic link merge.
2240
2266
  * Dedupes, tolerates already-present/already-absent ids. Returns the FULL
2241
2267
  * updated element (fixture P5). Use this for all relationship editing; it
2242
- * retires the read-merge-PATCH dance.
2268
+ * retires the read-merge-PATCH dance. `ifMatch` works as on patch(): a link write
2269
+ * moves the element too.
2243
2270
  */
2244
- editLinks(type: ElementType | string, id: string, field: string, edit: OwLinkEdit): Promise<OwElement>;
2271
+ editLinks(type: ElementType | string, id: string, field: string, edit: OwLinkEdit, opts?: {
2272
+ ifMatch?: number;
2273
+ }): Promise<OwElement>;
2245
2274
  /**
2246
2275
  * POST /bulk -- up to ~1000 items. Partial success by default (HTTP 200
2247
2276
  * always; inspect per-slot numeric `status` + top-level `errors` flag);
@@ -2353,6 +2382,8 @@ declare class OwV2Client {
2353
2382
  auth?: boolean;
2354
2383
  allowEmpty?: boolean;
2355
2384
  replayAware?: boolean;
2385
+ /** A `change_seq` to send as `If-Match` (keel #210); a whole number, else TypeError before sending. */
2386
+ ifMatch?: number;
2356
2387
  }): Promise<T>;
2357
2388
  }
2358
2389
 
@@ -2428,6 +2459,73 @@ declare function getElementIcon(type: string): string;
2428
2459
  /** Plural display label for an element type (e.g. phenomenon → "Phenomena"). */
2429
2460
  declare function getElementLabel(elementType: ElementType): string;
2430
2461
 
2462
+ /**
2463
+ * The world file: one `.zip` holding one whole world as a portable copy
2464
+ * (Folder Format spec §4.1, "a portable copy"), and the reader that opens one
2465
+ * back. Absorbed from Atlas's `src/store/world-file.ts` (Temper's, Atlas
2466
+ * 1.17), so Atlas, Lantern and any script read one file the same way.
2467
+ *
2468
+ * THE FILE: one top-level folder holding every file of the world as it sits
2469
+ * on disk, except the OnlyWorlds link and its key (`world.json` without `api`).
2470
+ * JSON is deflated, everything else stored. A world.json at the root, with no
2471
+ * folder, reads the same.
2472
+ *
2473
+ * THE READER takes bytes and returns bytes: no DOM, `File` or file system, so
2474
+ * it runs in a browser and in Node alike. It refuses, before returning
2475
+ * anything: a path that climbs out, is absolute, names a drive, holds a
2476
+ * backslash or an empty part, or is both a file and a folder; a declared size
2477
+ * or a file count past the budget (and the inflated bytes are counted again,
2478
+ * since a declared size can lie); no world, more than one world, files
2479
+ * outside the world's folder, a world.json nested inside the world; and a
2480
+ * world.json that will not parse or has no string `id`. Archive junk
2481
+ * (`__MACOSX`, `.DS_Store`, `Thumbs.db`) is dropped.
2482
+ *
2483
+ * A tool's own `.<tool>/` folder is the tool's (spec §4): this module knows
2484
+ * none of them. A caller that leaves some of its files out passes `skip`.
2485
+ */
2486
+ /** What one world file may hold, uncompressed: Atlas's budget, the writer's and the reader's alike. */
2487
+ declare const WORLD_FILE_MAX_BYTES: number;
2488
+ declare const WORLD_FILE_MAX_ENTRIES = 200000;
2489
+ type OwWorldFileReason = 'unsafe_path' | 'too_big' | 'too_many' | 'not_a_world' | 'more_than_one' | 'not_openable';
2490
+ /** A world file was refused; nothing was returned. `reason` says why, for a tool to word its own message. */
2491
+ declare class OwWorldFileError extends Error {
2492
+ readonly reason: OwWorldFileReason;
2493
+ constructor(reason: OwWorldFileReason, message?: string);
2494
+ }
2495
+ interface OwWorldFileEntry {
2496
+ /** Relative to the world's folder, `/`-separated. */
2497
+ path: string;
2498
+ data: Uint8Array;
2499
+ }
2500
+ interface OwReadWorldFile {
2501
+ worldId: string;
2502
+ /** `world.json`'s `name` as the file has it; `''` when it has none. */
2503
+ name: string;
2504
+ /** `world.json`, parsed, without `api`. */
2505
+ world: Record<string, unknown>;
2506
+ /** The world's files, relative to its folder, `world.json` included (without `api`), sorted by path. */
2507
+ files: OwWorldFileEntry[];
2508
+ }
2509
+ interface OwReadWorldFileOptions {
2510
+ maxBytes?: number;
2511
+ maxEntries?: number;
2512
+ /** Leave a file out of `files` (a path relative to the world's folder). It still counts against the budget. */
2513
+ skip?: (path: string) => boolean;
2514
+ }
2515
+ /**
2516
+ * Read a world file. Throws `OwWorldFileError` saying why, having returned
2517
+ * nothing; the caller writes only what this returns.
2518
+ */
2519
+ declare function readWorldFile(bytes: Uint8Array, opts?: OwReadWorldFileOptions): OwReadWorldFile;
2520
+ /**
2521
+ * Write a world file: every file under one top-level folder `topDir`, JSON
2522
+ * deflated, everything else stored, `world.json` without `api`. Refuses
2523
+ * (`OwWorldFileError`) an unsafe path or `topDir`, a duplicate path, and a
2524
+ * world.json that is missing, will not parse (it may hold a key, so it is
2525
+ * never zipped raw) or has no `id`.
2526
+ */
2527
+ declare function writeWorldFile(files: OwWorldFileEntry[], topDir: string): Uint8Array;
2528
+
2431
2529
  /**
2432
2530
  * OnlyWorlds Token Management API Types
2433
2531
  *
@@ -2712,4 +2810,4 @@ declare class TokenResource {
2712
2810
  getEncryptionInfo(): Promise<EncryptionInfo>;
2713
2811
  }
2714
2812
 
2715
- export { type AccessKeyResponse, ELEMENT_FAMILIES, ELEMENT_ICONS, ELEMENT_LABELS, ELEMENT_SECTIONS, ELEMENT_TYPES, type ElementFamily, type ElementType, type EncryptionInfo, FAMILY_COLORS, FAMILY_ORDER, FIELD_SCHEMA, type FieldInfo, type FieldType, GameTier, type ListParams, ONLYWORLDS_VERSION, OwApiError, type OwAuthErrorCode, type OwBulkItem, type OwBulkItemResult, type OwBulkResponse, type OwChange, type OwChangesPage, type OwClientConfig, type OwElement, type OwElementBase, type OwErrorBody, type OwKeyKind, type OwLinkEdit, type OwMediaTicket, OwNetworkError, type OwPage, type OwRemovalTicket, type OwRemovedImage, type OwUploadedImage, OwV2Client, type OwWorldMeta, type RevokeAllSessionsResponse, type RevokeSessionResponse, SPATIAL_TYPES, type SectionInfo, type TokenConsumeParams, type TokenConsumeResponse, TokenResource, type TokenStatus, type TokenTransport, detectKeyKind, elementColor, errorFromResponse, familyOf, getElementIcon, getElementLabel, isDemoKey, kindCanWrite, parseErrorEnvelope, pinExpectation };
2813
+ export { type AccessKeyResponse, ELEMENT_FAMILIES, ELEMENT_ICONS, ELEMENT_LABELS, ELEMENT_SECTIONS, ELEMENT_TYPES, type ElementFamily, type ElementType, type EncryptionInfo, FAMILY_COLORS, FAMILY_ORDER, FIELD_SCHEMA, type FieldInfo, type FieldType, GameTier, type ListParams, ONLYWORLDS_VERSION, OwApiError, type OwAuthErrorCode, type OwBulkItem, type OwBulkItemResult, type OwBulkResponse, type OwChange, type OwChangesPage, type OwClientConfig, type OwElement, type OwElementBase, type OwErrorBody, type OwKeyKind, type OwLinkEdit, type OwMediaTicket, OwNetworkError, type OwPage, OwPreconditionFailedError, type OwReadWorldFile, type OwReadWorldFileOptions, type OwRemovalTicket, type OwRemovedImage, type OwUploadedImage, OwV2Client, type OwWorldFileEntry, OwWorldFileError, type OwWorldFileReason, type OwWorldMeta, type RevokeAllSessionsResponse, type RevokeSessionResponse, SPATIAL_TYPES, type SectionInfo, type TokenConsumeParams, type TokenConsumeResponse, TokenResource, type TokenStatus, type TokenTransport, WORLD_FILE_MAX_BYTES, WORLD_FILE_MAX_ENTRIES, detectKeyKind, elementColor, errorFromResponse, familyOf, getElementIcon, getElementLabel, isDemoKey, kindCanWrite, parseErrorEnvelope, pinExpectation, readWorldFile, writeWorldFile };
package/dist/index.js CHANGED
@@ -66,6 +66,23 @@ var OwApiError = class extends Error {
66
66
  get isOwnerOnly() {
67
67
  return this.status === 403 && this.code === "owner_only";
68
68
  }
69
+ /**
70
+ * A `patch` or `editLinks` sent with `ifMatch` found the element moved past that
71
+ * `change_seq` (412 `precondition_failed`, keel #210): nothing was written and nothing
72
+ * merged. The error is an OwPreconditionFailedError; its `current` is the element as it
73
+ * is now, so no second read is needed.
74
+ */
75
+ get isPreconditionFailed() {
76
+ return this.status === 412 && this.code === "precondition_failed";
77
+ }
78
+ };
79
+ var OwPreconditionFailedError = class extends OwApiError {
80
+ constructor(status, code, message, docUrl, detail, type = null, param = null, retryAfter = null) {
81
+ super(status, code, message, docUrl, detail, type, param, retryAfter);
82
+ this.name = "OwPreconditionFailedError";
83
+ const cur = detail && typeof detail === "object" ? detail.current : void 0;
84
+ this.current = cur && typeof cur === "object" ? cur : null;
85
+ }
69
86
  };
70
87
  var OwNetworkError = class extends Error {
71
88
  constructor(message, cause) {
@@ -82,7 +99,8 @@ function parseErrorEnvelope(status, body, retryAfter = null) {
82
99
  const param = env.param ?? nested?.param ?? null;
83
100
  const docUrl = env.doc_url ?? nested?.doc_url ?? null;
84
101
  const message = env.message ?? nested?.message ?? (typeof env.detail === "string" ? env.detail : void 0) ?? `OnlyWorlds API error ${status}${code ? ` (${code})` : ""}`;
85
- return new OwApiError(status, code, message, docUrl, body, type, param, retryAfter);
102
+ const Cls = status === 412 && code === "precondition_failed" ? OwPreconditionFailedError : OwApiError;
103
+ return new Cls(status, code, message, docUrl, body, type, param, retryAfter);
86
104
  }
87
105
  function parseRetryAfter(value, now = Date.now()) {
88
106
  if (value == null) return null;
@@ -221,9 +239,14 @@ var OwV2Client = class {
221
239
  * PATCH /{type}/{id}/ -- partial update. DESTRUCTIVE on sent fields: arrays
222
240
  * replace wholesale, omitted fields stay untouched. For link arrays prefer
223
241
  * editLinks() -- atomic server-side merge, no read-before-write.
242
+ *
243
+ * `ifMatch` (keel #210): the element's `change_seq` as you read it. The update lands
244
+ * only if nobody changed the element since; otherwise nothing is written and this throws
245
+ * an OwPreconditionFailedError (412) whose `current` is the element as it is now. Omit
246
+ * it for today's unconditional write.
224
247
  */
225
- async patch(type, id, partial) {
226
- return this.request("PATCH", `/${type}/${id}/`, { body: sanitizePayload(partial) });
248
+ async patch(type, id, partial, opts = {}) {
249
+ return this.request("PATCH", `/${type}/${id}/`, { body: sanitizePayload(partial), ifMatch: opts.ifMatch });
227
250
  }
228
251
  /**
229
252
  * DELETE /{type}/{id}/ -- idempotent (204 on absent). Server writes a
@@ -237,10 +260,11 @@ var OwV2Client = class {
237
260
  * POST /{type}/{id}/links/{field} with {add, remove} -- atomic link merge.
238
261
  * Dedupes, tolerates already-present/already-absent ids. Returns the FULL
239
262
  * updated element (fixture P5). Use this for all relationship editing; it
240
- * retires the read-merge-PATCH dance.
263
+ * retires the read-merge-PATCH dance. `ifMatch` works as on patch(): a link write
264
+ * moves the element too.
241
265
  */
242
- async editLinks(type, id, field, edit) {
243
- return this.request("POST", `/${type}/${id}/links/${field}`, { body: edit });
266
+ async editLinks(type, id, field, edit, opts = {}) {
267
+ return this.request("POST", `/${type}/${id}/links/${field}`, { body: edit, ifMatch: opts.ifMatch });
244
268
  }
245
269
  // -- Bulk ----------------------------------------------------------------
246
270
  /**
@@ -395,6 +419,9 @@ var OwV2Client = class {
395
419
  * sanitizePayload — callers own their body shape.
396
420
  */
397
421
  async request(method, path, opts = {}) {
422
+ if (opts.ifMatch !== void 0 && !(Number.isInteger(opts.ifMatch) && opts.ifMatch >= 0)) {
423
+ throw new TypeError(`ifMatch must be a whole number (an element's change_seq), got ${String(opts.ifMatch)}`);
424
+ }
398
425
  const url = `${this.baseUrl}${path}${opts.query ? `?${opts.query}` : ""}`;
399
426
  const headers = {};
400
427
  if (opts.auth !== false) {
@@ -407,6 +434,7 @@ var OwV2Client = class {
407
434
  }
408
435
  if (opts.body !== void 0) headers["Content-Type"] = "application/json";
409
436
  if (opts.idempotencyKey) headers["Idempotency-Key"] = opts.idempotencyKey;
437
+ if (opts.ifMatch !== void 0) headers["If-Match"] = `"${opts.ifMatch}"`;
410
438
  let res;
411
439
  try {
412
440
  res = await this.fetchImpl(url, {
@@ -1305,6 +1333,140 @@ function getElementLabel(elementType) {
1305
1333
  return ELEMENT_LABELS[elementType];
1306
1334
  }
1307
1335
 
1336
+ // src/world-file.ts
1337
+ import { unzipSync, zipSync } from "fflate";
1338
+ var WORLD_FILE = "world.json";
1339
+ var WORLD_FILE_MAX_BYTES = 1024 ** 3;
1340
+ var WORLD_FILE_MAX_ENTRIES = 2e5;
1341
+ var MESSAGES = {
1342
+ unsafe_path: "The world file holds a path that cannot be written safely.",
1343
+ too_big: "The world file is bigger than the budget allows.",
1344
+ too_many: "The world file holds more files than the budget allows.",
1345
+ not_a_world: "The file is not a world: it has no readable world.json with an id.",
1346
+ more_than_one: "The file holds more than one world, or files outside the world's folder.",
1347
+ not_openable: "The file is not a zip that can be read."
1348
+ };
1349
+ var OwWorldFileError = class extends Error {
1350
+ constructor(reason, message = MESSAGES[reason]) {
1351
+ super(message);
1352
+ this.name = "OwWorldFileError";
1353
+ this.reason = reason;
1354
+ }
1355
+ };
1356
+ function isSafePath(path) {
1357
+ if (!path || path.includes("\\") || path.startsWith("/") || /^[a-zA-Z]:/.test(path)) return false;
1358
+ return path.split("/").every((seg) => seg !== "" && seg !== "." && seg !== "..");
1359
+ }
1360
+ function isJunk(path) {
1361
+ return path.split("/").some((seg) => seg === "__MACOSX" || seg === ".DS_Store" || seg === "Thumbs.db");
1362
+ }
1363
+ function parseWorld(data) {
1364
+ let meta;
1365
+ try {
1366
+ meta = JSON.parse(new TextDecoder().decode(data));
1367
+ } catch {
1368
+ throw new OwWorldFileError("not_a_world");
1369
+ }
1370
+ if (!meta || typeof meta !== "object" || Array.isArray(meta)) throw new OwWorldFileError("not_a_world");
1371
+ const id = meta.id;
1372
+ if (typeof id !== "string" || !id.trim()) throw new OwWorldFileError("not_a_world");
1373
+ return meta;
1374
+ }
1375
+ function withoutLink(data, meta) {
1376
+ if (!("api" in meta)) return { data, world: meta };
1377
+ const { api: _link, ...rest } = meta;
1378
+ return { data: new TextEncoder().encode(JSON.stringify(rest, null, 2) + "\n"), world: rest };
1379
+ }
1380
+ function readWorldFile(bytes, opts = {}) {
1381
+ const maxBytes = opts.maxBytes ?? WORLD_FILE_MAX_BYTES;
1382
+ const maxEntries = opts.maxEntries ?? WORLD_FILE_MAX_ENTRIES;
1383
+ let declared = 0;
1384
+ let count = 0;
1385
+ let refusal = null;
1386
+ let raw;
1387
+ try {
1388
+ raw = unzipSync(bytes, {
1389
+ filter: (file) => {
1390
+ if (refusal) return false;
1391
+ if (file.name.endsWith("/")) return false;
1392
+ if (!isSafePath(file.name)) {
1393
+ refusal = "unsafe_path";
1394
+ return false;
1395
+ }
1396
+ if (isJunk(file.name)) return false;
1397
+ count++;
1398
+ declared += file.originalSize;
1399
+ if (count > maxEntries) {
1400
+ refusal = "too_many";
1401
+ return false;
1402
+ }
1403
+ if (declared > maxBytes) {
1404
+ refusal = "too_big";
1405
+ return false;
1406
+ }
1407
+ return true;
1408
+ }
1409
+ });
1410
+ } catch {
1411
+ throw new OwWorldFileError(refusal ?? "not_openable");
1412
+ }
1413
+ if (refusal) throw new OwWorldFileError(refusal);
1414
+ const names = Object.keys(raw);
1415
+ if (names.reduce((n, k) => n + raw[k].length, 0) > maxBytes) throw new OwWorldFileError("too_big");
1416
+ const nameSet = new Set(names);
1417
+ for (const n of names) {
1418
+ const segs = n.split("/");
1419
+ for (let i = 1; i < segs.length; i++) {
1420
+ if (nameSet.has(segs.slice(0, i).join("/"))) throw new OwWorldFileError("unsafe_path");
1421
+ }
1422
+ }
1423
+ let prefix;
1424
+ if (nameSet.has(WORLD_FILE)) {
1425
+ prefix = "";
1426
+ } else {
1427
+ const tops = new Set(names.map((n) => n.includes("/") ? n.split("/")[0] + "/" : n));
1428
+ const worldTops = [...tops].filter((t) => t.endsWith("/") && nameSet.has(`${t}${WORLD_FILE}`));
1429
+ if (worldTops.length === 0) throw new OwWorldFileError("not_a_world");
1430
+ if (worldTops.length > 1 || tops.size > 1) throw new OwWorldFileError("more_than_one");
1431
+ prefix = worldTops[0];
1432
+ }
1433
+ const worldName = `${prefix}${WORLD_FILE}`;
1434
+ if (names.some((n) => n !== worldName && n.endsWith(`/${WORLD_FILE}`))) throw new OwWorldFileError("more_than_one");
1435
+ const parsed = withoutLink(raw[worldName], parseWorld(raw[worldName]));
1436
+ const files = [];
1437
+ for (const n of names) {
1438
+ const path = n.slice(prefix.length);
1439
+ if (opts.skip?.(path)) continue;
1440
+ files.push({ path, data: n === worldName ? parsed.data : raw[n] });
1441
+ }
1442
+ files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
1443
+ const name = parsed.world.name;
1444
+ return {
1445
+ worldId: parsed.world.id,
1446
+ name: typeof name === "string" ? name : "",
1447
+ world: parsed.world,
1448
+ files
1449
+ };
1450
+ }
1451
+ function writeWorldFile(files, topDir) {
1452
+ if (!isSafePath(topDir) || topDir.includes("/")) throw new OwWorldFileError("unsafe_path");
1453
+ const input = {};
1454
+ let sawWorld = false;
1455
+ for (const f of files) {
1456
+ if (!isSafePath(f.path)) throw new OwWorldFileError("unsafe_path");
1457
+ const key = `${topDir}/${f.path}`;
1458
+ if (key in input) throw new OwWorldFileError("unsafe_path", `The path ${f.path} is listed twice.`);
1459
+ let data = f.data;
1460
+ if (f.path === WORLD_FILE) {
1461
+ data = withoutLink(data, parseWorld(data)).data;
1462
+ sawWorld = true;
1463
+ }
1464
+ input[key] = [data, { level: /\.json$/i.test(f.path) ? 6 : 0 }];
1465
+ }
1466
+ if (!sawWorld) throw new OwWorldFileError("not_a_world");
1467
+ return zipSync(input);
1468
+ }
1469
+
1308
1470
  // src/token-resource.ts
1309
1471
  var TokenResource = class {
1310
1472
  constructor(client) {
@@ -1488,9 +1650,13 @@ export {
1488
1650
  ONLYWORLDS_VERSION,
1489
1651
  OwApiError,
1490
1652
  OwNetworkError,
1653
+ OwPreconditionFailedError,
1491
1654
  OwV2Client,
1655
+ OwWorldFileError,
1492
1656
  SPATIAL_TYPES,
1493
1657
  TokenResource,
1658
+ WORLD_FILE_MAX_BYTES,
1659
+ WORLD_FILE_MAX_ENTRIES,
1494
1660
  detectKeyKind,
1495
1661
  elementColor,
1496
1662
  errorFromResponse,
@@ -1500,5 +1666,7 @@ export {
1500
1666
  isDemoKey,
1501
1667
  kindCanWrite,
1502
1668
  parseErrorEnvelope,
1503
- pinExpectation
1669
+ pinExpectation,
1670
+ readWorldFile,
1671
+ writeWorldFile
1504
1672
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlyworlds/sdk",
3
- "version": "4.8.1",
3
+ "version": "4.10.0",
4
4
  "description": "TypeScript SDK for the OnlyWorlds API - build world-building applications with type safety",
5
5
  "type": "module",
6
6
  "exports": {
@@ -60,5 +60,8 @@
60
60
  },
61
61
  "engines": {
62
62
  "node": ">=18.0.0"
63
+ },
64
+ "dependencies": {
65
+ "fflate": "^0.8.3"
63
66
  }
64
67
  }