@brass-build/client 0.2.0 → 0.3.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/CHANGELOG.md +11 -0
- package/README.md +16 -12
- package/dist/client.d.ts +1 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +28 -1
- package/dist/client.js.map +1 -1
- package/dist/document-checkpoints.d.ts +1 -0
- package/dist/document-checkpoints.d.ts.map +1 -1
- package/dist/document-checkpoints.js +27 -0
- package/dist/document-checkpoints.js.map +1 -1
- package/dist/document-signals.d.ts +31 -1
- package/dist/document-signals.d.ts.map +1 -1
- package/dist/document-signals.js +59 -0
- package/dist/document-signals.js.map +1 -1
- package/dist/document-thumbnail-capture.d.ts +20 -0
- package/dist/document-thumbnail-capture.d.ts.map +1 -1
- package/dist/document-thumbnail-capture.js +247 -9
- package/dist/document-thumbnail-capture.js.map +1 -1
- package/dist/documents-namespace.d.ts.map +1 -1
- package/dist/documents-namespace.js +3 -0
- package/dist/documents-namespace.js.map +1 -1
- package/dist/documents.d.ts +6 -2
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +110 -14
- package/dist/documents.js.map +1 -1
- package/dist/handles.d.ts.map +1 -1
- package/dist/handles.js +2 -2
- package/dist/handles.js.map +1 -1
- package/dist/importer.d.ts.map +1 -1
- package/dist/importer.js +11 -60
- package/dist/importer.js.map +1 -1
- package/dist/internal.d.ts +1 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +1 -0
- package/dist/internal.js.map +1 -1
- package/dist/open-document.d.ts +15 -0
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +54 -1
- package/dist/open-document.js.map +1 -1
- package/dist/record-paging.d.ts +13 -14
- package/dist/record-paging.d.ts.map +1 -1
- package/dist/record-paging.js +13 -14
- package/dist/record-paging.js.map +1 -1
- package/dist/serializer.d.ts +3 -3
- package/dist/session-toolbar.d.ts.map +1 -1
- package/dist/session-toolbar.js +131 -9
- package/dist/session-toolbar.js.map +1 -1
- package/dist/version-bar.d.ts.map +1 -1
- package/dist/version-bar.js +23 -2
- package/dist/version-bar.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/dist/when-saved.d.ts +2 -0
- package/dist/when-saved.d.ts.map +1 -0
- package/dist/when-saved.js +70 -0
- package/dist/when-saved.js.map +1 -0
- package/dist/ws-client.d.ts +4 -0
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +70 -1
- package/dist/ws-client.js.map +1 -1
- package/dist/ws-frame-limit.d.ts.map +1 -1
- package/dist/ws-frame-limit.js +9 -10
- package/dist/ws-frame-limit.js.map +1 -1
- package/docs/custom-chrome.md +4 -2
- package/package.json +1 -1
- package/src/client.ts +37 -2
- package/src/document-checkpoints.ts +30 -0
- package/src/document-signals.ts +113 -1
- package/src/document-thumbnail-capture.ts +253 -9
- package/src/documents-namespace.ts +20 -10
- package/src/documents.ts +120 -13
- package/src/handles.ts +6 -5
- package/src/importer.ts +11 -56
- package/src/internal.ts +6 -0
- package/src/open-document.ts +100 -1
- package/src/record-paging.ts +13 -14
- package/src/serializer.ts +3 -3
- package/src/session-toolbar.ts +139 -10
- package/src/version-bar.ts +24 -5
- package/src/version.ts +1 -1
- package/src/when-saved.ts +66 -0
- package/src/ws-client.ts +70 -1
- package/src/ws-frame-limit.ts +9 -10
package/src/session-toolbar.ts
CHANGED
|
@@ -19,7 +19,10 @@ import type { BrassUser } from './types.js';
|
|
|
19
19
|
import type { Command, Document as BrassDocument } from './documents.js';
|
|
20
20
|
import type { DocumentSession } from './session.js';
|
|
21
21
|
import type { PresenceParticipant } from './presence.js';
|
|
22
|
-
import type {
|
|
22
|
+
import type {
|
|
23
|
+
DocumentDriveStatus,
|
|
24
|
+
DrivePublishTarget,
|
|
25
|
+
} from './document-signals.js';
|
|
23
26
|
import {
|
|
24
27
|
createVersionBarFrame,
|
|
25
28
|
type VersionBarDeps,
|
|
@@ -309,6 +312,10 @@ const CSS = `
|
|
|
309
312
|
.b-drive-source { font-size:12px; color:var(--brass-accent); text-decoration:none; white-space:nowrap; }
|
|
310
313
|
.b-drive-source:hover { text-decoration:underline; }
|
|
311
314
|
.b-drive-detail { margin:0; padding:0 10px 6px; font-size:12px; color:var(--brass-fg-muted); }
|
|
315
|
+
/* The drive rejecting a published file is the one line in this panel that
|
|
316
|
+
reports a problem, so it carries the danger tone rather than the muted one
|
|
317
|
+
every other line uses. */
|
|
318
|
+
.b-drive-unreadable { margin:0; padding:0 10px 6px; font-size:12px; color:var(--brass-danger, #b3261e); }
|
|
312
319
|
/* Historical-version preview bar: a full-width strip the toolbar shows below
|
|
313
320
|
the bar whenever the active doc is previewing an earlier version. The strip
|
|
314
321
|
itself is the platform-served version-bar iframe (the SDK owns only this
|
|
@@ -958,6 +965,7 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
|
|
|
958
965
|
// document that was closed.
|
|
959
966
|
let boundDoc: DocumentSession['document'] = null;
|
|
960
967
|
let statusOff: (() => void) | null = null;
|
|
968
|
+
let publishOff: (() => void) | null = null;
|
|
961
969
|
|
|
962
970
|
// A publish runs on a server worker and reaches `synced` only after the
|
|
963
971
|
// worker (and, on a file-roundtrip, the drive's importer) confirms. In that
|
|
@@ -971,24 +979,111 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
|
|
|
971
979
|
let publishing = false;
|
|
972
980
|
let publishError = false;
|
|
973
981
|
let publishTimer: ReturnType<typeof setTimeout> | null = null;
|
|
982
|
+
// Set on a click, cleared by the next publish-state frame. Between the two,
|
|
983
|
+
// the held state describes the PREVIOUS attempt, and rendering it would
|
|
984
|
+
// report the last failure over the publish just started.
|
|
985
|
+
let publishStateStale = false;
|
|
974
986
|
let currentStatus: DocumentDriveStatus | null = null;
|
|
975
987
|
const clearPublishing = (): void => {
|
|
976
988
|
publishing = false;
|
|
989
|
+
// The stale mark is cleared with the hold it belongs to. It is otherwise
|
|
990
|
+
// cleared only by a publish-state frame, so a publish that draws none
|
|
991
|
+
// would leave the panel reading the target's resting sentence for the
|
|
992
|
+
// life of the document.
|
|
993
|
+
publishStateStale = false;
|
|
977
994
|
if (publishTimer !== null) {
|
|
978
995
|
clearTimeout(publishTimer);
|
|
979
996
|
publishTimer = null;
|
|
980
997
|
}
|
|
981
998
|
};
|
|
982
999
|
|
|
1000
|
+
// The panel for a document with no source item: the server's sentence about
|
|
1001
|
+
// where the file lands, and the publish that puts it there. The first
|
|
1002
|
+
// publish creates the artifact and makes the document its mirror, at which
|
|
1003
|
+
// point the drive status arrives and the mirror panel above replaces this
|
|
1004
|
+
// one.
|
|
1005
|
+
const renderPublishOffer = (target: DrivePublishTarget): void => {
|
|
1006
|
+
root.hidden = false;
|
|
1007
|
+
// A document the drive has never received is as far out of sync as one
|
|
1008
|
+
// whose edits have not been published, so the icon carries the same
|
|
1009
|
+
// attention an `ahead` document's does. What separates them is what the
|
|
1010
|
+
// panel offers, not how much the reader is told to look.
|
|
1011
|
+
root.classList.add('has-changes');
|
|
1012
|
+
root.classList.remove('is-broken');
|
|
1013
|
+
const state = publishStateStale ? null : (boundDoc?._publishState ?? null);
|
|
1014
|
+
const inFlight =
|
|
1015
|
+
publishing || state?.publishing === true || target.publishing;
|
|
1016
|
+
button.title = inFlight
|
|
1017
|
+
? 'Publishing...'
|
|
1018
|
+
: state?.failed
|
|
1019
|
+
? `Publish to ${target.driveName} failed`
|
|
1020
|
+
: `Publish to ${target.driveName}`;
|
|
1021
|
+
|
|
1022
|
+
const failed =
|
|
1023
|
+
!inFlight && (state?.failed === true || target.publishFailed === true);
|
|
1024
|
+
|
|
1025
|
+
const rows: HTMLElement[] = [];
|
|
1026
|
+
// One line, server-authored either way: the worker's phase copy while it
|
|
1027
|
+
// runs, its account of the failure when it stopped, and the resting
|
|
1028
|
+
// sentence otherwise. Marked as a problem when it is one, since nothing
|
|
1029
|
+
// else in this panel distinguishes a document whose publish failed from
|
|
1030
|
+
// one that has simply never published.
|
|
1031
|
+
const p = el('p', failed ? 'b-drive-unreadable' : 'b-drive-detail');
|
|
1032
|
+
p.textContent = inFlight
|
|
1033
|
+
? (state?.publishing === true ? (state.detail ?? 'Publishing...') : 'Publishing...')
|
|
1034
|
+
: (state?.detail ?? target.detail);
|
|
1035
|
+
rows.push(p);
|
|
1036
|
+
|
|
1037
|
+
const pub = el('button');
|
|
1038
|
+
pub.type = 'button';
|
|
1039
|
+
pub.disabled = inFlight;
|
|
1040
|
+
pub.textContent = inFlight
|
|
1041
|
+
? 'Publishing...'
|
|
1042
|
+
: publishError || failed
|
|
1043
|
+
? `Publish to ${target.driveName}, retry`
|
|
1044
|
+
: `Publish to ${target.driveName}`;
|
|
1045
|
+
pub.onclick = () => {
|
|
1046
|
+
const doc = deps.session.document;
|
|
1047
|
+
if (!doc) return;
|
|
1048
|
+
publishError = false;
|
|
1049
|
+
publishing = true;
|
|
1050
|
+
publishStateStale = true;
|
|
1051
|
+
// The same safety net the mirror panel keeps: a publish that neither
|
|
1052
|
+
// errors nor lands leaves the panel offering the action again rather
|
|
1053
|
+
// than held forever.
|
|
1054
|
+
publishTimer = setTimeout(() => {
|
|
1055
|
+
clearPublishing();
|
|
1056
|
+
render(currentStatus);
|
|
1057
|
+
}, PUBLISH_HOLD_MS);
|
|
1058
|
+
render(currentStatus);
|
|
1059
|
+
void doc.pushToDrive().catch(() => {
|
|
1060
|
+
clearPublishing();
|
|
1061
|
+
publishError = true;
|
|
1062
|
+
render(currentStatus);
|
|
1063
|
+
});
|
|
1064
|
+
};
|
|
1065
|
+
rows.push(pub);
|
|
1066
|
+
pop.replaceChildren(...rows);
|
|
1067
|
+
};
|
|
1068
|
+
|
|
983
1069
|
const render = (status: DocumentDriveStatus | null): void => {
|
|
984
1070
|
currentStatus = status;
|
|
985
|
-
//
|
|
1071
|
+
// A document that mirrors nothing can still have somewhere to go: its
|
|
1072
|
+
// project may be linked to a drive that takes it as a new file, which is
|
|
1073
|
+
// what an upload into a linked project sits waiting for. Offer that
|
|
1074
|
+
// publish here, so an app gets it by mounting this piece rather than by
|
|
1075
|
+
// building a surface of its own.
|
|
986
1076
|
if (status === null) {
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
1077
|
+
const target = boundDoc?._publishTarget ?? null;
|
|
1078
|
+
if (target === null) {
|
|
1079
|
+
root.hidden = true;
|
|
1080
|
+
menu.close();
|
|
1081
|
+
clearPublishing();
|
|
1082
|
+
root.classList.remove('has-changes', 'is-broken');
|
|
1083
|
+
pop.replaceChildren();
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
renderPublishOffer(target);
|
|
992
1087
|
return;
|
|
993
1088
|
}
|
|
994
1089
|
// The publish settles once the doc actually reaches synced; drop the
|
|
@@ -1054,6 +1149,15 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
|
|
|
1054
1149
|
rows.push(p);
|
|
1055
1150
|
}
|
|
1056
1151
|
|
|
1152
|
+
// The drive could not read what the last publish delivered. Shown in the
|
|
1153
|
+
// drive's own words, above the commands: the publish succeeded, so
|
|
1154
|
+
// nothing else in this panel says anything is wrong.
|
|
1155
|
+
if (status.sourceUnreadable) {
|
|
1156
|
+
const warn = el('p', 'b-drive-unreadable');
|
|
1157
|
+
warn.textContent = status.sourceUnreadable;
|
|
1158
|
+
rows.push(warn);
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1057
1161
|
if (isPublishing) {
|
|
1058
1162
|
// Held while the publish is in flight (a local click hold, or the doc's
|
|
1059
1163
|
// pending work-status), until it reaches synced (see `isPublishing`).
|
|
@@ -1063,8 +1167,9 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
|
|
|
1063
1167
|
pub.disabled = true;
|
|
1064
1168
|
rows.push(pub);
|
|
1065
1169
|
} else if (status.canPush) {
|
|
1066
|
-
// `canPush` already encodes two-way + not broken + local edits
|
|
1067
|
-
// never offers a
|
|
1170
|
+
// `canPush` already encodes two-way + not broken + local edits + a
|
|
1171
|
+
// server-writable format, so this never offers a publish the server
|
|
1172
|
+
// would refuse.
|
|
1068
1173
|
const pub = el('button');
|
|
1069
1174
|
pub.type = 'button';
|
|
1070
1175
|
pub.textContent = publishError ? 'Publish failed, retry' : 'Publish changes';
|
|
@@ -1112,6 +1217,8 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
|
|
|
1112
1217
|
function bind(): void {
|
|
1113
1218
|
statusOff?.();
|
|
1114
1219
|
statusOff = null;
|
|
1220
|
+
publishOff?.();
|
|
1221
|
+
publishOff = null;
|
|
1115
1222
|
const doc = deps.session.document;
|
|
1116
1223
|
if (!doc) {
|
|
1117
1224
|
render(null);
|
|
@@ -1122,6 +1229,17 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
|
|
|
1122
1229
|
// started from any surface (the dashboard card, the app) flips it and the
|
|
1123
1230
|
// indicator holds "Publishing..." without a local click.
|
|
1124
1231
|
statusOff = doc.onDriveStatus(render);
|
|
1232
|
+
// The same signal for a document that mirrors nothing yet, which has no
|
|
1233
|
+
// drive status to carry it: its first publish is the one that creates the
|
|
1234
|
+
// artifact, and this is how its progress and its failure arrive.
|
|
1235
|
+
publishOff = doc._onPublishState(() => {
|
|
1236
|
+
publishStateStale = false;
|
|
1237
|
+
// A publish the server has finished with ends the local click hold:
|
|
1238
|
+
// holding "Publishing..." for the rest of it would report a push that
|
|
1239
|
+
// is over as one still running.
|
|
1240
|
+
if (!doc._publishState.publishing) clearPublishing();
|
|
1241
|
+
render(currentStatus);
|
|
1242
|
+
});
|
|
1125
1243
|
}
|
|
1126
1244
|
|
|
1127
1245
|
const onSession = (): void => {
|
|
@@ -1138,6 +1256,7 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
|
|
|
1138
1256
|
destroy(): void {
|
|
1139
1257
|
unsub();
|
|
1140
1258
|
statusOff?.();
|
|
1259
|
+
publishOff?.();
|
|
1141
1260
|
clearPublishing();
|
|
1142
1261
|
menu.destroy();
|
|
1143
1262
|
},
|
|
@@ -1334,8 +1453,18 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
|
|
|
1334
1453
|
// left it checked out would leave an app read-only with nothing to
|
|
1335
1454
|
// clear it. A document pinned to its version has no head to return to
|
|
1336
1455
|
// and keeps showing it; `dismissedAt` is what keeps the bar dismissed.
|
|
1456
|
+
// Once the return lands, the dismissal has nothing left to hold
|
|
1457
|
+
// against: the doc is live, and a LATER pick of the same version in
|
|
1458
|
+
// the history dialog is a fresh preview that must latch the bar (its
|
|
1459
|
+
// pick sets only `version`, so a stale `dismissedAt` equal to it would
|
|
1460
|
+
// otherwise leave the reader checked out, read-only, with no bar and
|
|
1461
|
+
// no exit).
|
|
1337
1462
|
if (doc && doc.version !== null) {
|
|
1338
|
-
void attempt(doc, () => doc.returnToHead())
|
|
1463
|
+
void attempt(doc, () => doc.returnToHead())
|
|
1464
|
+
.then(() => {
|
|
1465
|
+
if (session.document === doc) dismissedAt = undefined;
|
|
1466
|
+
})
|
|
1467
|
+
.catch(() => {});
|
|
1339
1468
|
}
|
|
1340
1469
|
hideBar();
|
|
1341
1470
|
},
|
package/src/version-bar.ts
CHANGED
|
@@ -239,11 +239,25 @@ export function createVersionBarFrame(
|
|
|
239
239
|
);
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
// Intents still awaiting their answer. While one is in flight, `update()`
|
|
243
|
+
// stays quiet: the checkout it runs fires the host's subscribers before its
|
|
244
|
+
// promise resolves, so a render lands here ahead of `confirmIntent`, and an
|
|
245
|
+
// uncorrelated `version-changed` posted then reaches the embed as a phantom
|
|
246
|
+
// external move one message before the answer to its own step. The answer
|
|
247
|
+
// reads the live version at settle time, so nothing the quiet window saw is
|
|
248
|
+
// lost.
|
|
249
|
+
let intentsInFlight = 0;
|
|
250
|
+
|
|
242
251
|
function runIntent(requestId: unknown, intent: () => Promise<void>): void {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
252
|
+
intentsInFlight += 1;
|
|
253
|
+
const settle = (ok: boolean) => (): void => {
|
|
254
|
+
// Decremented in every outcome. A count this leaves above zero silences
|
|
255
|
+
// `update()` for the life of the bar, so an external version change
|
|
256
|
+
// would never reach the embed again.
|
|
257
|
+
intentsInFlight -= 1;
|
|
258
|
+
confirmIntent(requestId, ok);
|
|
259
|
+
};
|
|
260
|
+
void intent().then(settle(true), settle(false));
|
|
247
261
|
}
|
|
248
262
|
|
|
249
263
|
function onMessage(event: MessageEvent): void {
|
|
@@ -336,7 +350,9 @@ export function createVersionBarFrame(
|
|
|
336
350
|
}
|
|
337
351
|
// Only an actual version change reaches the embed (dedup), so a
|
|
338
352
|
// re-render that didn't move the version can't clobber the bar's state.
|
|
339
|
-
|
|
353
|
+
// A change while an intent is in flight is that intent's own doing and
|
|
354
|
+
// is delivered by its answer instead.
|
|
355
|
+
if (ready && intentsInFlight === 0 && next.version !== deliveredVersion) {
|
|
340
356
|
deliveredVersion = next.version;
|
|
341
357
|
iframe.contentWindow?.postMessage(
|
|
342
358
|
{ [EMBED_ENVELOPE]: EMBED_PROTOCOL_VERSION, type: 'version-changed', version: next.version },
|
|
@@ -346,6 +362,9 @@ export function createVersionBarFrame(
|
|
|
346
362
|
},
|
|
347
363
|
destroy(): void {
|
|
348
364
|
destroyed = true;
|
|
365
|
+
// A bar torn down mid-intent leaves the count standing; a rebuilt bar
|
|
366
|
+
// gets a fresh closure, but this keeps the teardown total.
|
|
367
|
+
intentsInFlight = 0;
|
|
349
368
|
if (readyTimer !== undefined) {
|
|
350
369
|
clearTimeout(readyTimer);
|
|
351
370
|
readyTimer = undefined;
|
package/src/version.ts
CHANGED
|
@@ -3,6 +3,6 @@
|
|
|
3
3
|
// can measure the version distribution of deployed apps and time
|
|
4
4
|
// compatibility windows against it. A unit test pins this to
|
|
5
5
|
// package.json's version; the release bump touches both together.
|
|
6
|
-
export const SDK_VERSION = '0.
|
|
6
|
+
export const SDK_VERSION = '0.3.0';
|
|
7
7
|
|
|
8
8
|
export const SDK_CLIENT_ID = `sdk/${SDK_VERSION}`;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Waiting out a document's own unsaved ops.
|
|
2
|
+
//
|
|
3
|
+
// `savingCount` counts the ops this client has committed locally and the
|
|
4
|
+
// server has not acked, so a caller that needs the server to hold what the
|
|
5
|
+
// app just wrote waits on it: the import completion, and the checkpoint
|
|
6
|
+
// anchor, both of which describe a state rather than an intention.
|
|
7
|
+
|
|
8
|
+
import type { Document } from './documents.js';
|
|
9
|
+
|
|
10
|
+
// How often the wait re-checks. A closing document drops its subscribers
|
|
11
|
+
// without firing them, so this is the only thing that ends the wait for one
|
|
12
|
+
// that goes while writes are draining.
|
|
13
|
+
const SAVED_POLL_MS = 1_000;
|
|
14
|
+
|
|
15
|
+
// How long the OLDEST outstanding op may go unacked before the wait gives
|
|
16
|
+
// up. An op the server never received is never acked and never re-sent
|
|
17
|
+
// beyond the transport's own attempts, and `savingCount` then stays above
|
|
18
|
+
// zero for the document's life, so a wait with no bound hangs its caller
|
|
19
|
+
// forever. Read off the oldest op rather than off elapsed time, so a bulk
|
|
20
|
+
// write that takes minutes never trips it: there, every op acks promptly
|
|
21
|
+
// and the oldest is always young. Three forced-resend cycles wide (the
|
|
22
|
+
// transport forces one at 60s), so the wait outlasts the recovery it is
|
|
23
|
+
// waiting on.
|
|
24
|
+
const SAVED_STALL_MS = 180_000;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve `true` once the document has no locally-issued op waiting for its
|
|
28
|
+
* server ack; `false` when the document is no longer live, or the acks
|
|
29
|
+
* stopped arriving.
|
|
30
|
+
*
|
|
31
|
+
* `false` does not say the writes were lost, only that this wait cannot say
|
|
32
|
+
* they landed. A caller about to record something permanent against them (an
|
|
33
|
+
* import completion, a checkpoint anchor) has to stop on it.
|
|
34
|
+
*
|
|
35
|
+
* @internal
|
|
36
|
+
*/
|
|
37
|
+
export function whenSaved(doc: Document): Promise<boolean> {
|
|
38
|
+
if (doc.savingCount === 0) return Promise.resolve(true);
|
|
39
|
+
return new Promise<boolean>((resolve) => {
|
|
40
|
+
let off: (() => void) | undefined;
|
|
41
|
+
let poll: ReturnType<typeof setInterval> | undefined;
|
|
42
|
+
const settle = (saved: boolean): void => {
|
|
43
|
+
off?.();
|
|
44
|
+
if (poll !== undefined) clearInterval(poll);
|
|
45
|
+
resolve(saved);
|
|
46
|
+
};
|
|
47
|
+
try {
|
|
48
|
+
off = doc.subscribe(() => {
|
|
49
|
+
if (doc.savingCount === 0) settle(true);
|
|
50
|
+
});
|
|
51
|
+
} catch {
|
|
52
|
+
// The document closed, so nothing more is going to be acked.
|
|
53
|
+
resolve(false);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
poll = setInterval(() => {
|
|
57
|
+
if (doc.savingCount === 0) return settle(true);
|
|
58
|
+
if (!doc._isLive) return settle(false);
|
|
59
|
+
if (doc.diagnostics().oldestUnackedMs >= SAVED_STALL_MS) settle(false);
|
|
60
|
+
}, SAVED_POLL_MS);
|
|
61
|
+
// Node keeps the process alive for a pending interval; this one is
|
|
62
|
+
// bookkeeping for a wait that ends on its own, so it must not.
|
|
63
|
+
(poll as { unref?: () => void }).unref?.();
|
|
64
|
+
if (doc.savingCount === 0) settle(true);
|
|
65
|
+
});
|
|
66
|
+
}
|
package/src/ws-client.ts
CHANGED
|
@@ -17,7 +17,11 @@
|
|
|
17
17
|
// 10-minute idle timeout (only client-originated frames do), and it is the
|
|
18
18
|
// presence liveness signal (the server refreshes `last_seen` on each ping;
|
|
19
19
|
// the staleness threshold is 3 missed heartbeats). The server replies
|
|
20
|
-
// `pong`.
|
|
20
|
+
// `pong`. The same interval notices a socket that has stopped carrying
|
|
21
|
+
// anything at all (`SOCKET_SILENCE_LIMIT_MS`), since on an idle connection
|
|
22
|
+
// it is the only thing running; such a socket is retired here so the
|
|
23
|
+
// reconnect loop can replace it, because a dead path does not reliably close
|
|
24
|
+
// itself. A topic-less `{ type: 'error', reason: 'bad_message' }` is the
|
|
21
25
|
// server's catch-all rejection, so it is swallowed rather than surfaced (a
|
|
22
26
|
// deploy without the `ping` handler answers a heartbeat with it) but never
|
|
23
27
|
// counted as the connection working. Both are dropped.
|
|
@@ -103,6 +107,29 @@ const HEARTBEAT_INTERVAL_MS = 60_000;
|
|
|
103
107
|
// rather than wrong: it parks, and returning to it clears the count. A
|
|
104
108
|
// hidden page doing WORK is unaffected, because its ops draw `op_ack`.
|
|
105
109
|
const HEARTBEAT_FIRST_MS = 10_000;
|
|
110
|
+
// How long a socket may carry nothing at all before this client treats it as
|
|
111
|
+
// gone. A dead path does not always close: when a phone changes network
|
|
112
|
+
// neither end sends a FIN, so a socket that will never carry another byte
|
|
113
|
+
// still reads as OPEN, and nothing downstream notices, because the reconnect
|
|
114
|
+
// loop is driven by `close` and an op's resend goes out on the same socket.
|
|
115
|
+
// ANY inbound frame answers this, not just the `pong` the reconnect count
|
|
116
|
+
// credits: the question is whether the path still carries bytes. Two missed
|
|
117
|
+
// replies plus slack, so one dropped `pong` is not enough.
|
|
118
|
+
const SOCKET_SILENCE_LIMIT_MS = 150_000;
|
|
119
|
+
// Spread added to that limit, because the condition is SHARED: the server
|
|
120
|
+
// answers a `ping` after an awaited presence refresh and reap sweep, so its
|
|
121
|
+
// replies slow under exactly the load that makes sockets fragile, and every
|
|
122
|
+
// client goes silent at once. An unjittered threshold turns one degradation
|
|
123
|
+
// into a synchronized wave of reconnects landing on a service already
|
|
124
|
+
// shedding.
|
|
125
|
+
//
|
|
126
|
+
// Drawn PER SOCKET, not once per client. Clients that retire together
|
|
127
|
+
// reconnect together, so their next silence window is tighter than the last;
|
|
128
|
+
// a fixed per-client offset keeps its phase inside that convergence, where a
|
|
129
|
+
// fresh draw on every open scatters the schedule the wave just aligned. Two
|
|
130
|
+
// heartbeat intervals wide, since the check rides the 60s tick and a spread
|
|
131
|
+
// under one tick is quantized away.
|
|
132
|
+
const SOCKET_SILENCE_JITTER_MS = 120_000;
|
|
106
133
|
// Cap on the frames held for one topic's unacked subscribe, matching the
|
|
107
134
|
// `outboundQueue` bound. A disconnect rolls every wanted topic back to
|
|
108
135
|
// `subscribing`, so both queues hold the same kind of mid-reconnect frame
|
|
@@ -218,6 +245,14 @@ export class BrassWsClient {
|
|
|
218
245
|
// so a second outage after a recovery would go unreported for the life of
|
|
219
246
|
// the process.
|
|
220
247
|
private creditsThisSocket = 0;
|
|
248
|
+
// When this socket last carried anything inbound, set on open so a socket
|
|
249
|
+
// that never speaks is measured from the moment it opened. Zero while no
|
|
250
|
+
// socket is up, which stops a silence check firing against a connection
|
|
251
|
+
// that does not exist.
|
|
252
|
+
private lastInboundAt = 0;
|
|
253
|
+
// This socket's silence threshold, redrawn on every open. Held as state so
|
|
254
|
+
// one socket cannot move its own deadline every time the check looks at it.
|
|
255
|
+
private silenceLimitMs = SOCKET_SILENCE_LIMIT_MS;
|
|
221
256
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
222
257
|
// Sockets this client has opened. Every topic and every open document
|
|
223
258
|
// rides this one socket, so the count is a fact about the client rather
|
|
@@ -617,6 +652,9 @@ export class BrassWsClient {
|
|
|
617
652
|
this.connecting = false;
|
|
618
653
|
this.socketsOpened += 1;
|
|
619
654
|
this.creditsThisSocket = 0;
|
|
655
|
+
this.lastInboundAt = Date.now();
|
|
656
|
+
this.silenceLimitMs =
|
|
657
|
+
SOCKET_SILENCE_LIMIT_MS + Math.random() * SOCKET_SILENCE_JITTER_MS;
|
|
620
658
|
// `socket_open` per topic: re-subscribes every wanted topic (in
|
|
621
659
|
// subscription order) with a fresh retry grant, and prunes `tornDown`
|
|
622
660
|
// leftovers. Runs before the outbound drain so the server sees the
|
|
@@ -649,6 +687,9 @@ export class BrassWsClient {
|
|
|
649
687
|
}
|
|
650
688
|
|
|
651
689
|
private onMessage(ev: MessageEvent): void {
|
|
690
|
+
// Stamped before the parse: garbage the server sent is still the server
|
|
691
|
+
// reaching us, and the path carrying bytes is the whole question.
|
|
692
|
+
this.lastInboundAt = Date.now();
|
|
652
693
|
let frame: unknown;
|
|
653
694
|
try {
|
|
654
695
|
frame = JSON.parse(typeof ev.data === 'string' ? ev.data : String(ev.data));
|
|
@@ -757,6 +798,7 @@ export class BrassWsClient {
|
|
|
757
798
|
private onClose(): void {
|
|
758
799
|
this.stopHeartbeat();
|
|
759
800
|
this.ws = null;
|
|
801
|
+
this.lastInboundAt = 0;
|
|
760
802
|
// Held frames are tied to the connection's unacked subscribes;
|
|
761
803
|
// `socket_closed` drops them and preserves wantedness for the replay.
|
|
762
804
|
for (const topic of this.topics.keys()) {
|
|
@@ -917,6 +959,12 @@ export class BrassWsClient {
|
|
|
917
959
|
// from the cadence without a second timer to keep in step.
|
|
918
960
|
const arm = (delay: number): void => {
|
|
919
961
|
this.heartbeatTimer = setTimeout(() => {
|
|
962
|
+
// The one thing running on an idle connection, so it is also what
|
|
963
|
+
// notices the socket stopped carrying anything.
|
|
964
|
+
if (this.socketHasGoneSilent()) {
|
|
965
|
+
this.dropSilentSocket();
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
920
968
|
this.sendIfOpen({ type: 'ping' });
|
|
921
969
|
arm(HEARTBEAT_INTERVAL_MS);
|
|
922
970
|
}, delay);
|
|
@@ -924,6 +972,27 @@ export class BrassWsClient {
|
|
|
924
972
|
arm(HEARTBEAT_FIRST_MS);
|
|
925
973
|
}
|
|
926
974
|
|
|
975
|
+
private socketHasGoneSilent(): boolean {
|
|
976
|
+
if (!this.ws || this.lastInboundAt === 0) return false;
|
|
977
|
+
return Date.now() - this.lastInboundAt >= this.silenceLimitMs;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// Retire a socket the server has stopped answering on. `onClose` runs
|
|
981
|
+
// directly rather than being waited for: closing a dead path has nothing to
|
|
982
|
+
// complete the handshake with, so the socket can sit in CLOSING and never
|
|
983
|
+
// fire `close`, which is the state this exists to escape. Running the
|
|
984
|
+
// teardown first also lets the identity guard drop a late `close`, so the
|
|
985
|
+
// reconnect is scheduled once.
|
|
986
|
+
private dropSilentSocket(): void {
|
|
987
|
+
const dead = this.ws;
|
|
988
|
+
this.onClose();
|
|
989
|
+
try {
|
|
990
|
+
dead?.close();
|
|
991
|
+
} catch {
|
|
992
|
+
// Closing an already-dead socket is fine; the teardown already ran.
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
927
996
|
private stopHeartbeat(): void {
|
|
928
997
|
if (this.heartbeatTimer) {
|
|
929
998
|
clearTimeout(this.heartbeatTimer);
|
package/src/ws-frame-limit.ts
CHANGED
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
// The ceilings an op can hit, measured before it is sent.
|
|
2
|
-
//
|
|
3
|
-
// Duplicated from the server's `frameExceedsWsLimit` / `op-blob.ts`
|
|
4
|
-
// rather than imported: this package ships unbundled and can only import
|
|
5
|
-
// its own relative files. The server owns the contract; these follow it.
|
|
6
2
|
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
3
|
+
// The client's own SEND bound, deliberately smaller than the platform's
|
|
4
|
+
// 128 KiB server-push bound: the two are different limits, not one shared
|
|
5
|
+
// constant. A frame over this closes the connection, taking every other op
|
|
6
|
+
// and presence down with it, and the reconnect re-sends the oversized
|
|
7
|
+
// frame, so the document never saves anything. The transport also caps a
|
|
8
|
+
// message at 128 KiB, but the frame binds first in a browser: it negotiates
|
|
11
9
|
// `permessage-deflate` and Chromium sends a message as one frame, so what
|
|
12
10
|
// gets through depends on how the op compresses (43,264 bytes for
|
|
13
11
|
// incompressible base64, measured). Staying at the frame limit is what makes
|
|
14
12
|
// the bound independent of the content.
|
|
15
|
-
|
|
13
|
+
/** @internal Exported so a test can pin it against the server's bound. */
|
|
14
|
+
export const WS_SEND_FRAME_MAX_BYTES = 32 * 1024;
|
|
16
15
|
|
|
17
16
|
// Past this the blob is uploaded to storage and the write names the staged
|
|
18
17
|
// object instead of carrying it. Set by what a request body delivers
|
|
@@ -43,7 +42,7 @@ export interface OpFrame {
|
|
|
43
42
|
*/
|
|
44
43
|
export function opFrameExceedsWsLimit(frame: OpFrame): boolean {
|
|
45
44
|
const envelopeChars = JSON.stringify({ ...frame, op_blob: '' }).length;
|
|
46
|
-
return envelopeChars + frame.op_blob.length >
|
|
45
|
+
return envelopeChars + frame.op_blob.length > WS_SEND_FRAME_MAX_BYTES;
|
|
47
46
|
}
|
|
48
47
|
|
|
49
48
|
/** True when the blob is too large to ride in a request body, so it is
|