yrby-client 0.4.3 → 0.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.
Files changed (44) hide show
  1. package/README.md +250 -36
  2. package/dist/actioncable_provider.d.ts +26 -1
  3. package/dist/actioncable_provider.d.ts.map +1 -1
  4. package/dist/actioncable_provider.js +295 -150
  5. package/dist/actioncable_provider.js.map +1 -1
  6. package/dist/cjs/actioncable_provider.d.ts +26 -1
  7. package/dist/cjs/actioncable_provider.js +296 -151
  8. package/dist/cjs/document_element.d.ts +24 -0
  9. package/dist/cjs/document_element.js +260 -0
  10. package/dist/cjs/document_session.d.ts +62 -0
  11. package/dist/cjs/document_session.js +335 -0
  12. package/dist/cjs/index.d.ts +2 -0
  13. package/dist/cjs/index.js +4 -1
  14. package/dist/cjs/reliable_sync.d.ts +18 -31
  15. package/dist/cjs/reliable_sync.js +128 -98
  16. package/dist/cjs/turbo_adapter.d.ts +9 -0
  17. package/dist/cjs/turbo_adapter.js +81 -0
  18. package/dist/cjs/y_protocol_session.d.ts +7 -14
  19. package/dist/cjs/y_protocol_session.js +97 -92
  20. package/dist/document_element.d.ts +25 -0
  21. package/dist/document_element.d.ts.map +1 -0
  22. package/dist/document_element.js +225 -0
  23. package/dist/document_element.js.map +1 -0
  24. package/dist/document_session.d.ts +63 -0
  25. package/dist/document_session.d.ts.map +1 -0
  26. package/dist/document_session.js +296 -0
  27. package/dist/document_session.js.map +1 -0
  28. package/dist/index.d.ts +2 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +2 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/reliable_sync.d.ts +18 -31
  33. package/dist/reliable_sync.d.ts.map +1 -1
  34. package/dist/reliable_sync.js +128 -98
  35. package/dist/reliable_sync.js.map +1 -1
  36. package/dist/turbo_adapter.d.ts +10 -0
  37. package/dist/turbo_adapter.d.ts.map +1 -0
  38. package/dist/turbo_adapter.js +78 -0
  39. package/dist/turbo_adapter.js.map +1 -0
  40. package/dist/y_protocol_session.d.ts +7 -14
  41. package/dist/y_protocol_session.d.ts.map +1 -1
  42. package/dist/y_protocol_session.js +97 -92
  43. package/dist/y_protocol_session.js.map +1 -1
  44. package/package.json +29 -6
@@ -0,0 +1,260 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var _a;
36
+ Object.defineProperty(exports, "__esModule", { value: true });
37
+ exports.YrbyDocumentElement = void 0;
38
+ const document_session_js_1 = require("./document_session.js");
39
+ const turbo_adapter_js_1 = require("./turbo_adapter.js");
40
+ // Lets tests and SSR import this module where HTMLElement is undefined.
41
+ const Base = (typeof HTMLElement === "undefined" ? class {
42
+ } : HTMLElement);
43
+ // Holds the application's own inert value while the element forces inert on.
44
+ const INERT_ATTRIBUTE = "data-yrby-inert";
45
+ let sharedConsumer;
46
+ // Loads once per page. A failed import is cleared so a later attempt can retry it.
47
+ function defaultConsumer() {
48
+ sharedConsumer ??= Promise.resolve().then(() => __importStar(require("@rails/actioncable"))).then(actioncable => actioncable.createConsumer())
49
+ .catch(error => { sharedConsumer = undefined; throw error; });
50
+ return sharedConsumer;
51
+ }
52
+ function deferred() {
53
+ let resolve;
54
+ const promise = new Promise(r => { resolve = r; });
55
+ return { promise, resolve };
56
+ }
57
+ // Returns the yrby:error detail for a session that ended a lease. A block gets
58
+ // a detail, and a discard gets undefined.
59
+ function blockReport(session) {
60
+ return session.state === "blocked" ? { error: session.error, session } : undefined;
61
+ }
62
+ // The element binds when it is in the page, the Turbo adapter reports the page
63
+ // as live, and it has a descriptor. It abandons an attempt synchronously,
64
+ // because Turbo copies the page as soon as before-cache fires, and after a
65
+ // retarget the editor has to stop writing to the old document before anything
66
+ // else runs. Starting and advancing an attempt and handling every async result
67
+ // all go through #settle, which runs after the current call stack and compares
68
+ // what should be bound with what is.
69
+ class YrbyDocumentElement extends Base {
70
+ /** Set before adding elements to use another consumer, such as AnyCable's. */
71
+ static consumer;
72
+ // refresh is read when the session is acquired and isn't part of the
73
+ // document's identity, so changing it doesn't rebind the editor.
74
+ static observedAttributes = ["grant", "name", "channel"];
75
+ #live = false; // false while Turbo is showing a cached copy of the page
76
+ #attempt;
77
+ // Key of the document whose session blocked or failed. The element won't retry
78
+ // it until the page renders again, the attributes change, or the element is
79
+ // re-inserted.
80
+ #stalledKey;
81
+ #unregister;
82
+ #settleQueued = false;
83
+ // Resolves when the current attempt first syncs. Abandoning the attempt replaces it.
84
+ #firstSync = deferred();
85
+ get session() {
86
+ // Treat a lease its session aborted as gone, even before settle runs.
87
+ const lease = this.#attempt?.lease;
88
+ return lease && !lease.signal.aborted ? lease.session : undefined;
89
+ }
90
+ get doc() { return this.session?.doc; }
91
+ get provider() { return this.session?.provider; }
92
+ /** Resolves after the current attempt's first sync. If the attempt is abandoned, its promise never resolves. */
93
+ get whenSynced() { return this.#firstSync.promise; }
94
+ connectedCallback() {
95
+ this.#stalledKey = undefined;
96
+ // A same-turn move keeps its attempt, so don't make a live editor inert.
97
+ if (!this.#attempt)
98
+ this.#holdInert();
99
+ this.#unregister ??= (0, turbo_adapter_js_1.registerDocumentMount)(this);
100
+ this.#requestSettle();
101
+ }
102
+ // Same-turn moves keep their binding, because settle checks isConnected afterwards.
103
+ disconnectedCallback() { this.#requestSettle(); }
104
+ attributeChangedCallback(_name, oldValue, newValue) {
105
+ if (oldValue === newValue)
106
+ return;
107
+ this.#stalledKey = undefined;
108
+ this.#abandon();
109
+ this.#requestSettle();
110
+ }
111
+ /** @internal Called by the Turbo adapter when the page is live. A new render also retries a stalled document. */
112
+ activate() {
113
+ this.#live = true;
114
+ this.#stalledKey = undefined;
115
+ this.#requestSettle();
116
+ }
117
+ /** @internal Called by the Turbo adapter when the page is cached or previewed. */
118
+ deactivate() {
119
+ this.#live = false;
120
+ this.#abandon();
121
+ }
122
+ /** Releases the editor lease. The session keeps any unsaved work. */
123
+ destroy() {
124
+ // Reset the adapter's last report. The next connection registers again and gets a new one.
125
+ this.#live = false;
126
+ // Clear it before the call, since the call can re-enter through a replacement registration.
127
+ const unregister = this.#unregister;
128
+ this.#unregister = undefined;
129
+ unregister?.();
130
+ this.#abandon();
131
+ }
132
+ #requestSettle() {
133
+ if (this.#settleQueued)
134
+ return;
135
+ this.#settleQueued = true;
136
+ queueMicrotask(() => this.#settle());
137
+ }
138
+ #settle() {
139
+ this.#settleQueued = false;
140
+ if (!this.isConnected) {
141
+ this.destroy();
142
+ return;
143
+ }
144
+ const descriptor = this.#descriptor();
145
+ // key is undefined when nothing should be bound, because the page is cached
146
+ // or the attributes don't name a document yet.
147
+ const key = this.#live && descriptor.grant && descriptor.name ? (0, document_session_js_1.documentKey)(descriptor) : undefined;
148
+ const attempt = this.#attempt;
149
+ if (attempt && attempt.key !== key) {
150
+ // Should not happen, since every change to these facts already abandons the attempt.
151
+ this.#abandon();
152
+ this.#requestSettle();
153
+ return;
154
+ }
155
+ if (key === undefined || key === this.#stalledKey)
156
+ return;
157
+ // Take the next step. An attempt starts, acquires a lease once the consumer
158
+ // loads, and announces once synced. If the attempt has ended, stall.
159
+ if (!attempt)
160
+ this.#start(key, descriptor);
161
+ else if (attempt.ended)
162
+ this.#stall(attempt.ended.detail);
163
+ else if (attempt.consumer && !attempt.lease)
164
+ this.#acquire(attempt, attempt.consumer);
165
+ else if (attempt.synced && !attempt.announced)
166
+ this.#announce(attempt);
167
+ }
168
+ #start(key, descriptor) {
169
+ const attempt = { key, descriptor };
170
+ this.#attempt = attempt;
171
+ Promise.resolve(_a.consumer ?? defaultConsumer()).then(consumer => { attempt.consumer = consumer; }, error => { attempt.ended = { detail: { error } }; }).then(() => this.#requestSettle());
172
+ }
173
+ #acquire(attempt, consumer) {
174
+ let lease;
175
+ try {
176
+ lease = document_session_js_1.DocumentSessionStore.for(consumer).acquire(attempt.descriptor);
177
+ }
178
+ catch (error) {
179
+ this.#stall({ error });
180
+ return;
181
+ }
182
+ attempt.lease = lease;
183
+ const { session } = lease;
184
+ // A blocked session keeps new leases for retry(), and its first sync may
185
+ // be long past, so an editor must not bind to it.
186
+ const blocked = blockReport(session);
187
+ if (blocked) {
188
+ attempt.ended = { detail: blocked };
189
+ this.#requestSettle();
190
+ return;
191
+ }
192
+ // The lease aborts when its session blocks or is discarded. Read the
193
+ // reason now, before anything retries the session.
194
+ lease.signal.addEventListener("abort", () => {
195
+ attempt.ended ??= { detail: blockReport(session) };
196
+ this.#requestSettle();
197
+ }, { once: true });
198
+ void session.whenSynced.then(() => {
199
+ attempt.synced = true;
200
+ this.#requestSettle();
201
+ });
202
+ }
203
+ #announce(attempt) {
204
+ attempt.announced = true;
205
+ const lease = attempt.lease;
206
+ const { session } = lease;
207
+ this.#restoreInert();
208
+ this.#firstSync.resolve();
209
+ this.dispatchEvent(new CustomEvent("yrby:synced", {
210
+ bubbles: true,
211
+ detail: { session, doc: session.doc, provider: session.provider, lease, signal: lease.signal },
212
+ }));
213
+ }
214
+ #stall(detail) {
215
+ this.#stalledKey = this.#attempt?.key;
216
+ this.#abandon();
217
+ if (detail)
218
+ this.dispatchEvent(new CustomEvent("yrby:error", { bubbles: true, detail }));
219
+ }
220
+ // Ends the current attempt. Releasing the lease runs editor cleanup, which
221
+ // may change attributes or move the element, and those changes only request
222
+ // a settle.
223
+ #abandon() {
224
+ const attempt = this.#attempt;
225
+ if (!attempt)
226
+ return;
227
+ this.#attempt = undefined;
228
+ this.#firstSync = deferred();
229
+ this.#holdInert();
230
+ attempt.lease?.release();
231
+ }
232
+ #descriptor() {
233
+ return {
234
+ channel: this.getAttribute("channel") || undefined,
235
+ grant: this.getAttribute("grant") || "",
236
+ name: this.getAttribute("name") || "",
237
+ refresh: this.getAttribute("refresh") || undefined,
238
+ };
239
+ }
240
+ // Stay inert until synced so nobody types into a document that isn't live yet.
241
+ // The application's own inert value goes in an attribute, which survives a
242
+ // Turbo cache clone, and is restored when the element is ready.
243
+ #holdInert() {
244
+ if (!this.hasAttribute(INERT_ATTRIBUTE))
245
+ this.setAttribute(INERT_ATTRIBUTE, String(this.inert));
246
+ this.inert = true;
247
+ }
248
+ #restoreInert() {
249
+ const saved = this.getAttribute(INERT_ATTRIBUTE);
250
+ if (saved === null)
251
+ return;
252
+ this.inert = saved === "true";
253
+ this.removeAttribute(INERT_ATTRIBUTE);
254
+ }
255
+ }
256
+ exports.YrbyDocumentElement = YrbyDocumentElement;
257
+ _a = YrbyDocumentElement;
258
+ if (typeof customElements !== "undefined" && !customElements.get("yrby-document")) {
259
+ customElements.define("yrby-document", YrbyDocumentElement);
260
+ }
@@ -0,0 +1,62 @@
1
+ import * as Y from "yjs";
2
+ import { ActionCableProvider, type CableConsumer } from "./actioncable_provider.js";
3
+ export interface DocumentDescriptor {
4
+ channel?: string;
5
+ grant: string;
6
+ name: string;
7
+ /** A same-origin URL that returns `{ "grant": "..." }` for this document. The session fetches it at most once per rejection. */
8
+ refresh?: string;
9
+ }
10
+ export type ResolvedDescriptor = Readonly<{
11
+ channel: string;
12
+ grant: string;
13
+ name: string;
14
+ refresh?: string;
15
+ }>;
16
+ export type DocumentSessionState = "open" | "blocked" | "closed";
17
+ /** The identity of the document a descriptor names. Matching keys share a session. */
18
+ export declare function documentKey(descriptor: DocumentDescriptor): string;
19
+ declare const attachLease: unique symbol;
20
+ declare const notifyStoreChange: unique symbol;
21
+ /** Holds one consumer's sessions and emits "change" with the session in `detail`. */
22
+ export declare class DocumentSessionStore extends EventTarget {
23
+ #private;
24
+ readonly consumer: CableConsumer;
25
+ static for(consumer: CableConsumer): DocumentSessionStore;
26
+ private constructor();
27
+ get sessions(): readonly DocumentSession[];
28
+ /** Acquire a lease on this document session, creating it on first use. */
29
+ acquire(input: DocumentDescriptor): DocumentLease;
30
+ [notifyStoreChange](session: DocumentSession): void;
31
+ }
32
+ /** A caller's hold on a session. Release it when you're done with the session. */
33
+ export declare class DocumentLease {
34
+ #private;
35
+ readonly session: DocumentSession;
36
+ constructor(session: DocumentSession, onRelease: () => void);
37
+ /** Aborts when the lease ends, including when the session blocks or is discarded. */
38
+ get signal(): AbortSignal;
39
+ setPresence(state: Record<string, unknown> | null): void;
40
+ /** Runs editor cleanup synchronously, before the final check for pending work. */
41
+ release(): void;
42
+ }
43
+ export declare class DocumentSession {
44
+ #private;
45
+ readonly store: DocumentSessionStore;
46
+ readonly descriptor: ResolvedDescriptor;
47
+ private readonly remove;
48
+ readonly doc: Y.Doc;
49
+ readonly provider: ActionCableProvider;
50
+ /** Create and hold sessions through DocumentSessionStore.acquire. */
51
+ constructor(store: DocumentSessionStore, descriptor: ResolvedDescriptor, remove: () => void);
52
+ get error(): unknown;
53
+ get hasPending(): boolean;
54
+ get whenSynced(): Promise<void>;
55
+ get state(): DocumentSessionState;
56
+ [attachLease](): DocumentLease;
57
+ /** Reconnects with this session's current grant, which is the original one or the last one a refresh returned. */
58
+ retry(): void;
59
+ /** Closes the session and drops pending work. The application calls this explicitly, because an ordinary detach keeps pending work. */
60
+ discard(): void;
61
+ }
62
+ export {};
@@ -0,0 +1,335 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.DocumentSession = exports.DocumentLease = exports.DocumentSessionStore = void 0;
37
+ exports.documentKey = documentKey;
38
+ // Holds a document for the application, independently of editors and page
39
+ // navigation.
40
+ //
41
+ // A session remains open while an editor is attached or the server has not yet
42
+ // acknowledged some of its edits, and it closes itself when neither is true.
43
+ // When the server rejects the subscription, the session tries the descriptor's
44
+ // refresh URL once if it has one. If there is no URL, the refresh fails, or the
45
+ // server rejects the new grant, the session blocks. Blocking releases its
46
+ // editors and keeps its queued work in memory until the application calls
47
+ // retry() or discard().
48
+ const Y = __importStar(require("yjs"));
49
+ const random_1 = require("lib0/random");
50
+ const actioncable_provider_js_1 = require("./actioncable_provider.js");
51
+ // Each phase sets the state apps see, whether a new lease connects right away,
52
+ // and which transitions are allowed. A new lease does not connect while the
53
+ // session is blocked or waiting for a refreshed grant. Transitions that aren't
54
+ // listed are ignored.
55
+ const PHASES = {
56
+ open: { state: "open", connects: true, on: { refresh: "refreshing", block: "blocked", close: "closed" } },
57
+ refreshing: { state: "open", connects: false, on: { renew: "renewed", block: "blocked", close: "closed" } },
58
+ renewed: { state: "open", connects: true, on: { accept: "open", block: "blocked", close: "closed" } },
59
+ blocked: { state: "blocked", connects: false, on: { retry: "open", close: "closed" } },
60
+ closed: { state: "closed", connects: false, on: {} },
61
+ };
62
+ // Without a limit, a refresh request that never returns would leave the
63
+ // session offline and stuck with its editors attached. After this long, the
64
+ // session blocks.
65
+ const REFRESH_TIMEOUT_MS = 15_000;
66
+ const DEFAULT_CHANNEL = "Y::DocumentChannel";
67
+ const stores = new WeakMap();
68
+ /** The identity of the document a descriptor names. Matching keys share a session. */
69
+ function documentKey(descriptor) {
70
+ return JSON.stringify([descriptor.channel || DEFAULT_CHANNEL, descriptor.grant, descriptor.name]);
71
+ }
72
+ // Only the factory may create a store for a consumer.
73
+ const storeToken = Symbol("storeToken");
74
+ // Not exported, so only the store can acquire leases.
75
+ const attachLease = Symbol("attachLease");
76
+ // Not exported, so only a session can publish store changes.
77
+ const notifyStoreChange = Symbol("notifyStoreChange");
78
+ /** Holds one consumer's sessions and emits "change" with the session in `detail`. */
79
+ class DocumentSessionStore extends EventTarget {
80
+ consumer;
81
+ static for(consumer) {
82
+ let store = stores.get(consumer);
83
+ if (!store)
84
+ stores.set(consumer, store = new DocumentSessionStore(consumer, storeToken));
85
+ return store;
86
+ }
87
+ #sessions = new Map();
88
+ constructor(consumer, token) {
89
+ super();
90
+ this.consumer = consumer;
91
+ if (token !== storeToken)
92
+ throw new Error("Use DocumentSessionStore.for(consumer)");
93
+ }
94
+ get sessions() { return [...this.#sessions.values()]; }
95
+ /** Acquire a lease on this document session, creating it on first use. */
96
+ acquire(input) {
97
+ if (!input.grant || !input.name)
98
+ throw new Error("A document requires a grant and name");
99
+ // The refresh URL is not part of the identity. Matching tuples share a
100
+ // session, which renews with the URL from the first acquisition.
101
+ const descriptor = Object.freeze({
102
+ channel: input.channel || DEFAULT_CHANNEL,
103
+ grant: input.grant,
104
+ name: input.name,
105
+ ...(input.refresh ? { refresh: input.refresh } : {}),
106
+ });
107
+ const key = documentKey(descriptor);
108
+ let session = this.#sessions.get(key);
109
+ if (!session) {
110
+ session = new DocumentSession(this, descriptor, () => { this.#sessions.delete(key); });
111
+ this.#sessions.set(key, session);
112
+ }
113
+ return session[attachLease]();
114
+ }
115
+ [notifyStoreChange](session) {
116
+ this.dispatchEvent(new CustomEvent("change", { detail: session }));
117
+ }
118
+ }
119
+ exports.DocumentSessionStore = DocumentSessionStore;
120
+ /** A caller's hold on a session. Release it when you're done with the session. */
121
+ class DocumentLease {
122
+ session;
123
+ #controller = new AbortController();
124
+ #onRelease;
125
+ constructor(session, onRelease) {
126
+ this.session = session;
127
+ this.#onRelease = onRelease;
128
+ }
129
+ /** Aborts when the lease ends, including when the session blocks or is discarded. */
130
+ get signal() { return this.#controller.signal; }
131
+ setPresence(state) {
132
+ if (!this.signal.aborted)
133
+ this.session.provider.awareness.setLocalState(state);
134
+ }
135
+ /** Runs editor cleanup synchronously, before the final check for pending work. */
136
+ release() {
137
+ if (this.signal.aborted)
138
+ return;
139
+ this.#controller.abort();
140
+ this.#onRelease();
141
+ }
142
+ }
143
+ exports.DocumentLease = DocumentLease;
144
+ // Application commands (acquire, retry, discard) take effect immediately.
145
+ // Provider callbacks and lease releases record what happened and request a
146
+ // settle, which runs after the current call stack finishes. Settle releases a
147
+ // blocked session's leases, closes a session nobody needs, and notifies store
148
+ // observers once.
149
+ class DocumentSession {
150
+ store;
151
+ descriptor;
152
+ remove;
153
+ doc = new Y.Doc();
154
+ provider;
155
+ #lifecycle = { phase: "open" };
156
+ #leases = new Set();
157
+ // Leases held when the session blocked. Settle releases these and keeps any
158
+ // lease acquired while blocked for retry().
159
+ #retiring;
160
+ #error;
161
+ #dirty = false; // true until store observers are notified of the latest change
162
+ #settleQueued = false;
163
+ /** Create and hold sessions through DocumentSessionStore.acquire. */
164
+ constructor(store, descriptor, remove) {
165
+ this.store = store;
166
+ this.descriptor = descriptor;
167
+ this.remove = remove;
168
+ // The session uses one provider for its whole life. The provider queues
169
+ // edits while offline, so blocking and retrying only need to disconnect and
170
+ // connect.
171
+ this.provider = new actioncable_provider_js_1.ActionCableProvider(this.doc, store.consumer, descriptor.channel, {
172
+ grant: descriptor.grant,
173
+ name: descriptor.name,
174
+ // Ack sequence numbers are per session, not per record.
175
+ session_id: (0, random_1.uuidv4)(),
176
+ }, {
177
+ onError: (error, context) => {
178
+ if (context === "rejected")
179
+ this.#rejected(error);
180
+ else if (this.state !== "closed") {
181
+ this.#error = error;
182
+ this.#changed();
183
+ }
184
+ },
185
+ });
186
+ this.provider.awareness.setLocalState(null); // no cursor until an editor sets one
187
+ this.provider.onStatusChange(({ status }) => {
188
+ if (this.state !== "open")
189
+ return;
190
+ // The server accepted the subscription, so a renewed grant is valid.
191
+ if (status === "connected" || status === "synced")
192
+ this.#transition("accept");
193
+ this.#changed();
194
+ });
195
+ }
196
+ get error() { return this.#error; }
197
+ get hasPending() { return this.provider.hasPending; }
198
+ get whenSynced() { return this.provider.whenSynced; }
199
+ get state() { return PHASES[this.#lifecycle.phase].state; }
200
+ [attachLease]() {
201
+ if (this.state === "closed")
202
+ throw new Error("Cannot acquire a closed document session");
203
+ const lease = new DocumentLease(this, () => this.#release(lease));
204
+ this.#leases.add(lease);
205
+ this.#changed();
206
+ if (PHASES[this.#lifecycle.phase].connects)
207
+ this.#connect();
208
+ return lease;
209
+ }
210
+ #release(lease) {
211
+ if (!this.#leases.delete(lease))
212
+ return;
213
+ this.#changed();
214
+ if (!this.#leases.size)
215
+ this.provider.awareness.setLocalState(null);
216
+ }
217
+ /** Reconnects with this session's current grant, which is the original one or the last one a refresh returned. */
218
+ retry() {
219
+ if (this.#transition("retry"))
220
+ this.#connect();
221
+ }
222
+ /** Closes the session and drops pending work. The application calls this explicitly, because an ordinary detach keeps pending work. */
223
+ discard() { this.#close(); }
224
+ #changed() {
225
+ this.#dirty = true;
226
+ if (this.#settleQueued)
227
+ return;
228
+ this.#settleQueued = true;
229
+ queueMicrotask(() => this.#settle());
230
+ }
231
+ #settle() {
232
+ this.#settleQueued = false;
233
+ // If editor cleanup retries or blocks during #enforce, #changed queues
234
+ // another settle to handle it.
235
+ this.#enforce();
236
+ if (!this.#dirty)
237
+ return;
238
+ this.#dirty = false;
239
+ this.store[notifyStoreChange](this);
240
+ }
241
+ #enforce() {
242
+ if (this.state === "closed")
243
+ return;
244
+ if (this.state === "blocked") {
245
+ const retiring = this.#retiring;
246
+ this.#retiring = undefined;
247
+ // Leave the queue alone until retry() or discard().
248
+ for (const lease of retiring ?? [])
249
+ lease.release();
250
+ return;
251
+ }
252
+ if (!this.#needed())
253
+ this.#close();
254
+ }
255
+ // Connect with the current grant, or resubscribe with a renewed one. The
256
+ // provider defers its own callbacks, so a failure here only blocks.
257
+ #connect(grant) {
258
+ try {
259
+ if (grant === undefined)
260
+ this.provider.connect();
261
+ else
262
+ this.provider.renew({ grant });
263
+ }
264
+ catch (error) {
265
+ this.#transition("block", error);
266
+ }
267
+ }
268
+ #needed() { return this.#leases.size > 0 || this.provider.hasPending; }
269
+ // The only method that changes the phase. It doesn't call out to other code,
270
+ // and #settle acts on the result.
271
+ #transition(event, error) {
272
+ const phase = PHASES[this.#lifecycle.phase].on[event];
273
+ if (!phase)
274
+ return false;
275
+ this.#lifecycle = { phase };
276
+ this.#retiring = phase === "blocked" ? [...this.#leases] : undefined;
277
+ if (event === "retry")
278
+ this.#error = undefined;
279
+ else if (event === "block")
280
+ this.#error = error;
281
+ this.#changed();
282
+ return true;
283
+ }
284
+ #close() {
285
+ if (!this.#transition("close"))
286
+ return;
287
+ // Leave the store first so editor cleanup below can acquire a new session.
288
+ this.remove();
289
+ for (const lease of [...this.#leases])
290
+ lease.release();
291
+ this.provider.destroy();
292
+ this.doc.destroy();
293
+ }
294
+ // Try the refresh URL once per rejection. Block if the server rejects again
295
+ // while refreshing or rejects the renewed grant.
296
+ #rejected(error) {
297
+ const url = this.descriptor.refresh;
298
+ if (url && this.#transition("refresh"))
299
+ void this.#refresh(url, this.#lifecycle);
300
+ else
301
+ this.#transition("block", error);
302
+ }
303
+ async #refresh(url, attempt) {
304
+ let grant;
305
+ try {
306
+ grant = await fetchGrant(url);
307
+ }
308
+ catch (error) {
309
+ if (this.#lifecycle === attempt)
310
+ this.#transition("block", error);
311
+ return;
312
+ }
313
+ // A retry, discard, or block during the request takes precedence over it.
314
+ if (this.#lifecycle !== attempt)
315
+ return;
316
+ if (this.#transition("renew"))
317
+ this.#connect(grant);
318
+ }
319
+ }
320
+ exports.DocumentSession = DocumentSession;
321
+ /** Ask the application for a new grant. Resolves to the grant or throws. */
322
+ async function fetchGrant(url) {
323
+ const response = await fetch(url, {
324
+ credentials: "same-origin",
325
+ headers: { Accept: "application/json" },
326
+ signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS),
327
+ });
328
+ if (!response.ok)
329
+ throw new Error(`grant refresh failed: ${response.status}`);
330
+ const body = await response.json();
331
+ const grant = body?.grant;
332
+ if (typeof grant !== "string" || !grant)
333
+ throw new Error("grant refresh returned no grant");
334
+ return grant;
335
+ }
@@ -5,3 +5,5 @@ export type { YProtocolSessionOptions } from "./y_protocol_session.js";
5
5
  export { ActionCableProvider } from "./actioncable_provider.js";
6
6
  export type { ActionCableProviderOptions, ProviderStatus, StatusEvent, CableConsumer, CableSubscription, } from "./actioncable_provider.js";
7
7
  export { toBase64, fromBase64 } from "./base64.js";
8
+ export { DocumentSessionStore } from "./document_session.js";
9
+ export type { DocumentSession, DocumentLease, DocumentDescriptor, DocumentSessionState } from "./document_session.js";
package/dist/cjs/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.fromBase64 = exports.toBase64 = exports.ActionCableProvider = exports.MessageType = exports.YProtocolSession = exports.ReliableSync = void 0;
3
+ exports.DocumentSessionStore = exports.fromBase64 = exports.toBase64 = exports.ActionCableProvider = exports.MessageType = exports.YProtocolSession = exports.ReliableSync = void 0;
4
4
  // Zero-dependency reliable-delivery core. Safe to import on its own.
5
5
  var reliable_sync_js_1 = require("./reliable_sync.js");
6
6
  Object.defineProperty(exports, "ReliableSync", { enumerable: true, get: function () { return reliable_sync_js_1.ReliableSync; } });
@@ -17,3 +17,6 @@ Object.defineProperty(exports, "ActionCableProvider", { enumerable: true, get: f
17
17
  var base64_js_1 = require("./base64.js");
18
18
  Object.defineProperty(exports, "toBase64", { enumerable: true, get: function () { return base64_js_1.toBase64; } });
19
19
  Object.defineProperty(exports, "fromBase64", { enumerable: true, get: function () { return base64_js_1.fromBase64; } });
20
+ // Document sessions, leased by editors or held by headless workflows.
21
+ var document_session_js_1 = require("./document_session.js");
22
+ Object.defineProperty(exports, "DocumentSessionStore", { enumerable: true, get: function () { return document_session_js_1.DocumentSessionStore; } });