@diffenderfer-games/hub 2.0.0-next.0 → 2.0.0-next.10
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 +163 -0
- package/LICENSE +21 -21
- package/README.md +203 -139
- package/dist/auto.d.ts +8 -8
- package/dist/auto.js +3 -3
- package/dist/avatar-pixi.d.ts +165 -0
- package/dist/avatar-pixi.d.ts.map +1 -0
- package/dist/avatar-pixi.js +299 -0
- package/dist/avatar-pixi.js.map +1 -0
- package/dist/avatar-svg.d.ts +4 -0
- package/dist/avatar-svg.js +5 -0
- package/dist/avatar-three.d.ts +69 -0
- package/dist/avatar-three.d.ts.map +1 -0
- package/dist/avatar-three.js +49 -0
- package/dist/avatar-three.js.map +1 -0
- package/dist/avatar.d.ts +7 -0
- package/dist/avatar.js +500 -0
- package/dist/avatar.js.map +1 -0
- package/dist/daily.d.ts +2 -2
- package/dist/doctor/cli.mjs +119 -28
- package/dist/doctor/cli.mjs.map +1 -1
- package/dist/index.d.ts +8 -8
- package/dist/index.js +3 -3
- package/dist/input.d.ts +2 -2
- package/dist/input.js +2 -2
- package/dist/mp.d.ts +3 -3
- package/dist/race.d.ts +1 -1
- package/dist/rooms.d.ts +3 -3
- package/dist/rooms.js +1 -1
- package/dist/shared/animator.d.ts +104 -0
- package/dist/shared/animator.d.ts.map +1 -0
- package/dist/shared/art.js +1946 -0
- package/dist/shared/art.js.map +1 -0
- package/dist/shared/atlas.d.ts +138 -0
- package/dist/shared/atlas.d.ts.map +1 -0
- package/dist/shared/avatar.d.ts +123 -0
- package/dist/shared/avatar.d.ts.map +1 -0
- package/dist/shared/bridge.js +18 -4
- package/dist/shared/bridge.js.map +1 -1
- package/dist/shared/client-version.gen.js +1 -1
- package/dist/shared/daily.d.ts +145 -19
- package/dist/shared/daily.d.ts.map +1 -1
- package/dist/shared/daily.js +138 -25
- package/dist/shared/daily.js.map +1 -1
- package/dist/shared/defineProperty.js +39 -0
- package/dist/shared/describe.d.ts +32 -0
- package/dist/shared/describe.d.ts.map +1 -0
- package/dist/shared/describe.js +109 -0
- package/dist/shared/describe.js.map +1 -0
- package/dist/shared/globals.js +9 -42
- package/dist/shared/globals.js.map +1 -1
- package/dist/shared/index.d.ts +1122 -46
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/input.d.ts +74 -14
- package/dist/shared/input.d.ts.map +1 -1
- package/dist/shared/input.js +179 -25
- package/dist/shared/input.js.map +1 -1
- package/dist/shared/json-guards.js +48 -0
- package/dist/shared/json-guards.js.map +1 -0
- package/dist/shared/latest-state.js +1 -1
- package/dist/shared/live-canvas.js +62 -0
- package/dist/shared/live-canvas.js.map +1 -0
- package/dist/shared/mp.js +10 -2
- package/dist/shared/mp.js.map +1 -1
- package/dist/shared/multiplayer-types.d.ts +15 -2
- package/dist/shared/multiplayer-types.d.ts.map +1 -1
- package/dist/shared/overlays.js +800 -0
- package/dist/shared/overlays.js.map +1 -0
- package/dist/shared/page-stack.js +2 -1
- package/dist/shared/page-stack.js.map +1 -1
- package/dist/shared/pose-key.js +249 -0
- package/dist/shared/pose-key.js.map +1 -0
- package/dist/shared/race-types.d.ts +1 -1
- package/dist/shared/race.js +1 -1
- package/dist/shared/room-types.d.ts +28 -3
- package/dist/shared/room-types.d.ts.map +1 -1
- package/dist/shared/rooms.d.ts.map +1 -1
- package/dist/shared/rooms.js +23 -4
- package/dist/shared/rooms.js.map +1 -1
- package/dist/shared/runtime-opener.js +1 -1
- package/dist/shared/scene.d.ts +90 -0
- package/dist/shared/scene.d.ts.map +1 -0
- package/dist/shared/social.d.ts +90 -9
- package/dist/shared/social.d.ts.map +1 -1
- package/dist/shared/social.js +6 -1
- package/dist/shared/social.js.map +1 -1
- package/dist/shared/src.js +4563 -667
- package/dist/shared/src.js.map +1 -1
- package/dist/shared/svg-markup.js +2354 -0
- package/dist/shared/svg-markup.js.map +1 -0
- package/dist/shared/svg.js +27 -0
- package/dist/shared/svg.js.map +1 -0
- package/dist/shared/types.d.ts +174 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/shared/wire-types.d.ts +6 -2
- package/dist/shared/wire-types.d.ts.map +1 -1
- package/dist/social.d.ts +5 -5
- package/dist/social.js +2 -2
- package/dist/turns-rules.d.ts +2 -0
- package/dist/turns-rules.js +1 -0
- package/dist/turns.d.ts +61 -0
- package/dist/turns.d.ts.map +1 -0
- package/dist/turns.js +183 -0
- package/dist/turns.js.map +1 -0
- package/package.json +125 -89
- package/src/auto.ts +39 -39
- package/src/avatar/adapters/pixi-avatar.ts +73 -0
- package/src/avatar/adapters/pixi-crowd.ts +329 -0
- package/src/avatar/adapters/pixi-types.ts +84 -0
- package/src/avatar/adapters/three.ts +126 -0
- package/src/avatar/animator/animator.ts +285 -0
- package/src/avatar/animator/reactions.ts +44 -0
- package/src/avatar/animator/schedule.ts +73 -0
- package/src/avatar/art/beard.ts +49 -0
- package/src/avatar/art/glasses.ts +67 -0
- package/src/avatar/art/hair.ts +254 -0
- package/src/avatar/art/headwear.ts +75 -0
- package/src/avatar/art/index.ts +21 -0
- package/src/avatar/art/shape.ts +87 -0
- package/src/avatar/art-store.ts +36 -0
- package/src/avatar/atlas.ts +246 -0
- package/src/avatar/catalog-view.ts +14 -0
- package/src/avatar/colour.ts +109 -0
- package/src/avatar/describe.ts +79 -0
- package/src/avatar/face.ts +187 -0
- package/src/avatar/geom.ts +173 -0
- package/src/avatar/layout.ts +47 -0
- package/src/avatar/look-like.ts +52 -0
- package/src/avatar/outputs/bitmap.ts +75 -0
- package/src/avatar/outputs/canvas-paint.ts +177 -0
- package/src/avatar/outputs/canvas.ts +62 -0
- package/src/avatar/outputs/custom-element.ts +140 -0
- package/src/avatar/outputs/element.ts +58 -0
- package/src/avatar/outputs/live-canvas.ts +57 -0
- package/src/avatar/outputs/overlay-svg.ts +22 -0
- package/src/avatar/outputs/pose-key.ts +30 -0
- package/src/avatar/outputs/svg-markup.ts +113 -0
- package/src/avatar/outputs/svg.ts +41 -0
- package/src/avatar/overlays.ts +128 -0
- package/src/avatar/render/body.ts +116 -0
- package/src/avatar/render/colours.ts +81 -0
- package/src/avatar/render/compose.ts +320 -0
- package/src/avatar/render/context.ts +117 -0
- package/src/avatar/render/eyes.ts +78 -0
- package/src/avatar/render/eyewear.ts +118 -0
- package/src/avatar/render/frame.ts +73 -0
- package/src/avatar/render/head.ts +81 -0
- package/src/avatar/render/mouth.ts +86 -0
- package/src/avatar/render/props.ts +91 -0
- package/src/avatar/render/types.ts +93 -0
- package/src/avatar/render/walker-parts.ts +121 -0
- package/src/avatar/render/walker.ts +199 -0
- package/src/avatar/scene.ts +119 -0
- package/src/avatar-pixi.ts +20 -0
- package/src/avatar-svg.ts +21 -0
- package/src/avatar-three.ts +18 -0
- package/src/avatar.ts +63 -0
- package/src/core/avatar/avatar-cache.ts +95 -0
- package/src/core/avatar/avatar-ref.ts +34 -0
- package/src/core/avatar/avatar-types.ts +43 -0
- package/src/core/avatar/hub-avatar.ts +308 -0
- package/src/core/connection.ts +408 -299
- package/src/core/errors.ts +160 -147
- package/src/core/game-data.ts +40 -38
- package/src/core/hub-economy.ts +216 -179
- package/src/core/hub-progress.ts +269 -205
- package/src/core/hub.ts +352 -131
- package/src/core/menu-control.ts +61 -61
- package/src/core/offline/cloud-saves.ts +62 -0
- package/src/core/offline/events.ts +50 -50
- package/src/core/offline/favorites.ts +21 -21
- package/src/core/offline/hub-kinds/avatar-kind.ts +42 -0
- package/src/core/offline/hub-kinds/daily-kind.ts +68 -0
- package/src/core/offline/hub-kinds/economy-kinds.ts +86 -0
- package/src/core/offline/hub-kinds/game-fields.ts +2 -0
- package/src/core/offline/hub-kinds/index.ts +52 -0
- package/src/core/offline/hub-kinds/legacy-ops.ts +37 -0
- package/src/core/offline/hub-kinds/progress-kinds.ts +165 -0
- package/src/core/offline/hub-kinds/resume-kind.ts +108 -0
- package/src/core/offline/hub-kinds/saved-kind.ts +93 -0
- package/src/core/offline/kind-adapters.ts +48 -0
- package/src/core/offline/local-inventory.ts +64 -0
- package/src/core/offline/local-store.ts +304 -261
- package/src/core/offline/offline-api.ts +65 -0
- package/src/core/offline/outbox-entry.ts +59 -0
- package/src/core/offline/outbox-kinds.ts +78 -0
- package/src/core/offline/outbox-merge.ts +39 -0
- package/src/core/offline/outbox-replayer.ts +141 -0
- package/src/core/offline/outbox-store.ts +85 -0
- package/src/core/offline/outbox.ts +232 -0
- package/src/core/offline/queue.ts +126 -171
- package/src/core/offline/replay-lease.ts +87 -0
- package/src/core/offline/replay-policy.ts +52 -0
- package/src/core/page-account.ts +121 -0
- package/src/core/page.ts +87 -74
- package/src/core/rest/endpoint-types.ts +22 -22
- package/src/core/rest/rest-client.ts +163 -152
- package/src/core/rest/routes.gen.ts +144 -88
- package/src/core/resume/hub-resume.ts +286 -0
- package/src/core/resume/local-resume.ts +130 -0
- package/src/core/resume/resume-list.ts +65 -0
- package/src/core/resume/resume-request.ts +52 -0
- package/src/core/resume/resume-types.ts +64 -0
- package/src/core/saved/daily-saves.ts +196 -0
- package/src/core/saved/daily-seed.ts +16 -0
- package/src/core/saved/hub-saved.ts +221 -0
- package/src/core/saved/local-saved.ts +122 -0
- package/src/core/saved/saved-list.ts +129 -0
- package/src/core/saved/saved-open.ts +104 -0
- package/src/core/saved/saved-order.ts +49 -0
- package/src/core/saved/saved-types.ts +24 -0
- package/src/core/storage/json-guards.ts +80 -74
- package/src/core/storage/key-value.ts +66 -66
- package/src/core/ticket.ts +43 -43
- package/src/core/turns/hub-turns.ts +161 -0
- package/src/core/turns/turn-game.ts +510 -0
- package/src/core/turns/your-turn-buzz.ts +69 -0
- package/src/core/types.ts +134 -134
- package/src/core/version/client-version.gen.ts +2 -2
- package/src/core/version/hub-version.ts +62 -62
- package/src/core/version/semver.ts +97 -97
- package/src/daily/daily-system.ts +276 -161
- package/src/daily/page-daily.ts +36 -36
- package/src/daily/types.ts +105 -67
- package/src/daily.ts +10 -10
- package/src/doctor/checks.ts +86 -76
- package/src/doctor/cli.ts +101 -106
- package/src/doctor/hub-version-fetch.ts +43 -0
- package/src/doctor/tsconfig.json +12 -12
- package/src/doctor/world-check.ts +61 -0
- package/src/globals.ts +115 -110
- package/src/index.ts +68 -50
- package/src/input/device-state.ts +146 -146
- package/src/input/edges.ts +55 -55
- package/src/input/focus-ring.ts +13 -0
- package/src/input/gamepad.ts +61 -61
- package/src/input/input-events.ts +32 -32
- package/src/input/input-registry.ts +171 -164
- package/src/input/input-system.ts +406 -393
- package/src/input/legacy-controls.ts +64 -64
- package/src/input/modality.ts +122 -0
- package/src/input/nav-state.ts +200 -191
- package/src/input/navigator.ts +182 -171
- package/src/input/on-screen-controls.ts +101 -101
- package/src/input/page-control.ts +43 -43
- package/src/input/page-input.ts +193 -193
- package/src/input/player-remaps.ts +77 -77
- package/src/input/source-values.ts +116 -116
- package/src/input/spatial.ts +47 -47
- package/src/input/types.ts +331 -331
- package/src/input/zero-config.ts +164 -177
- package/src/input.ts +26 -22
- package/src/mp.ts +10 -10
- package/src/overlay/controls-panel.ts +163 -163
- package/src/overlay/dom.ts +46 -46
- package/src/overlay/layout-clusters.ts +162 -162
- package/src/overlay/layout-geometry.ts +136 -136
- package/src/overlay/layout.ts +203 -203
- package/src/overlay/listener-scope.ts +23 -23
- package/src/overlay/on-screen-keyboard.ts +137 -137
- package/src/overlay/overlay.ts +234 -208
- package/src/overlay/styles.ts +13 -12
- package/src/overlay/virtual-controls.ts +262 -262
- package/src/race.ts +10 -10
- package/src/realtime/bridge.ts +126 -121
- package/src/realtime/cross-bundle.ts +29 -29
- package/src/realtime/fallback-notice.ts +48 -48
- package/src/realtime/hub-link.ts +65 -65
- package/src/realtime/latest-state.ts +33 -33
- package/src/realtime/multiplayer-forwarder.ts +40 -28
- package/src/realtime/multiplayer-types.ts +66 -52
- package/src/realtime/page-runtime.ts +60 -60
- package/src/realtime/race-forwarder.ts +59 -59
- package/src/realtime/race-types.ts +132 -132
- package/src/realtime/room-types.ts +151 -125
- package/src/realtime/rooms-forwarder.ts +104 -79
- package/src/realtime/runtime-api.ts +74 -49
- package/src/realtime/runtime-opener.ts +61 -61
- package/src/realtime/social-forwarder.ts +77 -72
- package/src/realtime/social-types.ts +86 -78
- package/src/realtime/types.ts +54 -53
- package/src/realtime/wire-types.ts +79 -72
- package/src/realtime/world-types.ts +20 -0
- package/src/rooms.ts +10 -10
- package/src/social.ts +19 -18
- package/src/turns-kit/check-rules.ts +281 -0
- package/src/turns-rules.ts +12 -0
- package/src/turns.ts +14 -0
- package/src/types.ts +13 -13
- package/src/uistack/page-stack.ts +45 -45
- package/src/uistack/play-suspension.ts +82 -82
- package/src/uistack/types.ts +76 -76
- package/src/uistack/ui-stack.ts +144 -144
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One operation in the offline outbox: a `kind` (whose registered handler
|
|
3
|
+
* performs it), its JSON payload, and its replay bookkeeping. Each entry is
|
|
4
|
+
* stored under its own key, so two tabs adding and removing entries never
|
|
5
|
+
* overwrite each other's lists.
|
|
6
|
+
*/
|
|
7
|
+
import type { Json } from '@diffenderfer-games/hub-contract';
|
|
8
|
+
import { isJson, isRecord, optionalNumber, optionalString } from '../storage/json-guards';
|
|
9
|
+
|
|
10
|
+
/** A queued operation. */
|
|
11
|
+
export interface OutboxEntry {
|
|
12
|
+
/** Unique and sortable by queue time: replay runs in `id` order. Also the base of its operation keys. */
|
|
13
|
+
readonly id: string;
|
|
14
|
+
/** Which handler performs it (`hub.save`, `hub.stat`, or a game's own kind). */
|
|
15
|
+
readonly kind: string;
|
|
16
|
+
/** The handler's input. */
|
|
17
|
+
readonly payload: Json;
|
|
18
|
+
/** When it was queued (epoch ms). */
|
|
19
|
+
readonly createdAt: number;
|
|
20
|
+
/** How many times replay has started it. A started entry never absorbs a later one. */
|
|
21
|
+
readonly attempts: number;
|
|
22
|
+
/** Not before this time (epoch ms); 0 means as soon as possible. */
|
|
23
|
+
readonly retryAt: number;
|
|
24
|
+
/** The last failure's message, when it has failed before. */
|
|
25
|
+
readonly lastError?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** How wide the time part of an id is (enough for epoch ms until the year 5138). */
|
|
29
|
+
const TIME_DIGITS = 14;
|
|
30
|
+
|
|
31
|
+
/** How wide the sequence part of an id is. */
|
|
32
|
+
const SEQUENCE_DIGITS = 6;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A new entry id: the queue time, a per-page sequence and a random tag, so
|
|
36
|
+
* ids sort by time and two tabs queueing in the same millisecond never clash.
|
|
37
|
+
*/
|
|
38
|
+
export function newEntryId(now: number, sequence: number): string {
|
|
39
|
+
const tag = Math.floor(Math.random() * 36 ** 6).toString(36).padStart(6, '0');
|
|
40
|
+
return `${String(now).padStart(TIME_DIGITS, '0')}-${String(sequence % 10 ** SEQUENCE_DIGITS).padStart(SEQUENCE_DIGITS, '0')}-${tag}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A stored entry, or `undefined` when it is malformed. */
|
|
44
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: an entry read back from localStorage
|
|
45
|
+
export function readEntry(stored: unknown): OutboxEntry | undefined {
|
|
46
|
+
if (!isRecord(stored) || !isJson(stored.payload)) {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
const id = optionalString(stored.id);
|
|
50
|
+
const kind = optionalString(stored.kind);
|
|
51
|
+
const createdAt = optionalNumber(stored.createdAt);
|
|
52
|
+
const attempts = optionalNumber(stored.attempts);
|
|
53
|
+
const retryAt = optionalNumber(stored.retryAt);
|
|
54
|
+
const lastError = optionalString(stored.lastError);
|
|
55
|
+
if (id === undefined || kind === undefined || createdAt === undefined || attempts === undefined || retryAt === undefined) {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
return { id, kind, payload: stored.payload, createdAt, attempts, retryAt, ...(lastError === undefined ? {} : { lastError }) };
|
|
59
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbox kinds: what each sort of queued operation does when it replays,
|
|
3
|
+
* and how a new one folds into those already waiting. The hub registers its
|
|
4
|
+
* own (`hub.*`); a game adds its own with `hub.offline.define`.
|
|
5
|
+
*/
|
|
6
|
+
import type { Json } from '@diffenderfer-games/hub-contract';
|
|
7
|
+
import type { EndpointName, EndpointReply } from '../rest/endpoint-types';
|
|
8
|
+
import type { CallArguments } from '../rest/rest-client';
|
|
9
|
+
|
|
10
|
+
/** What a kind's handler gets besides its payload. */
|
|
11
|
+
export interface ReplayContext {
|
|
12
|
+
/** The operation's id (stable across retries). */
|
|
13
|
+
readonly opId: string;
|
|
14
|
+
/** Which try this is, from 1. */
|
|
15
|
+
readonly attempt: number;
|
|
16
|
+
/** `false` when the op runs at once (online, nothing waiting); `true` when it runs from the queue. */
|
|
17
|
+
readonly isReplay: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Call a hub endpoint. Calls to endpoints marked for offline replay carry
|
|
20
|
+
* an operation key made from the op id and the call's position in the
|
|
21
|
+
* handler, so a retried op whose earlier calls already landed gets their
|
|
22
|
+
* remembered replies instead of running them twice. Make the same calls in
|
|
23
|
+
* the same order on every try.
|
|
24
|
+
*/
|
|
25
|
+
call<K extends EndpointName>(name: K, ...fieldsArgument: CallArguments<K>): Promise<EndpointReply<K>>;
|
|
26
|
+
/** A key for `step` of this op that is the same on every try: send it to a game server to deduplicate there. */
|
|
27
|
+
key(step: string): string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Performs one operation of a kind. Throw to fail it (see the docs for which failures retry). */
|
|
31
|
+
export type OfflineHandler<Payload> = (payload: Payload, context: ReplayContext) => Promise<Json | undefined> | Json | undefined;
|
|
32
|
+
|
|
33
|
+
/** A kind, with optional rules for folding new operations into waiting ones. */
|
|
34
|
+
export interface OfflineKindDefinition<Payload> {
|
|
35
|
+
/** Perform one operation. */
|
|
36
|
+
readonly run: OfflineHandler<Payload>;
|
|
37
|
+
/**
|
|
38
|
+
* Operations with the same merge key may fold together. Without one, every
|
|
39
|
+
* operation replays on its own.
|
|
40
|
+
*/
|
|
41
|
+
readonly mergeKey?: (payload: Payload) => string;
|
|
42
|
+
/**
|
|
43
|
+
* Fold `incoming` into the newest waiting operation with the same merge key
|
|
44
|
+
* (in its place in the queue); `undefined` to queue it separately.
|
|
45
|
+
*/
|
|
46
|
+
readonly merge?: (waiting: Payload, incoming: Payload) => Payload | undefined;
|
|
47
|
+
/** `true`: a new operation replaces every waiting one with the same merge key and goes last (a save slot's last write wins). */
|
|
48
|
+
readonly replaces?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A kind as the outbox runs it: payloads are the stored JSON. */
|
|
52
|
+
export interface RegisteredKind {
|
|
53
|
+
/** Perform one operation (malformed payloads are refused before this). */
|
|
54
|
+
readonly run: (payload: Json, context: ReplayContext) => Promise<Json | undefined> | Json | undefined;
|
|
55
|
+
/** Whether a stored payload is well formed for this kind. */
|
|
56
|
+
readonly accepts: (payload: Json) => boolean;
|
|
57
|
+
readonly mergeKey?: (payload: Json) => string;
|
|
58
|
+
readonly merge?: (waiting: Json, incoming: Json) => Json | undefined;
|
|
59
|
+
readonly replaces: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The prefix reserved for the hub's own kinds. */
|
|
63
|
+
export const HUB_KIND_PREFIX = 'hub.';
|
|
64
|
+
|
|
65
|
+
/** The registered kinds of one client. */
|
|
66
|
+
export class KindRegistry {
|
|
67
|
+
private readonly kinds = new Map<string, RegisteredKind>();
|
|
68
|
+
|
|
69
|
+
/** Register (or replace) a kind. */
|
|
70
|
+
register(kind: string, definition: RegisteredKind): void {
|
|
71
|
+
this.kinds.set(kind, definition);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A registered kind, or `undefined`. */
|
|
75
|
+
get(kind: string): RegisteredKind | undefined {
|
|
76
|
+
return this.kinds.get(kind);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a new operation joins the outbox (pure, so it is easy to test): queued
|
|
3
|
+
* on its own, folded into the newest waiting one with the same merge key, or
|
|
4
|
+
* replacing every waiting one with that key. An entry replay has started is
|
|
5
|
+
* never changed or replaced: its request may already have reached the hub
|
|
6
|
+
* under its operation key, so a changed payload would be lost.
|
|
7
|
+
*/
|
|
8
|
+
import type { OutboxEntry } from './outbox-entry';
|
|
9
|
+
import type { RegisteredKind } from './outbox-kinds';
|
|
10
|
+
|
|
11
|
+
/** The store changes one enqueue makes. */
|
|
12
|
+
export interface EnqueuePlan {
|
|
13
|
+
/** Entries to write (the new one, or a waiting one with a merged payload). */
|
|
14
|
+
readonly put: OutboxEntry;
|
|
15
|
+
/** Entries to remove (waiting ones the new one replaces). */
|
|
16
|
+
readonly remove: readonly string[];
|
|
17
|
+
/** `true` when `put` is a waiting entry that absorbed the new one. */
|
|
18
|
+
readonly isMerged: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** How `incoming` (of kind `kind`) joins `entries` (oldest first). */
|
|
22
|
+
export function planEnqueue(entries: readonly OutboxEntry[], incoming: OutboxEntry, kind: RegisteredKind): EnqueuePlan {
|
|
23
|
+
const separately: EnqueuePlan = { put: incoming, remove: [], isMerged: false };
|
|
24
|
+
const { mergeKey } = kind;
|
|
25
|
+
if (mergeKey === undefined) {
|
|
26
|
+
return separately;
|
|
27
|
+
}
|
|
28
|
+
const key = mergeKey(incoming.payload);
|
|
29
|
+
const sameKey = entries.filter((entry) => entry.kind === incoming.kind && kind.accepts(entry.payload) && mergeKey(entry.payload) === key);
|
|
30
|
+
if (kind.replaces) {
|
|
31
|
+
return { put: incoming, remove: sameKey.filter((entry) => entry.attempts === 0).map((entry) => entry.id), isMerged: false };
|
|
32
|
+
}
|
|
33
|
+
const newest = sameKey.at(-1);
|
|
34
|
+
if (kind.merge === undefined || newest === undefined || newest.attempts > 0) {
|
|
35
|
+
return separately;
|
|
36
|
+
}
|
|
37
|
+
const merged = kind.merge(newest.payload, incoming.payload);
|
|
38
|
+
return merged === undefined ? separately : { put: { ...newest, payload: merged }, remove: [], isMerged: true };
|
|
39
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replays the outbox in order, holding the game's replay lease so only one
|
|
3
|
+
* tab (or client copy) sends at a time. Each entry is marked started before
|
|
4
|
+
* its handler runs, then removed on success or on a permanent failure; a
|
|
5
|
+
* passing failure leaves it at the head with a backoff, so nothing behind it
|
|
6
|
+
* jumps the queue. An entry whose kind isn't defined here stops this
|
|
7
|
+
* replayer (and lets go of the lease) so a client that knows the kind, such
|
|
8
|
+
* as the game's own copy on the page, can carry on.
|
|
9
|
+
*/
|
|
10
|
+
import type { Json } from '@diffenderfer-games/hub-contract';
|
|
11
|
+
import type { OutboxEntry } from './outbox-entry';
|
|
12
|
+
import type { KindRegistry, ReplayContext } from './outbox-kinds';
|
|
13
|
+
import type { OutboxStore } from './outbox-store';
|
|
14
|
+
import type { ReplayLease } from './replay-lease';
|
|
15
|
+
import { backoffMs, failureMessage, MAX_RETRIES, verdictFor } from './replay-policy';
|
|
16
|
+
|
|
17
|
+
/** An op dropped during replay, with why. */
|
|
18
|
+
export interface ReplayFailure {
|
|
19
|
+
/** The dropped op. */
|
|
20
|
+
readonly entry: OutboxEntry;
|
|
21
|
+
/** Why it was dropped (the hub's message, or the handler's). */
|
|
22
|
+
readonly error: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Why a replay stopped before the outbox was empty. */
|
|
26
|
+
export type ReplayStop = 'connection' | 'backoff' | 'unknown-kind' | 'lease';
|
|
27
|
+
|
|
28
|
+
/** What one replay did. */
|
|
29
|
+
export interface ReplayReport {
|
|
30
|
+
/** The ops that landed, in order. */
|
|
31
|
+
readonly replayed: OutboxEntry[];
|
|
32
|
+
/** The ops dropped. */
|
|
33
|
+
readonly failed: ReplayFailure[];
|
|
34
|
+
/** Why it stopped early, or `undefined` when the outbox is empty. */
|
|
35
|
+
readonly stoppedBy?: ReplayStop;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** What the replayer works with. */
|
|
39
|
+
export interface ReplayerParts {
|
|
40
|
+
readonly store: OutboxStore;
|
|
41
|
+
readonly kinds: KindRegistry;
|
|
42
|
+
readonly lease: ReplayLease;
|
|
43
|
+
/** The context an entry's handler gets on try `attempt`. */
|
|
44
|
+
readonly contextFor: (entry: OutboxEntry, attempt: number) => ReplayContext;
|
|
45
|
+
/** An op landed (`result` is what its handler returned). */
|
|
46
|
+
readonly onReplayed: (entry: OutboxEntry, result: Json | undefined) => void;
|
|
47
|
+
/** An op was dropped. */
|
|
48
|
+
readonly onFailed: (failure: ReplayFailure) => void;
|
|
49
|
+
/** The hub couldn't be reached. */
|
|
50
|
+
readonly onUnreachable: () => void;
|
|
51
|
+
/** Ask for another replay in `delayMs`. */
|
|
52
|
+
readonly scheduleRetry: (delayMs: number) => void;
|
|
53
|
+
readonly now: () => number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A report for a replay that didn't get the lease. */
|
|
57
|
+
const LEASE_HELD: ReplayReport = { replayed: [], failed: [], stoppedBy: 'lease' };
|
|
58
|
+
|
|
59
|
+
/** Replays one game's outbox. */
|
|
60
|
+
export class OutboxReplayer {
|
|
61
|
+
private readonly parts: ReplayerParts;
|
|
62
|
+
|
|
63
|
+
/** A replayer over `parts`. */
|
|
64
|
+
constructor(parts: ReplayerParts) {
|
|
65
|
+
this.parts = parts;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Replay everything due, in order. `isEager` skips the backoff of ops waiting for the connection (it just came back). */
|
|
69
|
+
async replay(isEager = false): Promise<ReplayReport> {
|
|
70
|
+
let report: ReplayReport = LEASE_HELD;
|
|
71
|
+
const isHeld = await this.parts.lease.hold(async () => {
|
|
72
|
+
report = await this.drain(isEager);
|
|
73
|
+
});
|
|
74
|
+
return isHeld ? report : LEASE_HELD;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private async drain(isEager: boolean): Promise<ReplayReport> {
|
|
78
|
+
const { store, kinds, now } = this.parts;
|
|
79
|
+
const replayed: OutboxEntry[] = [];
|
|
80
|
+
const failed: ReplayFailure[] = [];
|
|
81
|
+
for (let head = store.list()[0]; head !== undefined; head = store.list()[0]) {
|
|
82
|
+
if (head.retryAt > now() && !(isEager && head.lastError === undefined)) {
|
|
83
|
+
this.parts.scheduleRetry(head.retryAt - now());
|
|
84
|
+
return { replayed, failed, stoppedBy: 'backoff' };
|
|
85
|
+
}
|
|
86
|
+
const kind = kinds.get(head.kind);
|
|
87
|
+
if (kind === undefined) {
|
|
88
|
+
return { replayed, failed, stoppedBy: 'unknown-kind' };
|
|
89
|
+
}
|
|
90
|
+
if (!kind.accepts(head.payload)) {
|
|
91
|
+
store.remove(head.id);
|
|
92
|
+
this.drop(failed, head, 'Malformed offline operation payload.');
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const started: OutboxEntry = { ...head, attempts: head.attempts + 1 };
|
|
96
|
+
store.put(started);
|
|
97
|
+
try {
|
|
98
|
+
const result = await kind.run(head.payload, this.parts.contextFor(head, started.attempts));
|
|
99
|
+
store.remove(head.id);
|
|
100
|
+
replayed.push(started);
|
|
101
|
+
this.parts.onReplayed(started, result ?? undefined);
|
|
102
|
+
} catch (failure) {
|
|
103
|
+
const stop = this.settleFailure(started, failure, failed);
|
|
104
|
+
if (stop !== undefined) {
|
|
105
|
+
return { replayed, failed, stoppedBy: stop };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { replayed, failed };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Record a failed try; answers why replay stops, or `undefined` to carry on with the next op. */
|
|
113
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: whatever a replay handler threw
|
|
114
|
+
private settleFailure(started: OutboxEntry, failure: unknown, failed: ReplayFailure[]): ReplayStop | undefined {
|
|
115
|
+
const { store, now } = this.parts;
|
|
116
|
+
const verdict = verdictFor(failure);
|
|
117
|
+
const message = failureMessage(failure);
|
|
118
|
+
if (verdict === 'drop' || (verdict === 'retry' && started.attempts >= MAX_RETRIES)) {
|
|
119
|
+
store.remove(started.id);
|
|
120
|
+
this.drop(failed, started, message);
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
const delay = backoffMs(started.attempts);
|
|
124
|
+
// Waiting for the connection is not an error of the op's own: an eager replay (back online) retries it at once.
|
|
125
|
+
const { id, kind, payload, createdAt, attempts } = started;
|
|
126
|
+
const retrying: OutboxEntry = { id, kind, payload, createdAt, attempts, retryAt: now() + delay };
|
|
127
|
+
const waiting: OutboxEntry = verdict === 'offline' ? retrying : { ...retrying, lastError: message };
|
|
128
|
+
store.put(waiting);
|
|
129
|
+
if (verdict === 'offline') {
|
|
130
|
+
this.parts.onUnreachable();
|
|
131
|
+
}
|
|
132
|
+
this.parts.scheduleRetry(delay);
|
|
133
|
+
return verdict === 'offline' ? 'connection' : 'backoff';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private drop(failed: ReplayFailure[], entry: OutboxEntry, error: string): void {
|
|
137
|
+
const failure = { entry, error };
|
|
138
|
+
failed.push(failure);
|
|
139
|
+
this.parts.onFailed(failure);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the outbox lives: one `localStorage` key per entry,
|
|
3
|
+
* `hub:<slug>:outbox:<id>`, read back in id (queue) order. A tab adding an
|
|
4
|
+
* entry and another removing one touch different keys, so neither loses the
|
|
5
|
+
* other's change. The outbox is capped by entry count and stored size.
|
|
6
|
+
*/
|
|
7
|
+
import { parseJsonText } from '../storage/json-guards';
|
|
8
|
+
import type { KeyValueStore } from '../storage/key-value';
|
|
9
|
+
import { readEntry, type OutboxEntry } from './outbox-entry';
|
|
10
|
+
|
|
11
|
+
/** The most entries one game's outbox holds. */
|
|
12
|
+
export const OUTBOX_MAX_ENTRIES = 500;
|
|
13
|
+
|
|
14
|
+
/** The most stored characters one game's outbox holds (about 2 MB of `localStorage`). */
|
|
15
|
+
export const OUTBOX_MAX_CHARS = 1_000_000;
|
|
16
|
+
|
|
17
|
+
/** One game's outbox. */
|
|
18
|
+
export class OutboxStore {
|
|
19
|
+
/** The key prefix of this game's entries. */
|
|
20
|
+
readonly prefix: string;
|
|
21
|
+
private readonly store: KeyValueStore;
|
|
22
|
+
|
|
23
|
+
/** The outbox for game `slug` in `store`. */
|
|
24
|
+
constructor(slug: string, store: KeyValueStore) {
|
|
25
|
+
this.prefix = `hub:${slug}:outbox:`;
|
|
26
|
+
this.store = store;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Every well-formed entry, oldest first. Malformed ones are removed. */
|
|
30
|
+
list(): OutboxEntry[] {
|
|
31
|
+
const entries: OutboxEntry[] = [];
|
|
32
|
+
for (const key of this.entryKeys()) {
|
|
33
|
+
const text = this.store.read(key);
|
|
34
|
+
const entry = text === null ? undefined : readEntry(parseJsonText(text));
|
|
35
|
+
if (entry === undefined) {
|
|
36
|
+
this.store.remove(key);
|
|
37
|
+
} else {
|
|
38
|
+
entries.push(entry);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return entries;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** How many entries are queued. */
|
|
45
|
+
count(): number {
|
|
46
|
+
return this.entryKeys().length;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** One entry, or `undefined` once it is gone (replayed, or removed by another tab). */
|
|
50
|
+
get(id: string): OutboxEntry | undefined {
|
|
51
|
+
const text = this.store.read(this.prefix + id);
|
|
52
|
+
return text === null ? undefined : readEntry(parseJsonText(text));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Whether `extra` more stored characters in one more entry still fit under the caps. */
|
|
56
|
+
fits(extraChars: number): boolean {
|
|
57
|
+
const keys = this.entryKeys();
|
|
58
|
+
if (keys.length >= OUTBOX_MAX_ENTRIES) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
const used = keys.reduce((total, key) => total + (this.store.read(key)?.length ?? 0), 0);
|
|
62
|
+
return used + extraChars <= OUTBOX_MAX_CHARS;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Store an entry (new or updated); `false` when the storage refused it (full or blocked). */
|
|
66
|
+
put(entry: OutboxEntry): boolean {
|
|
67
|
+
const text = JSON.stringify(entry);
|
|
68
|
+
this.store.write(this.prefix + entry.id, text);
|
|
69
|
+
return this.store.read(this.prefix + entry.id) === text;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Forget an entry. */
|
|
73
|
+
remove(id: string): void {
|
|
74
|
+
this.store.remove(this.prefix + id);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Whether a storage key belongs to this outbox (for cross-tab `storage` events). */
|
|
78
|
+
owns(key: string | null): boolean {
|
|
79
|
+
return key === null || key.startsWith(this.prefix);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private entryKeys(): string[] {
|
|
83
|
+
return this.store.keys().filter((key) => key.startsWith(this.prefix)).sort();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The offline operation outbox: typed operations queued in `localStorage`
|
|
3
|
+
* in order, each performed by its kind's handler, replayed in order (one tab
|
|
4
|
+
* at a time) when the hub is reachable, and reported as `queued`,
|
|
5
|
+
* `replayed` and `failed` events, to listeners and as `hub:offline-*`
|
|
6
|
+
* window events.
|
|
7
|
+
*/
|
|
8
|
+
import type { Json } from '@diffenderfer-games/hub-contract';
|
|
9
|
+
import { HubError } from '../errors';
|
|
10
|
+
import type { RestClient } from '../rest/rest-client';
|
|
11
|
+
import type { KeyValueStore } from '../storage/key-value';
|
|
12
|
+
import { newEntryId, type OutboxEntry } from './outbox-entry';
|
|
13
|
+
import { KindRegistry, type RegisteredKind, type ReplayContext } from './outbox-kinds';
|
|
14
|
+
import { planEnqueue } from './outbox-merge';
|
|
15
|
+
import { OutboxReplayer, type ReplayReport } from './outbox-replayer';
|
|
16
|
+
import { OutboxStore } from './outbox-store';
|
|
17
|
+
import { createReplayLease } from './replay-lease';
|
|
18
|
+
import { backoffMs } from './replay-policy';
|
|
19
|
+
|
|
20
|
+
/** A queued operation, as `hub.offline` reports it. */
|
|
21
|
+
export type OfflineOp = OutboxEntry;
|
|
22
|
+
|
|
23
|
+
/** Each outbox event's detail. */
|
|
24
|
+
export interface OfflineEventDetails {
|
|
25
|
+
/** An op was queued (or folded into a waiting one, which is then the `op`). */
|
|
26
|
+
queued: { readonly op: OfflineOp };
|
|
27
|
+
/** An op landed; `result` is what its handler returned. */
|
|
28
|
+
replayed: { readonly op: OfflineOp; readonly result?: Json };
|
|
29
|
+
/** An op was dropped: the hub refused it, or it kept failing. */
|
|
30
|
+
failed: { readonly op: OfflineOp; readonly error: string };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** An outbox event's name. */
|
|
34
|
+
export type OfflineEventName = keyof OfflineEventDetails;
|
|
35
|
+
|
|
36
|
+
/** An outbox event listener. */
|
|
37
|
+
export type OfflineListener<Name extends OfflineEventName> = (detail: OfflineEventDetails[Name]) => void;
|
|
38
|
+
|
|
39
|
+
/** The code of the error `add` throws when the outbox is full. */
|
|
40
|
+
export const OUTBOX_FULL_CODE = 'offline_queue_full';
|
|
41
|
+
|
|
42
|
+
/** How long a replayer that lost the lease waits before checking again. */
|
|
43
|
+
const LEASE_RECHECK_MS = 3000;
|
|
44
|
+
|
|
45
|
+
/** What the outbox needs from its client. */
|
|
46
|
+
export interface OutboxOptions {
|
|
47
|
+
/** The game whose outbox it is. */
|
|
48
|
+
readonly slug: string;
|
|
49
|
+
/** Where it lives (`localStorage`, or memory). */
|
|
50
|
+
readonly store: KeyValueStore;
|
|
51
|
+
/** The REST client its handlers call through. */
|
|
52
|
+
readonly rest: RestClient;
|
|
53
|
+
/** Fire a window event (the menu listens). */
|
|
54
|
+
readonly emit: (name: string, detail: OfflineEventDetails[OfflineEventName]) => void;
|
|
55
|
+
/** The hub couldn't be reached during a replay. */
|
|
56
|
+
readonly onUnreachable: () => void;
|
|
57
|
+
/** The queue length may have changed. */
|
|
58
|
+
readonly onChange: () => void;
|
|
59
|
+
/** A retry is due: replay again. */
|
|
60
|
+
readonly onRetryDue: () => void;
|
|
61
|
+
readonly now?: () => number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One game's outbox. */
|
|
65
|
+
export class Outbox {
|
|
66
|
+
/** The kinds this client can perform. */
|
|
67
|
+
readonly kinds = new KindRegistry();
|
|
68
|
+
/** The stored entries. */
|
|
69
|
+
readonly entries: OutboxStore;
|
|
70
|
+
private readonly options: OutboxOptions;
|
|
71
|
+
private readonly replayer: OutboxReplayer;
|
|
72
|
+
private readonly now: () => number;
|
|
73
|
+
private sequence = 0;
|
|
74
|
+
private retryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
75
|
+
private retryAt = Number.POSITIVE_INFINITY;
|
|
76
|
+
private readonly listeners: { readonly [Name in OfflineEventName]: Set<OfflineListener<Name>> } = {
|
|
77
|
+
queued: new Set(), replayed: new Set(), failed: new Set(),
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** The outbox for `options.slug`. */
|
|
81
|
+
constructor(options: OutboxOptions) {
|
|
82
|
+
this.options = options;
|
|
83
|
+
this.now = options.now ?? Date.now;
|
|
84
|
+
this.entries = new OutboxStore(options.slug, options.store);
|
|
85
|
+
this.replayer = new OutboxReplayer({
|
|
86
|
+
store: this.entries,
|
|
87
|
+
kinds: this.kinds,
|
|
88
|
+
lease: createReplayLease(options.slug, options.store),
|
|
89
|
+
contextFor: (entry, attempt) => this.contextFor(entry, attempt, true),
|
|
90
|
+
onReplayed: (op, result) => {
|
|
91
|
+
this.fire('replayed', result === undefined ? { op } : { op, result });
|
|
92
|
+
},
|
|
93
|
+
onFailed: ({ entry, error }) => {
|
|
94
|
+
this.fire('failed', { op: entry, error });
|
|
95
|
+
},
|
|
96
|
+
onUnreachable: options.onUnreachable,
|
|
97
|
+
scheduleRetry: (delayMs) => {
|
|
98
|
+
this.scheduleRetry(delayMs);
|
|
99
|
+
},
|
|
100
|
+
now: this.now,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Register a kind. */
|
|
105
|
+
define(kind: string, definition: RegisteredKind): void {
|
|
106
|
+
this.kinds.register(kind, definition);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A new, not yet stored, op of `kind`. */
|
|
110
|
+
stamp(kind: string, payload: Json): OutboxEntry {
|
|
111
|
+
this.sequence += 1;
|
|
112
|
+
const now = this.now();
|
|
113
|
+
return { id: newEntryId(now, this.sequence), kind, payload, createdAt: now, attempts: 0, retryAt: 0 };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Queue an op, folding it into a waiting one where its kind says so.
|
|
118
|
+
* Throws a {@link HubError} (code {@link OUTBOX_FULL_CODE}) when the
|
|
119
|
+
* outbox is full or the storage refuses it.
|
|
120
|
+
*/
|
|
121
|
+
add(op: OutboxEntry): void {
|
|
122
|
+
const kind = this.kinds.get(op.kind);
|
|
123
|
+
const plan = kind === undefined ? { put: op, remove: [], isMerged: false } : planEnqueue(this.entries.list(), op, kind);
|
|
124
|
+
const extra = plan.isMerged ? 0 : JSON.stringify(plan.put).length;
|
|
125
|
+
if (plan.remove.length === 0 && !plan.isMerged && !this.entries.fits(extra)) {
|
|
126
|
+
throw outboxFull();
|
|
127
|
+
}
|
|
128
|
+
if (!this.entries.put(plan.put)) {
|
|
129
|
+
throw outboxFull();
|
|
130
|
+
}
|
|
131
|
+
for (const id of plan.remove) {
|
|
132
|
+
this.entries.remove(id);
|
|
133
|
+
}
|
|
134
|
+
this.fire('queued', { op: plan.put });
|
|
135
|
+
this.options.onChange();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Perform an op now, without queueing it (the online path); throws what its handler throws. */
|
|
139
|
+
async runNow(op: OutboxEntry): Promise<Json | undefined> {
|
|
140
|
+
const kind = this.kinds.get(op.kind);
|
|
141
|
+
if (kind === undefined) {
|
|
142
|
+
throw new HubError(`No offline kind "${op.kind}" is defined.`);
|
|
143
|
+
}
|
|
144
|
+
return (await kind.run(op.payload, this.contextFor(op, 1, false))) ?? undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Replay what is due, in order. `isEager`: the connection just came back, so ops waiting for it go at once. */
|
|
148
|
+
async replay(isEager = false): Promise<ReplayReport> {
|
|
149
|
+
this.clearRetry();
|
|
150
|
+
const report = await this.replayer.replay(isEager);
|
|
151
|
+
if ((report.stoppedBy === 'lease' && this.entries.count() > 0) || report.stoppedBy === 'unknown-kind') {
|
|
152
|
+
// Another tab or client copy is replaying, or knows a kind this one doesn't: check back later.
|
|
153
|
+
this.scheduleRetry(report.stoppedBy === 'lease' ? LEASE_RECHECK_MS : backoffMs(this.entries.list()[0]?.attempts ?? 1));
|
|
154
|
+
}
|
|
155
|
+
this.options.onChange();
|
|
156
|
+
return report;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The waiting ops, oldest first. */
|
|
160
|
+
pending(): OfflineOp[] {
|
|
161
|
+
return this.entries.list();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** How many ops are waiting. */
|
|
165
|
+
count(): number {
|
|
166
|
+
return this.entries.count();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Listen for an event; returns the unsubscribe function. */
|
|
170
|
+
on<Name extends OfflineEventName>(name: Name, listener: OfflineListener<Name>): () => void {
|
|
171
|
+
const listeners: Set<OfflineListener<Name>> = this.listeners[name];
|
|
172
|
+
listeners.add(listener);
|
|
173
|
+
return () => {
|
|
174
|
+
listeners.delete(listener);
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private fire<Name extends OfflineEventName>(name: Name, detail: OfflineEventDetails[Name]): void {
|
|
179
|
+
const listeners: Set<OfflineListener<Name>> = this.listeners[name];
|
|
180
|
+
for (const listener of listeners) {
|
|
181
|
+
try {
|
|
182
|
+
listener(detail);
|
|
183
|
+
} catch {
|
|
184
|
+
// A listener's failure must not stop the others or the replay.
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
this.options.emit(`hub:offline-${name}`, detail);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private contextFor(op: OutboxEntry, attempt: number, isReplay: boolean): ReplayContext {
|
|
191
|
+
const { rest } = this.options;
|
|
192
|
+
let step = 0;
|
|
193
|
+
return {
|
|
194
|
+
opId: op.id,
|
|
195
|
+
attempt,
|
|
196
|
+
isReplay,
|
|
197
|
+
key: (stepName) => `${op.id}.${stepName}`,
|
|
198
|
+
call: (name, ...fieldsArgument) => {
|
|
199
|
+
const opKey = `${op.id}.${String(step)}`;
|
|
200
|
+
step += 1;
|
|
201
|
+
return rest.callOnce(opKey, name, ...fieldsArgument);
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private scheduleRetry(delayMs: number): void {
|
|
207
|
+
const at = this.now() + delayMs;
|
|
208
|
+
if (at >= this.retryAt) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
this.clearRetry();
|
|
212
|
+
this.retryAt = at;
|
|
213
|
+
this.retryTimer = setTimeout(() => {
|
|
214
|
+
this.retryTimer = undefined;
|
|
215
|
+
this.retryAt = Number.POSITIVE_INFINITY;
|
|
216
|
+
this.options.onRetryDue();
|
|
217
|
+
}, delayMs);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private clearRetry(): void {
|
|
221
|
+
if (this.retryTimer !== undefined) {
|
|
222
|
+
clearTimeout(this.retryTimer);
|
|
223
|
+
this.retryTimer = undefined;
|
|
224
|
+
}
|
|
225
|
+
this.retryAt = Number.POSITIVE_INFINITY;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The error for a full outbox. */
|
|
230
|
+
function outboxFull(): HubError {
|
|
231
|
+
return new HubError('Too many changes are waiting to sync. Reconnect to save more.', 507, { code: OUTBOX_FULL_CODE });
|
|
232
|
+
}
|