@brftech/filex-app-ui 0.54.0 → 0.56.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/dist/index.d.ts CHANGED
@@ -6,6 +6,152 @@ export declare interface BridgeError {
6
6
  message?: string;
7
7
  }
8
8
 
9
+ /** `coedit.append` params: the host seals it and the relay places it. */
10
+ export declare interface CoEditAppendParams {
11
+ kind: CoEditKind;
12
+ body: unknown;
13
+ }
14
+
15
+ export declare interface CoEditAppendResult {
16
+ /** Where it landed; it comes back as a `coedit.entry` too. */
17
+ seq: number;
18
+ }
19
+
20
+ export declare interface CoEditBlobGetParams {
21
+ name: string;
22
+ }
23
+
24
+ export declare interface CoEditBlobGetResult {
25
+ name: string;
26
+ bytes: ArrayBuffer;
27
+ }
28
+
29
+ /** `coedit.blob.put` / `coedit.blob.get`: the session's sealed blobs (the
30
+ * base document, images added while editing). A name, not a path. */
31
+ export declare interface CoEditBlobPutParams {
32
+ name: string;
33
+ data: ArrayBuffer;
34
+ }
35
+
36
+ /** `coedit.cursor` event data. */
37
+ export declare interface CoEditCursor {
38
+ client: string;
39
+ cursor: unknown;
40
+ }
41
+
42
+ /** `coedit.cursor` params: passed to the other members, not kept. The host
43
+ * sends a few a second at most (the last one wins). */
44
+ export declare interface CoEditCursorParams {
45
+ cursor: unknown;
46
+ }
47
+
48
+ /** `coedit.dropped` event data (filex 0.56). */
49
+ export declare interface CoEditDropped {
50
+ /** The last entry the host handed over. */
51
+ from: number;
52
+ /** `gone`: the membership ended (the session closed, or the page was away
53
+ * too long) - join again. `broken`: an entry did not check out (missing,
54
+ * moved, altered, replayed) - stop editing together. */
55
+ reason: 'gone' | 'broken';
56
+ }
57
+
58
+ /** One entry of the session's log, opened and checked by filex. */
59
+ export declare type CoEditEntry = (CoEditEntryBase & {
60
+ kind: CoEditKind;
61
+ body: unknown;
62
+ }) | (CoEditEntryBase & {
63
+ kind: 'join';
64
+ member: CoEditMember;
65
+ }) | (CoEditEntryBase & {
66
+ kind: 'leave';
67
+ }) | (CoEditEntryBase & {
68
+ kind: 'saved';
69
+ through: number;
70
+ });
71
+
72
+ declare interface CoEditEntryBase {
73
+ seq: number;
74
+ /** The relay's time, Unix ms - the same for every member. */
75
+ at: number;
76
+ client: string;
77
+ }
78
+
79
+ /**
80
+ * What `coedit.join` answers: who the app is in the session and how far
81
+ * the log has gone. The entries come as `coedit.entry` events once the app
82
+ * subscribes (`coedit.subscribe` from 0 for the whole log). `unavailable`
83
+ * when filex offers no editing together here (filex 0.55, editing together
84
+ * turned off, an encrypted file, a vault): the app edits alone - the message
85
+ * says why (`not_ready` after waiting for a session's creator, `encrypted`,
86
+ * `nothing_to_watch` for a person who may only read the file while nobody
87
+ * edits it, or the server's code: `coedit_off`, `coedit_build`,
88
+ * `coedit_file_changed`).
89
+ */
90
+ export declare interface CoEditHello {
91
+ session: string;
92
+ me: CoEditMember;
93
+ /**
94
+ * The last entry's seq when filex handed the session over (0: an empty
95
+ * log). filex 0.56: the changes this browser kept from an earlier opening
96
+ * of the file (a lost connection, a closed tab), which filex sends before
97
+ * it answers, are in the log up to here - an editor that starts once it
98
+ * has read this far opens with them.
99
+ */
100
+ head: number;
101
+ /** The last `changes` entry's seq, as far as `head`. */
102
+ changesHead: number;
103
+ /** The last entry the file on the storage holds. */
104
+ savedThrough: number;
105
+ /**
106
+ * filex 0.56: this member started the session. Before anybody else can
107
+ * join it puts the base document - EXACTLY the bytes it opened - with
108
+ * `coedit.blob.put('base', bytes)`. A member that did not start it opens
109
+ * `coedit.blob.get('base')`, never the file: the file may hold a save of
110
+ * the session already, and the log applies to the base.
111
+ */
112
+ created: boolean;
113
+ }
114
+
115
+ /** `coedit.join` params: join (or start) the session of an opened file. */
116
+ export declare interface CoEditJoinParams {
117
+ index?: number;
118
+ }
119
+
120
+ /** What a member appends: the editor's changes, a lock request, a release. */
121
+ export declare type CoEditKind = 'changes' | 'lock' | 'release';
122
+
123
+ /** `coedit.lease`: the changes lease (one writer at a time, one that has
124
+ * seen every change). */
125
+ export declare interface CoEditLeaseParams {
126
+ op: 'acquire' | 'release';
127
+ /** The seq of the last `changes` entry the app applied. */
128
+ changesSeen: number;
129
+ }
130
+
131
+ export declare interface CoEditLeaseResult {
132
+ granted: boolean;
133
+ }
134
+
135
+ /** A member of the session. */
136
+ export declare interface CoEditMember {
137
+ /** The relay's id for the connection. */
138
+ client: string;
139
+ /** An opaque id of the person, the same for each of their connections in
140
+ * this session; never an account id. */
141
+ user: string;
142
+ /** The person's display name. */
143
+ name: string;
144
+ /** The editor's per-session user index: given once, never twice. */
145
+ indexUser: number;
146
+ /** May append changes (an editor role and the right to change the file). */
147
+ canEdit: boolean;
148
+ }
149
+
150
+ /** `coedit.subscribe` params: entries after `from` arrive as `coedit.entry`. */
151
+ export declare interface CoEditSubscribeParams {
152
+ from: number;
153
+ }
154
+
9
155
  export declare interface ConfirmParams {
10
156
  title?: string;
11
157
  text: string;
@@ -92,16 +238,64 @@ export declare interface FileInfo {
92
238
  /** It cannot be saved over: read-only storage, view-only opening, or the
93
239
  * app holds no `files:write`. */
94
240
  readOnly: boolean;
241
+ /**
242
+ * 0.55.0: the file is end-to-end encrypted - `folder` in an encrypted
243
+ * folder, `vault` in a vault (where only one person edits at a time),
244
+ * `file` for a single encrypted file (`.fxe`). As the server says it;
245
+ * ABSENT for a file that is not encrypted.
246
+ *
247
+ * ⚠ The server holds such a file only as ciphertext and has no key. Unless
248
+ * `plaintext` is true, filex hands the app nothing of it: `file.read` is
249
+ * refused (`failed` with `encrypted`), a save is refused, and `readOnly`
250
+ * is true.
251
+ */
252
+ encrypted?: 'folder' | 'vault' | 'file';
253
+ /**
254
+ * 0.56.0: filex hands this encrypted file to the app in the clear. The
255
+ * person's browser, which holds the folder key while the folder is
256
+ * unlocked, decrypts it for `file.read` and encrypts what `file.save`
257
+ * hands over before it leaves the browser; the server sees only
258
+ * ciphertext. Only with `encrypted: 'folder'` or `'vault'`, for an app
259
+ * whose manifest says `encrypted_folders: {"open": true}` (the grant
260
+ * `files:e2e-plaintext`) - never for a `.fxe`. ABSENT otherwise.
261
+ *
262
+ * In a vault (`encrypted: 'vault'`) the browser reads the file from the
263
+ * vault's packs and writes a save as the vault's next generation, under
264
+ * the vault's write lock (one writer at a time). A save is refused -
265
+ * `failed` with `vault_locked` - while somebody else writes the vault, and
266
+ * with `vault_lock_lost` when this session's write lock ended before the
267
+ * save was committed: nothing was written, save again. The vault locked
268
+ * meanwhile (its keys left the browser): `unavailable`, and filex closes
269
+ * the app.
270
+ *
271
+ * What changes for the app: a read and a save are held whole in the page
272
+ * (at most LIMITS.maxEncryptedBytes); a save is written only over the file
273
+ * as it was opened (`failed` with `changed` when it changed meanwhile);
274
+ * `file.saveAs` answers `unavailable` (a copy elsewhere would leave the
275
+ * folder decrypted); the app's module (`engine.call`, `job.submit`) is
276
+ * never handed the file; `state.get` / `state.set` use a store of their
277
+ * own for this folder, sealed in the person's browser (the server keeps it
278
+ * and cannot read it; apart from the app's ordinary store, a little smaller
279
+ * as it is measured sealed). Everything the app holds is plaintext: keep it
280
+ * in the frame. In a folder whose names are encrypted too, `name` and
281
+ * `ext` are the decrypted name's - the server never sees them.
282
+ */
283
+ plaintext?: true;
95
284
  }
96
285
 
97
286
  export declare interface FilexApp {
98
287
  readonly session: Session;
99
288
  /** The file at `index` (default the first) the interface was opened with. */
100
289
  open(index?: number): Promise<OpenedFile>;
101
- /** Save new content over an opened file. */
290
+ /**
291
+ * Save new content over an opened file. `through` (filex 0.56, editing
292
+ * together): the last log entry the saved document holds, so the session
293
+ * knows what is saved.
294
+ */
102
295
  save(data: SaveData, opts?: {
103
296
  index?: number;
104
297
  mime?: string;
298
+ through?: number;
105
299
  }): Promise<SaveResult>;
106
300
  /** Save a NEW file; filex asks the person where (its own folder picker). */
107
301
  saveAs(name: string, data: SaveData, mime?: string): Promise<SaveAsResult>;
@@ -120,13 +314,46 @@ export declare interface FilexApp {
120
314
  * filex asks the person first. `cancelled` when they say no.
121
315
  */
122
316
  download(name: string, data: SaveData, mime?: string): Promise<DownloadResult>;
317
+ /**
318
+ * Print a PDF (filex 0.55; the app needs `ui.print` in its manifest): a
319
+ * sandboxed frame may not open the print dialog, so filex prints it from
320
+ * its own page. filex asks the person every time, and the dialog opens on
321
+ * their click on filex's Allow; at most LIMITS.maxPrintBytes. filex 0.54
322
+ * and older answer `unknown_method`, and a host whose print page may not
323
+ * be shown `unavailable`: hand the PDF over with `download` then.
324
+ */
325
+ print(name: string, pdf: Blob | ArrayBuffer | ArrayBufferView | ReadableStream<Uint8Array>): Promise<PrintResult>;
326
+ /**
327
+ * Editing together (filex 0.56; the app's manifest says `"co_edit":
328
+ * {"open": true}`, granted as `files:co-edit`). filex seals what the app appends, orders it
329
+ * through its relay and hands every member the same entries in the same
330
+ * order (`coedit.entry` events after `subscribe`); the app never holds a
331
+ * key. `join` answering `unavailable` (filex 0.55, editing together off
332
+ * here, an encrypted file) means: edit alone. The member that started the
333
+ * session (`created`) puts the base document before anybody else may join.
334
+ */
335
+ coedit: {
336
+ join(index?: number): Promise<CoEditHello>;
337
+ subscribe(from: number): Promise<void>;
338
+ append(kind: CoEditKind, body: unknown): Promise<CoEditAppendResult>;
339
+ lease(op: 'acquire' | 'release', changesSeen: number): Promise<boolean>;
340
+ cursor(cursor: unknown): void;
341
+ putBlob(name: string, data: Blob | ArrayBuffer | ArrayBufferView): Promise<void>;
342
+ getBlob(name: string): Promise<ArrayBuffer>;
343
+ leave(): Promise<void>;
344
+ };
123
345
  /** Call the app's own module (`ui_call` export) with the opened files. */
124
346
  call<T = unknown>(method: string, params?: unknown): Promise<T>;
125
347
  /** Queue one of the app's actions on the opened files; answers the op. */
126
348
  submit(action: string, params?: Record<string, unknown>): Promise<{
127
349
  op: unknown;
128
350
  }>;
129
- /** This person's small store for this app (JSON values, 8 KiB each). */
351
+ /**
352
+ * This person's small store for this app (JSON values, 8 KiB each). While
353
+ * the app holds a file of an encrypted folder in the clear
354
+ * (`FileInfo.plaintext`, filex 0.56) it is another store, sealed in the
355
+ * browser - apart from this one, and empty while the folder is locked.
356
+ */
130
357
  state: {
131
358
  get<T = unknown>(key: string): Promise<T | undefined>;
132
359
  set(key: string, value: unknown): Promise<void>;
@@ -176,7 +403,18 @@ export declare type HostEvent =
176
403
  /** The person closed the frame's surroundings; last chance to say so. */
177
404
  | 'close.request'
178
405
  /** An administrator installed a new version of this app: reload to use it. */
179
- | 'app.updated';
406
+ | 'app.updated'
407
+ /** Editing together: the next entry of the session's log, opened. `data: CoEditEntry`. */
408
+ | 'coedit.entry'
409
+ /** Editing together: another member's cursor (not kept). `data: CoEditCursor`. */
410
+ | 'coedit.cursor'
411
+ /**
412
+ * Editing together: the host stopped handing entries over. `data:
413
+ * CoEditDropped` - `gone`: the membership ended (join again); `broken`: an
414
+ * entry did not check out (stop editing together). A connection that drops
415
+ * for a moment is the host's own: it reconnects and says nothing.
416
+ */
417
+ | 'coedit.dropped';
180
418
 
181
419
  /** What the host may ask the app (and wait for). */
182
420
  export declare type HostRequest =
@@ -244,12 +482,35 @@ export declare const LIMITS: {
244
482
  readonly connectTimeoutMs: 10000;
245
483
  /** The largest file `ui.download` hands the person (streamed or not). */
246
484
  readonly maxDownloadBytes: number;
485
+ /**
486
+ * The largest PDF `ui.print` prints (filex 0.55). Lower than a download:
487
+ * a download can stream to disk, a print is held whole in the page (one
488
+ * Blob) until the print dialog has read it, and a document worth printing
489
+ * is far smaller.
490
+ */
491
+ readonly maxPrintBytes: number;
492
+ /**
493
+ * The largest file of an encrypted folder filex hands an app in the clear
494
+ * or takes back from it (filex 0.56, `FileInfo.plaintext`): it is
495
+ * decrypted and encrypted whole in the page, as filex's own explorer does
496
+ * for a file of this size.
497
+ */
498
+ readonly maxEncryptedBytes: number;
247
499
  };
248
500
 
249
501
  /** What the app may ask the host. Anything else is `unknown_method`. */
250
502
  export declare type Method = 'session.get' | 'file.read' | 'file.save' | 'file.saveAs' | 'ui.dirty' | 'ui.title' | 'ui.toast' | 'ui.confirm' | 'ui.close' | 'clipboard.write' | 'ui.download' | 'engine.call' | 'job.submit' | 'state.get' | 'state.set'
251
503
  /** 0.52.0: what the app's license says (a paid app from a store), `LicenseInfo`. */
252
- | 'license.get';
504
+ | 'license.get'
505
+ /** 0.55.0: filex prints a PDF the app hands it (`PrintParams`, grant `ui:print`). */
506
+ | 'ui.print'
507
+ /**
508
+ * Editing together (`coedit.*`, task #189): the session's log the host
509
+ * seals, orders through filex's relay and opens again. Defined in filex
510
+ * 0.55, which answers each `unavailable`; offered from 0.56 to an app
511
+ * granted `files:co-edit` (see `CoEditHello`). `unavailable` = edit alone.
512
+ */
513
+ | 'coedit.join' | 'coedit.subscribe' | 'coedit.append' | 'coedit.lease' | 'coedit.cursor' | 'coedit.blob.put' | 'coedit.blob.get' | 'coedit.leave';
253
514
 
254
515
  export declare const METHODS: readonly Method[];
255
516
 
@@ -270,6 +531,31 @@ export declare interface PortMessage {
270
531
  v: number;
271
532
  }
272
533
 
534
+ /**
535
+ * `ui.print` (filex 0.55.0): a PDF for the browser's print dialog. A
536
+ * sandboxed frame may not open that dialog (measured: Chromium ignores
537
+ * `print()` there and says so, Firefox ignores it), so filex prints the PDF
538
+ * from a page of its own, with the app's `ui:print` grant, never over
539
+ * LIMITS.maxPrintBytes. filex asks every time, above the frame, and the
540
+ * dialog opens only on the person's click on filex's Allow. The print dialog
541
+ * can save the PDF too, so `ui:print` is the same kind of grant as
542
+ * `ui:download`. filex 0.54 and older answer `unknown_method`.
543
+ */
544
+ export declare interface PrintParams {
545
+ /** The document's name, for the question filex may ask - a name, not a path. */
546
+ name: string;
547
+ /** The PDF: a transferred ArrayBuffer or ReadableStream<Uint8Array>. */
548
+ data: ReadableStream<Uint8Array> | ArrayBuffer;
549
+ /** `application/pdf` when given; nothing else is printed. */
550
+ mime?: string;
551
+ }
552
+
553
+ export declare interface PrintResult {
554
+ /** The browser's print dialog was opened with the PDF. */
555
+ printed: true;
556
+ size: number;
557
+ }
558
+
273
559
  /** `file.read` params. */
274
560
  export declare interface ReadParams {
275
561
  index?: number;
@@ -326,6 +612,12 @@ export declare interface SaveParams {
326
612
  /** A transferred ReadableStream<Uint8Array>, an ArrayBuffer or a string. */
327
613
  data: ReadableStream<Uint8Array> | ArrayBuffer | string;
328
614
  mime?: string;
615
+ /**
616
+ * 0.55.0, editing together: the last log entry (`CoEditEntry.seq`) the
617
+ * saved document holds, so the session knows what is saved. Ignored
618
+ * outside a session.
619
+ */
620
+ through?: number;
329
621
  }
330
622
 
331
623
  export declare interface SaveResult {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brftech/filex-app-ui",
3
- "version": "0.54.0",
3
+ "version": "0.56.0",
4
4
  "description": "filex app interface SDK: the bridge between an app's own sandboxed interface and filex (open, save, dirty, call the app's module)",
5
5
  "type": "module",
6
6
  "main": "./dist/filex-app-ui.js",
package/src/index.ts CHANGED
@@ -36,6 +36,12 @@ import {
36
36
  type ToastParams,
37
37
  type DownloadResult,
38
38
  type LicenseInfo,
39
+ type PrintResult,
40
+ type CoEditHello,
41
+ type CoEditKind,
42
+ type CoEditAppendResult,
43
+ type CoEditLeaseResult,
44
+ type CoEditBlobGetResult,
39
45
  } from './protocol';
40
46
 
41
47
  export * from './protocol';
@@ -78,8 +84,12 @@ export interface FilexApp {
78
84
  readonly session: Session;
79
85
  /** The file at `index` (default the first) the interface was opened with. */
80
86
  open(index?: number): Promise<OpenedFile>;
81
- /** Save new content over an opened file. */
82
- save(data: SaveData, opts?: { index?: number; mime?: string }): Promise<SaveResult>;
87
+ /**
88
+ * Save new content over an opened file. `through` (filex 0.56, editing
89
+ * together): the last log entry the saved document holds, so the session
90
+ * knows what is saved.
91
+ */
92
+ save(data: SaveData, opts?: { index?: number; mime?: string; through?: number }): Promise<SaveResult>;
83
93
  /** Save a NEW file; filex asks the person where (its own folder picker). */
84
94
  saveAs(name: string, data: SaveData, mime?: string): Promise<SaveAsResult>;
85
95
  /** Unsaved changes, or not: filex asks before the person leaves them. */
@@ -97,11 +107,44 @@ export interface FilexApp {
97
107
  * filex asks the person first. `cancelled` when they say no.
98
108
  */
99
109
  download(name: string, data: SaveData, mime?: string): Promise<DownloadResult>;
110
+ /**
111
+ * Print a PDF (filex 0.55; the app needs `ui.print` in its manifest): a
112
+ * sandboxed frame may not open the print dialog, so filex prints it from
113
+ * its own page. filex asks the person every time, and the dialog opens on
114
+ * their click on filex's Allow; at most LIMITS.maxPrintBytes. filex 0.54
115
+ * and older answer `unknown_method`, and a host whose print page may not
116
+ * be shown `unavailable`: hand the PDF over with `download` then.
117
+ */
118
+ print(name: string, pdf: Blob | ArrayBuffer | ArrayBufferView | ReadableStream<Uint8Array>): Promise<PrintResult>;
119
+ /**
120
+ * Editing together (filex 0.56; the app's manifest says `"co_edit":
121
+ * {"open": true}`, granted as `files:co-edit`). filex seals what the app appends, orders it
122
+ * through its relay and hands every member the same entries in the same
123
+ * order (`coedit.entry` events after `subscribe`); the app never holds a
124
+ * key. `join` answering `unavailable` (filex 0.55, editing together off
125
+ * here, an encrypted file) means: edit alone. The member that started the
126
+ * session (`created`) puts the base document before anybody else may join.
127
+ */
128
+ coedit: {
129
+ join(index?: number): Promise<CoEditHello>;
130
+ subscribe(from: number): Promise<void>;
131
+ append(kind: CoEditKind, body: unknown): Promise<CoEditAppendResult>;
132
+ lease(op: 'acquire' | 'release', changesSeen: number): Promise<boolean>;
133
+ cursor(cursor: unknown): void;
134
+ putBlob(name: string, data: Blob | ArrayBuffer | ArrayBufferView): Promise<void>;
135
+ getBlob(name: string): Promise<ArrayBuffer>;
136
+ leave(): Promise<void>;
137
+ };
100
138
  /** Call the app's own module (`ui_call` export) with the opened files. */
101
139
  call<T = unknown>(method: string, params?: unknown): Promise<T>;
102
140
  /** Queue one of the app's actions on the opened files; answers the op. */
103
141
  submit(action: string, params?: Record<string, unknown>): Promise<{ op: unknown }>;
104
- /** This person's small store for this app (JSON values, 8 KiB each). */
142
+ /**
143
+ * This person's small store for this app (JSON values, 8 KiB each). While
144
+ * the app holds a file of an encrypted folder in the clear
145
+ * (`FileInfo.plaintext`, filex 0.56) it is another store, sealed in the
146
+ * browser - apart from this one, and empty while the folder is locked.
147
+ */
105
148
  state: {
106
149
  get<T = unknown>(key: string): Promise<T | undefined>;
107
150
  set(key: string, value: unknown): Promise<void>;
@@ -301,7 +344,9 @@ function bridge(port: MessagePort, opts: ConnectOptions): FilexApp {
301
344
  },
302
345
  async save(data, o = {}) {
303
346
  const p = await payload(data);
304
- return request<SaveResult>('file.save', { index: o.index ?? 0, data: p.data, mime: o.mime }, p.transfer);
347
+ const params: Record<string, unknown> = { index: o.index ?? 0, data: p.data, mime: o.mime };
348
+ if (typeof o.through === 'number') params.through = o.through;
349
+ return request<SaveResult>('file.save', params, p.transfer);
305
350
  },
306
351
  async saveAs(name, data, mime) {
307
352
  const p = await payload(data);
@@ -311,6 +356,40 @@ function bridge(port: MessagePort, opts: ConnectOptions): FilexApp {
311
356
  const p = await payload(data);
312
357
  return request<DownloadResult>('ui.download', { name, data: p.data, mime }, p.transfer);
313
358
  },
359
+ async print(name, pdf) {
360
+ const p = await payload(pdf);
361
+ return request<PrintResult>('ui.print', { name, data: p.data, mime: 'application/pdf' }, p.transfer);
362
+ },
363
+ coedit: {
364
+ join(index = 0) {
365
+ return request<CoEditHello>('coedit.join', { index });
366
+ },
367
+ subscribe(from: number) {
368
+ return request<void>('coedit.subscribe', { from });
369
+ },
370
+ append(kind: CoEditKind, body: unknown) {
371
+ return request<CoEditAppendResult>('coedit.append', { kind, body });
372
+ },
373
+ async lease(op: 'acquire' | 'release', changesSeen: number) {
374
+ const r = await request<CoEditLeaseResult>('coedit.lease', { op, changesSeen });
375
+ return !!r?.granted;
376
+ },
377
+ cursor(cursor: unknown) {
378
+ notify('coedit.cursor', { cursor });
379
+ },
380
+ async putBlob(name: string, data: Blob | ArrayBuffer | ArrayBufferView) {
381
+ const p = await payload(data);
382
+ if (!(p.data instanceof ArrayBuffer)) throw new FilexError({ code: 'invalid', message: 'a blob is bytes' });
383
+ await request<void>('coedit.blob.put', { name, data: p.data }, p.transfer);
384
+ },
385
+ async getBlob(name: string) {
386
+ const r = await request<CoEditBlobGetResult>('coedit.blob.get', { name });
387
+ return r.bytes;
388
+ },
389
+ async leave() {
390
+ await request<void>('coedit.leave');
391
+ },
392
+ },
314
393
  dirty(on) {
315
394
  notify('ui.dirty', { dirty: !!on });
316
395
  },