@neta-art/cohub 12.4.0 → 12.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -95,6 +95,14 @@ await session.messages.send({
95
95
  });
96
96
  ```
97
97
 
98
+ ## Space files
99
+
100
+ `space.files.read(path)` and `space.files.resolveUrl(path)` share requests: reads
101
+ made in the same task become one batched read, and concurrent reads of a file
102
+ share one request. In browsers with realtime, resolved URLs are remembered per
103
+ user across reloads, confirmed in the background and dropped when the file
104
+ changes; call `clearFileUrlCache()` on sign-out.
105
+
98
106
  ## Boards
99
107
 
100
108
  A Board is one document of `board` settings, `items` and `animations`. Bind
@@ -197,16 +205,14 @@ editor.addMedia("https://cdn.example.com/media?id=42", worldPoint(400, 0), {
197
205
  snapshot: { title: "Launch", naturalWidth: 1920, naturalHeight: 1080 },
198
206
  });
199
207
 
200
- // Media URLs load directly; these callbacks only receive Space paths.
201
- // A published copy can resolve paths against captured artifacts instead.
202
- const source = {
203
- resolveFileUrl: (path: string) => space.files.resolveUrl(path, { purpose: "preview" }),
204
- resolvePlaybackUrl: (path: string) => space.files.resolveUrl(path, { purpose: "playback" }),
205
- };
208
+ // Media URLs load directly; Space paths resolve through the Space's file URLs,
209
+ // which reload pictures when their file changes. A published copy can resolve
210
+ // paths against captured artifacts instead.
211
+ const source = space.boards.assetSource();
206
212
  const stage = mountBoardStage(element, {
207
213
  editor,
208
214
  source,
209
- assets: createBoardAssetManager({ resolveFileUrl: source.resolveFileUrl }),
215
+ assets: createBoardAssetManager(source),
210
216
  views: [{
211
217
  type: "acme.order",
212
218
  // Mount anything: a React root, a Vue app, plain DOM.
@@ -245,9 +251,10 @@ Pictures your own items show go through `itemAsset`. They share the built-in
245
251
  loader: CDN variants sized to the screen, loads ordered by where the view is
246
252
  heading, prefetch, compact copies when panned away, device budgets, one cache
247
253
  and exports. `src` is a Space path, resolved like an image item's, or a URL;
248
- `fit: "cover"` sizes pictures cropped to fill their box by the short edge. Call
249
- `invalidateItemAssets()` when the data it reads changes, and `invalidatePath`
250
- when a file changes:
254
+ `fit: "cover"` sizes pictures cropped to fill their box by the short edge. When
255
+ the data it reads changes for some items, call `invalidateItemAssets(ids)` and
256
+ `stage.invalidate({ items: ids })` (omit `ids` when everything changed); call
257
+ `invalidatePath` when a file changes:
251
258
 
252
259
  ```ts
253
260
  const assets = createBoardAssetManager({
@@ -6,6 +6,7 @@ type BoardAssetUrlOptions = {
6
6
  type BoardAssetSource = {
7
7
  resolveFileUrl: (path: string, options?: BoardAssetUrlOptions) => Promise<string | null>;
8
8
  resolvePlaybackUrl?: (path: string, options?: BoardAssetUrlOptions) => Promise<string | null>;
9
+ subscribe?: (listener: (path: string) => void) => () => void;
9
10
  };
10
11
  declare function resolveBoardMediaUrl(src: string, source: BoardAssetSource, purpose?: "preview" | "playback"): Promise<string | null>;
11
12
  type BoardPlayableMedia = {
@@ -1,5 +1,5 @@
1
1
  import { BoardDelta, BoardDocument, BoardPatch } from "../../protocol/dist/board-model.js";
2
- import { BoardApplyResult, BoardPlaybackSnapshot, BoardReadResult } from "../../protocol/dist/board.js";
2
+ import { BoardApplyResult, BoardPlaybackSnapshot, BoardReadInput, BoardReadResult } from "../../protocol/dist/board.js";
3
3
  import "../../protocol/dist/index.js";
4
4
  import { BoardReplicaDatabaseOptions, BoardReplicaScope, clearBoardReplicaStorage, createIndexedDbBoardReplicaStorage } from "./indexeddb.js";
5
5
  //#region src/board/replica/index.d.ts
@@ -14,7 +14,8 @@ type BoardChangedPayload = {
14
14
  after?: BoardDelta;
15
15
  };
16
16
  type BoardRemote = {
17
- get(): Promise<BoardReadResult>;
17
+ /** `only: []` reads metadata only; remotes may return the full Board. */
18
+ get(read?: Pick<BoardReadInput, "only">): Promise<BoardReadResult>;
18
19
  apply(patch: BoardPatch, options: {
19
20
  mutationId: string;
20
21
  clientId?: string;
@@ -57,7 +57,7 @@ function createBoardReplica(options) {
57
57
  return remote.subscribe({
58
58
  changed: (event) => receive(event.payload),
59
59
  playback: (event) => receivePlayback(event.payload.playback),
60
- resync: () => void retry()
60
+ resync: () => void resync()
61
61
  });
62
62
  } catch {
63
63
  return null;
@@ -220,7 +220,7 @@ function createBoardReplica(options) {
220
220
  }
221
221
  emit();
222
222
  }
223
- function retry() {
223
+ function retry(read = true) {
224
224
  if (disposed) return Promise.resolve();
225
225
  if (retryTimer) clearTimeout(retryTimer);
226
226
  retryTimer = null;
@@ -228,7 +228,18 @@ function createBoardReplica(options) {
228
228
  readError = null;
229
229
  writeError = null;
230
230
  emit();
231
- return refresh().then(flush);
231
+ return (read ? refresh() : Promise.resolve()).then(flush);
232
+ }
233
+ async function resync() {
234
+ await refreshing;
235
+ if (disposed) return;
236
+ if (!loaded || readError) return retry();
237
+ const revision = playbackRevision;
238
+ const head = await remote.get({ only: [] }).catch(() => null);
239
+ if (disposed) return;
240
+ if (!head || head.version > version) return retry();
241
+ if (revision === playbackRevision) playback = head.playback;
242
+ return retry(false);
232
243
  }
233
244
  function onOnline() {
234
245
  if (pending.length || readError || writeError) retry();
@@ -276,7 +287,7 @@ function createBoardReplica(options) {
276
287
  receive,
277
288
  receivePlayback,
278
289
  refresh,
279
- retry,
290
+ retry: () => retry(),
280
291
  dispose() {
281
292
  disposed = true;
282
293
  globalThis.removeEventListener?.("online", onOnline);
@@ -1,102 +1,31 @@
1
+ import { deleteByIndex, indexedDb } from "../../indexeddb.js";
1
2
  //#region src/board/replica/indexeddb.ts
2
3
  const DATABASE = "cohub-board";
3
4
  const VERSION = 1;
4
- const OPEN_TIMEOUT_MS = 3e3;
5
- const connections = /* @__PURE__ */ new Map();
6
- function open(name) {
7
- const existing = connections.get(name);
8
- if (existing) return existing;
9
- const opening = new Promise((resolve, reject) => {
10
- const factory = globalThis.indexedDB;
11
- if (!factory) {
12
- reject(/* @__PURE__ */ new Error("IndexedDB is unavailable."));
13
- return;
14
- }
15
- let late = false;
16
- const timer = setTimeout(() => {
17
- late = true;
18
- reject(/* @__PURE__ */ new Error("IndexedDB did not open in time."));
19
- }, OPEN_TIMEOUT_MS);
20
- const request = factory.open(name, VERSION);
21
- request.onupgradeneeded = () => {
22
- const db = request.result;
23
- db.createObjectStore("documents", { keyPath: "board" }).createIndex("user", "userKey");
24
- const pending = db.createObjectStore("pending", { keyPath: ["board", "mutationId"] });
25
- pending.createIndex("board", "board");
26
- pending.createIndex("user", "userKey");
27
- };
28
- request.onsuccess = () => {
29
- clearTimeout(timer);
30
- const db = request.result;
31
- if (late) {
32
- db.close();
33
- return;
34
- }
35
- const forget = () => {
36
- if (connections.get(name) === opening) connections.delete(name);
37
- };
38
- db.onversionchange = () => {
39
- db.close();
40
- forget();
41
- };
42
- db.onclose = forget;
43
- resolve(db);
44
- };
45
- request.onerror = () => {
46
- clearTimeout(timer);
47
- reject(request.error ?? /* @__PURE__ */ new Error("IndexedDB failed to open."));
48
- };
49
- });
50
- connections.set(name, opening);
51
- opening.catch(() => {
52
- if (connections.get(name) === opening) connections.delete(name);
53
- });
54
- return opening;
55
- }
56
- async function transact(name, stores, mode, work, retry = true) {
57
- const db = await open(name);
58
- let tx;
59
- try {
60
- tx = db.transaction(stores, mode);
61
- } catch (error) {
62
- if (!retry) throw error;
63
- db.close();
64
- connections.delete(name);
65
- return transact(name, stores, mode, work, false);
66
- }
67
- return new Promise((resolve, reject) => {
68
- const result = work(tx);
69
- tx.oncomplete = () => resolve(result());
70
- tx.onerror = () => reject(tx.error ?? /* @__PURE__ */ new Error("IndexedDB transaction failed."));
71
- tx.onabort = () => reject(tx.error ?? /* @__PURE__ */ new Error("IndexedDB transaction aborted."));
72
- });
73
- }
74
- function deleteByIndex(store, index, key) {
75
- const request = store.index(index).openKeyCursor(IDBKeyRange.only(key));
76
- request.onsuccess = () => {
77
- const cursor = request.result;
78
- if (!cursor) return;
79
- store.delete(cursor.primaryKey);
80
- cursor.continue();
81
- };
5
+ function upgrade(db) {
6
+ db.createObjectStore("documents", { keyPath: "board" }).createIndex("user", "userKey");
7
+ const pending = db.createObjectStore("pending", { keyPath: ["board", "mutationId"] });
8
+ pending.createIndex("board", "board");
9
+ pending.createIndex("user", "userKey");
82
10
  }
11
+ const database = (name) => indexedDb(name, VERSION, upgrade);
83
12
  let lastOrder = 0;
84
13
  function createIndexedDbBoardReplicaStorage(scope, options = {}) {
85
- const name = options.database ?? DATABASE;
14
+ const { transact } = database(options.database ?? DATABASE);
86
15
  const board = JSON.stringify([
87
16
  scope.userKey,
88
17
  scope.spaceId,
89
18
  scope.boardId
90
19
  ]);
91
20
  return {
92
- listPending: () => transact(name, ["pending"], "readonly", (tx) => {
21
+ listPending: () => transact(["pending"], "readonly", (tx) => {
93
22
  const request = tx.objectStore("pending").index("board").getAll(IDBKeyRange.only(board));
94
23
  return () => request.result.sort((a, b) => a.order - b.order).map(({ mutationId, patch }) => ({
95
24
  mutationId,
96
25
  patch
97
26
  }));
98
27
  }),
99
- putPending: ({ mutationId, patch }) => transact(name, ["pending"], "readwrite", (tx) => {
28
+ putPending: ({ mutationId, patch }) => transact(["pending"], "readwrite", (tx) => {
100
29
  lastOrder = Math.max(Date.now(), lastOrder + 1);
101
30
  const record = {
102
31
  board,
@@ -108,11 +37,11 @@ function createIndexedDbBoardReplicaStorage(scope, options = {}) {
108
37
  tx.objectStore("pending").put(record);
109
38
  return () => void 0;
110
39
  }),
111
- deletePending: (mutationId) => transact(name, ["pending"], "readwrite", (tx) => {
40
+ deletePending: (mutationId) => transact(["pending"], "readwrite", (tx) => {
112
41
  tx.objectStore("pending").delete([board, mutationId]);
113
42
  return () => void 0;
114
43
  }),
115
- readDocument: () => transact(name, ["documents"], "readonly", (tx) => {
44
+ readDocument: () => transact(["documents"], "readonly", (tx) => {
116
45
  const request = tx.objectStore("documents").get(board);
117
46
  return () => {
118
47
  const record = request.result;
@@ -122,7 +51,7 @@ function createIndexedDbBoardReplicaStorage(scope, options = {}) {
122
51
  } : null;
123
52
  };
124
53
  }),
125
- writeDocument: (version, document) => transact(name, ["documents"], "readwrite", (tx) => {
54
+ writeDocument: (version, document) => transact(["documents"], "readwrite", (tx) => {
126
55
  const store = tx.objectStore("documents");
127
56
  const request = store.get(board);
128
57
  request.onsuccess = () => {
@@ -142,7 +71,7 @@ function createIndexedDbBoardReplicaStorage(scope, options = {}) {
142
71
  }
143
72
  async function clearBoardReplicaStorage(options = {}) {
144
73
  const { userKey } = options;
145
- await transact(options.database ?? DATABASE, ["documents", "pending"], "readwrite", (tx) => {
74
+ await database(options.database ?? DATABASE).transact(["documents", "pending"], "readwrite", (tx) => {
146
75
  for (const name of ["documents", "pending"]) {
147
76
  const store = tx.objectStore(name);
148
77
  if (userKey === void 0) store.clear();
@@ -1,7 +1,7 @@
1
1
  import { ModelFormat } from "../../protocol/dist/fs/mime.js";
2
2
  import { BoardSceneItem } from "../model/scene.js";
3
3
  import { BoardAssetMedia, BoardAssetRef } from "../model/asset-key.js";
4
- import { BoardAssetUrlOptions } from "../model/media-playback.js";
4
+ import { BoardAssetSource, BoardAssetUrlOptions } from "../model/media-playback.js";
5
5
  import "../model/index.js";
6
6
  import { LruBudget } from "./lru.js";
7
7
  import { BoardImageTexturePool } from "./texture-pool.js";
@@ -25,7 +25,7 @@ type BoardModelPreviews = {
25
25
  type BoardAssetManager = {
26
26
  assetKey: (item: BoardSceneItem) => string | null;
27
27
  invalidatePath: (path: string) => void;
28
- invalidateItemAssets: () => void;
28
+ invalidateItemAssets: (ids?: Iterable<string>) => void;
29
29
  requestItem: (item: BoardSceneItem, edge?: number) => void;
30
30
  /** World point used to prioritize queued loads. */
31
31
  focus: (point: {
@@ -61,6 +61,7 @@ type BoardAssetChange = "texture" | "tone";
61
61
  type BoardAssetManagerOptions = {
62
62
  /** Resolve a Space file path to a URL a texture can load from; null when unavailable. */
63
63
  resolveFileUrl: (path: string, options?: BoardAssetUrlOptions) => Promise<string | null>;
64
+ subscribe?: BoardAssetSource["subscribe"];
64
65
  /** undefined keeps the built-in rule, null shows none; cached until `invalidateItemAssets`. */
65
66
  itemAsset?: (item: BoardSceneItem) => BoardAssetRef | null | undefined;
66
67
  concurrency?: number;
@@ -80,6 +80,7 @@ function createBoardAssetManager(options) {
80
80
  }
81
81
  const resolveFileUrl = options.resolveFileUrl;
82
82
  const entries = /* @__PURE__ */ new Map();
83
+ const keysByPath = /* @__PURE__ */ new Map();
83
84
  const pathVersions = /* @__PURE__ */ new Map();
84
85
  const starters = /* @__PURE__ */ new Map();
85
86
  const failedModels = /* @__PURE__ */ new Set();
@@ -92,6 +93,7 @@ function createBoardAssetManager(options) {
92
93
  model: 0
93
94
  };
94
95
  let disposed = false;
96
+ let stopSource = null;
95
97
  let focus = null;
96
98
  let trimTimer = null;
97
99
  let trimSince = 0;
@@ -131,16 +133,33 @@ function createBoardAssetManager(options) {
131
133
  cancelTone = toneQueue.size > 0 ? scheduleBoardIdle(readNextTone, 0) : null;
132
134
  }
133
135
  const keys = /* @__PURE__ */ new WeakMap();
136
+ const itemEpochs = /* @__PURE__ */ new Map();
134
137
  let keyEpoch = 0;
135
138
  function managedAssetKey(item) {
136
- const cached = keys.get(item);
137
- if (cached?.epoch === keyEpoch) return cached.key;
138
- const key = resolveAssetKey(item);
139
- keys.set(item, {
140
- epoch: keyEpoch,
141
- key
142
- });
143
- return key;
139
+ const itemEpoch = itemEpochs.get(item.id) ?? 0;
140
+ let record = keys.get(item);
141
+ if (record?.epoch !== keyEpoch || record.itemEpoch !== itemEpoch) {
142
+ const base = resolveAssetKey(item);
143
+ const source = base ? boardAssetKeySource(base) : null;
144
+ const path = source?.kind === "file" ? source.value : null;
145
+ record = {
146
+ epoch: keyEpoch,
147
+ itemEpoch,
148
+ base,
149
+ path,
150
+ version: 0,
151
+ key: base
152
+ };
153
+ keys.set(item, record);
154
+ }
155
+ if (record.path !== null) {
156
+ const version = pathVersions.get(record.path) ?? 0;
157
+ if (version !== record.version) {
158
+ record.version = version;
159
+ record.key = `local:${version}:${record.base}`;
160
+ }
161
+ }
162
+ return record.key;
144
163
  }
145
164
  function resolveAssetKey(item) {
146
165
  const asset = options.itemAsset?.(item);
@@ -149,9 +168,7 @@ function createBoardAssetManager(options) {
149
168
  const source = boardAssetKeySource(key);
150
169
  if (source?.media === "video" && options.loadVideoPreviews === false) return null;
151
170
  if (source?.media === "model" && !modelPreviewable(item)) return null;
152
- if (source?.kind !== "file") return key;
153
- const version = pathVersions.get(source.value) ?? 0;
154
- return version > 0 ? `local:${version}:${key}` : key;
171
+ return key;
155
172
  }
156
173
  function clearRetry(entry) {
157
174
  if (entry.retryTimer) {
@@ -163,9 +180,11 @@ function createBoardAssetManager(options) {
163
180
  let entry = entries.get(key);
164
181
  if (!entry) {
165
182
  const source = boardAssetKeySource(key);
183
+ const path = source?.kind === "file" ? source.value : null;
166
184
  entry = {
167
185
  media: source?.media ?? "image",
168
186
  cover: source?.fit === "cover",
187
+ path,
169
188
  source: null,
170
189
  url: null,
171
190
  texture: null,
@@ -182,6 +201,11 @@ function createBoardAssetManager(options) {
182
201
  lastUsedAt: now()
183
202
  };
184
203
  entries.set(key, entry);
204
+ if (path !== null) {
205
+ const keyed = keysByPath.get(path);
206
+ if (keyed) keyed.add(key);
207
+ else keysByPath.set(path, /* @__PURE__ */ new Set([key]));
208
+ }
185
209
  }
186
210
  return entry;
187
211
  }
@@ -203,6 +227,10 @@ function createBoardAssetManager(options) {
203
227
  starters.delete(key);
204
228
  if (entry.url || entry.texture) releaseTexture(entry.media, entry.url ?? "", entry.texture);
205
229
  entries.delete(key);
230
+ if (entry.path === null) return;
231
+ const keyed = keysByPath.get(entry.path);
232
+ keyed?.delete(key);
233
+ if (keyed?.size === 0) keysByPath.delete(entry.path);
206
234
  }
207
235
  function scheduleTrim() {
208
236
  const at = now();
@@ -464,18 +492,19 @@ function createBoardAssetManager(options) {
464
492
  const manager = {
465
493
  assetKey: managedAssetKey,
466
494
  invalidatePath(path) {
467
- if (disposed) return;
468
- const stale = [...entries.keys()].filter((key) => {
469
- const source = boardAssetKeySource(key);
470
- return source?.kind === "file" && source.value === path;
471
- });
472
- if (stale.length === 0) return;
495
+ const stale = keysByPath.get(path);
496
+ if (disposed || !stale) return;
473
497
  pathVersions.set(path, (pathVersions.get(path) ?? 0) + 1);
474
- keyEpoch += 1;
475
- for (const key of stale) notify(key);
498
+ for (const key of [...stale]) notify(key);
476
499
  },
477
- invalidateItemAssets() {
478
- if (options.itemAsset) keyEpoch += 1;
500
+ invalidateItemAssets(ids) {
501
+ if (!options.itemAsset) return;
502
+ if (!ids) {
503
+ keyEpoch += 1;
504
+ itemEpochs.clear();
505
+ return;
506
+ }
507
+ for (const id of ids) itemEpochs.set(id, (itemEpochs.get(id) ?? 0) + 1);
479
508
  },
480
509
  requestItem(item, edge = Number.POSITIVE_INFINITY) {
481
510
  if (disposed) return;
@@ -659,6 +688,7 @@ function createBoardAssetManager(options) {
659
688
  },
660
689
  destroy() {
661
690
  disposed = true;
691
+ stopSource?.();
662
692
  if (trimTimer !== null) clearTimeout(trimTimer);
663
693
  trimTimer = null;
664
694
  cancelTone?.();
@@ -669,6 +699,7 @@ function createBoardAssetManager(options) {
669
699
  unwarmable.clear();
670
700
  listeners.clear();
671
701
  pathVersions.clear();
702
+ itemEpochs.clear();
672
703
  starters.clear();
673
704
  failedModels.clear();
674
705
  for (const [key, entry] of entries) evict(key, entry);
@@ -676,6 +707,7 @@ function createBoardAssetManager(options) {
676
707
  for (const release of [...retiring]) release();
677
708
  }
678
709
  };
710
+ stopSource = options.subscribe?.((path) => manager.invalidatePath(path)) ?? null;
679
711
  return manager;
680
712
  }
681
713
  //#endregion
@@ -14,6 +14,7 @@ const Z_LIVE_STROKE = Number.MAX_SAFE_INTEGER - 1;
14
14
  const Z_OVERLAY = Number.MAX_SAFE_INTEGER;
15
15
  const POOL_LIMIT_PER_RENDERER = 48;
16
16
  const EMPTY_IDS = /* @__PURE__ */ new Set();
17
+ const NO_PLATES = /* @__PURE__ */ new Map();
17
18
  function idSetSignature(ids) {
18
19
  let hash = ids.size;
19
20
  for (const id of ids) {
@@ -257,13 +258,19 @@ function createBoardScene(options) {
257
258
  let paintedPlan = null;
258
259
  let paintedSig = "";
259
260
  let platesPending = false;
260
- let covered = EMPTY_IDS;
261
+ let covered = NO_PLATES;
261
262
  /**
262
263
  * Repaint the plates whose cards changed, within a per-frame budget. A card
263
264
  * is covered once a plate draws it; until then it stays live if it already is.
264
265
  */
265
266
  function paintPlates(input, current) {
266
267
  const { context, globalSig, scene } = input;
268
+ for (const id of input.staleIds ?? EMPTY_IDS) {
269
+ const plate = covered.get(id);
270
+ if (!plate) continue;
271
+ plate.sig = "";
272
+ paintedPlan = null;
273
+ }
267
274
  if (current === paintedPlan && globalSig === paintedSig) return false;
268
275
  const start = performance.now();
269
276
  const allowance = budget.allow("plate");
@@ -303,8 +310,8 @@ function createBoardScene(options) {
303
310
  plate.graphics.destroy();
304
311
  changed = true;
305
312
  }
306
- const next = /* @__PURE__ */ new Set();
307
- for (const plate of plates.values()) for (const item of plate.items) next.add(item.id);
313
+ const next = /* @__PURE__ */ new Map();
314
+ for (const plate of plates.values()) for (const item of plate.items) next.set(item.id, plate);
308
315
  covered = next;
309
316
  budget.spend("plate", painted, paintMs);
310
317
  platesPending = pending;
@@ -328,7 +335,7 @@ function createBoardScene(options) {
328
335
  paintedPlan = null;
329
336
  paintedSig = "";
330
337
  platesPending = false;
331
- covered = EMPTY_IDS;
338
+ covered = NO_PLATES;
332
339
  plan = null;
333
340
  planInputs = [];
334
341
  nearIds = EMPTY_IDS;
@@ -1,4 +1,4 @@
1
- import { O as readRtcTransportStats, k as waitForIceGathering } from "./http.js";
1
+ import { A as readRtcTransportStats, j as waitForIceGathering } from "./http.js";
2
2
  import { c as HttpError, p as REALTIME_ROOM_EVENT_NAME_PATTERN } from "./http-error.js";
3
3
  import { z } from "zod";
4
4
  //#region \0rolldown/runtime.js