@diffenderfer-games/hub 2.0.0-next.1 → 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 +160 -6
- package/README.md +27 -15
- 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 +71 -10
- 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 +1 -39
- 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 +39 -3
- 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 +201 -92
- package/src/core/errors.ts +15 -2
- package/src/core/game-data.ts +11 -9
- package/src/core/hub-economy.ts +47 -10
- package/src/core/hub-progress.ts +94 -30
- package/src/core/hub.ts +228 -7
- package/src/core/offline/cloud-saves.ts +62 -0
- 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 +66 -23
- 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 +4 -49
- 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 +14 -1
- package/src/core/rest/rest-client.ts +14 -3
- 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 +6 -0
- 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/version/client-version.gen.ts +2 -2
- package/src/daily/daily-system.ts +127 -12
- package/src/daily/page-daily.ts +2 -2
- package/src/daily/types.ts +49 -11
- package/src/doctor/checks.ts +12 -4
- package/src/doctor/cli.ts +5 -2
- package/src/doctor/hub-version-fetch.ts +43 -43
- package/src/doctor/world-check.ts +61 -0
- package/src/index.ts +20 -2
- package/src/input/focus-ring.ts +13 -0
- package/src/input/input-registry.ts +9 -2
- package/src/input/input-system.ts +14 -1
- package/src/input/modality.ts +122 -0
- package/src/input/nav-state.ts +13 -4
- package/src/input/navigator.ts +11 -0
- package/src/input/zero-config.ts +1 -14
- package/src/input.ts +4 -0
- package/src/mp.ts +1 -1
- package/src/overlay/overlay.ts +30 -4
- package/src/overlay/styles.ts +4 -3
- package/src/realtime/bridge.ts +5 -0
- package/src/realtime/multiplayer-forwarder.ts +13 -1
- package/src/realtime/multiplayer-types.ts +15 -1
- package/src/realtime/room-types.ts +28 -2
- package/src/realtime/rooms-forwarder.ts +27 -2
- package/src/realtime/runtime-api.ts +33 -8
- package/src/realtime/social-forwarder.ts +5 -0
- package/src/realtime/social-types.ts +8 -0
- package/src/realtime/types.ts +1 -0
- package/src/realtime/wire-types.ts +7 -0
- package/src/realtime/world-types.ts +20 -0
- package/src/rooms.ts +1 -1
- package/src/social.ts +1 -0
- package/src/turns-kit/check-rules.ts +281 -0
- package/src/turns-rules.ts +12 -0
- package/src/turns.ts +14 -0
|
@@ -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
|
+
}
|
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The offline
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* merge), so the backlog stays small.
|
|
7
|
-
*
|
|
8
|
-
* The op shapes are the v1 client's, unchanged: a page upgraded from a
|
|
9
|
-
* vendored copy replays the queue that copy left in `localStorage`.
|
|
2
|
+
* The v1 client's offline queue (`hub:<slug>:queue`): its op shapes and the
|
|
3
|
+
* reader for what a vendored v1 copy left in `localStorage`. This client
|
|
4
|
+
* queues into the outbox instead (`outbox.ts`) and moves any v1 ops there on
|
|
5
|
+
* start (`hub-kinds/legacy-ops.ts`), so nothing a v1 copy queued is lost.
|
|
10
6
|
*/
|
|
11
7
|
import type { Json } from '@diffenderfer-games/hub-contract';
|
|
12
8
|
import type { SortDir } from '@diffenderfer-games/hub-contract';
|
|
@@ -128,44 +124,3 @@ export function readQueue(stored: unknown): QueueOp[] {
|
|
|
128
124
|
export function betterScore(first: number, second: number, sort: SortDir | undefined): number {
|
|
129
125
|
return sort === 'asc' ? Math.min(first, second) : Math.max(first, second);
|
|
130
126
|
}
|
|
131
|
-
|
|
132
|
-
/** The op that `queued` becomes when `incoming` collapses into it, or `undefined` when they don't collapse. */
|
|
133
|
-
function collapsedOp(queued: QueueOp, incoming: QueueOp): QueueOp | undefined {
|
|
134
|
-
if (queued.kind === 'incrStat' && incoming.kind === 'incrStat' && queued.key === incoming.key) {
|
|
135
|
-
return { ...queued, amount: queued.amount + incoming.amount };
|
|
136
|
-
}
|
|
137
|
-
if (queued.kind === 'maxStat' && incoming.kind === 'maxStat' && queued.key === incoming.key) {
|
|
138
|
-
return { ...queued, value: Math.max(queued.value, incoming.value) };
|
|
139
|
-
}
|
|
140
|
-
if (queued.kind === 'setStat' && incoming.kind === 'setStat' && queued.key === incoming.key) {
|
|
141
|
-
return { ...queued, value: incoming.value };
|
|
142
|
-
}
|
|
143
|
-
if (queued.kind === 'submitScore' && incoming.kind === 'submitScore' && queued.key === incoming.key) {
|
|
144
|
-
return { ...queued, score: betterScore(queued.score, incoming.score, incoming.opts.sort), opts: incoming.opts };
|
|
145
|
-
}
|
|
146
|
-
if (queued.kind === 'setProfile' && incoming.kind === 'setProfile') {
|
|
147
|
-
return { ...queued, patch: { ...queued.patch, ...incoming.patch } };
|
|
148
|
-
}
|
|
149
|
-
return undefined;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** Whether an op writes save slot `slot`. */
|
|
153
|
-
function writesSlot(op: QueueOp, slot: string): boolean {
|
|
154
|
-
return (op.kind === 'putSave' || op.kind === 'deleteSave') && op.slot === slot;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* The queue with `incoming` added: a save or delete replaces any queued
|
|
159
|
-
* write to the same slot (and goes last); a compatible op merges into the
|
|
160
|
-
* first queued one in place; anything else is appended.
|
|
161
|
-
*/
|
|
162
|
-
export function enqueueOp(queue: readonly QueueOp[], incoming: QueueOp): QueueOp[] {
|
|
163
|
-
if (incoming.kind === 'putSave' || incoming.kind === 'deleteSave') {
|
|
164
|
-
return [...queue.filter((op) => !writesSlot(op, incoming.slot)), incoming];
|
|
165
|
-
}
|
|
166
|
-
const mergeAt = queue.findIndex((op) => collapsedOp(op, incoming) !== undefined);
|
|
167
|
-
if (mergeAt < 0) {
|
|
168
|
-
return [...queue, incoming];
|
|
169
|
-
}
|
|
170
|
-
return queue.map((op, i) => (i === mergeAt ? (collapsedOp(op, incoming) ?? op) : op));
|
|
171
|
-
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One replayer at a time per game, across tabs and across the client copies
|
|
3
|
+
* on one page: a Web Lock where the browser has them, else a lease in
|
|
4
|
+
* `localStorage` that its holder renews while it works and that lapses if
|
|
5
|
+
* the tab dies. A replayer that can't get it simply doesn't run; the holder
|
|
6
|
+
* replays for everyone.
|
|
7
|
+
*/
|
|
8
|
+
import { isRecord, optionalNumber, optionalString, parseJsonText } from '../storage/json-guards';
|
|
9
|
+
import type { KeyValueStore } from '../storage/key-value';
|
|
10
|
+
|
|
11
|
+
/** Runs work while holding the lease. */
|
|
12
|
+
export interface ReplayLease {
|
|
13
|
+
/** Run `work` holding the lease; `false` (without running it) when another replayer holds it. */
|
|
14
|
+
hold(work: () => Promise<void>): Promise<boolean>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** How long a `localStorage` lease lasts without renewal. */
|
|
18
|
+
export const STORAGE_LEASE_MS = 15_000;
|
|
19
|
+
|
|
20
|
+
/** How often the holder renews it. */
|
|
21
|
+
const RENEW_EVERY_MS = 5_000;
|
|
22
|
+
|
|
23
|
+
/** A lease on the Web Locks API. */
|
|
24
|
+
function webLockLease(locks: LockManager, name: string): ReplayLease {
|
|
25
|
+
return {
|
|
26
|
+
hold: (work) => locks.request(name, { ifAvailable: true }, async (lock) => {
|
|
27
|
+
if (lock === null) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
await work();
|
|
31
|
+
return true;
|
|
32
|
+
}),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The lease's holder and expiry, or `undefined` when there is none. */
|
|
37
|
+
function readLease(text: string | null): { readonly owner: string; readonly until: number } | undefined {
|
|
38
|
+
const stored = text === null ? undefined : parseJsonText(text);
|
|
39
|
+
if (!isRecord(stored)) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
const owner = optionalString(stored.owner);
|
|
43
|
+
const until = optionalNumber(stored.until);
|
|
44
|
+
return owner === undefined || until === undefined ? undefined : { owner, until };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A lease kept in `store` under `key`, for pages without Web Locks. */
|
|
48
|
+
export function storageLease(store: KeyValueStore, key: string, now: () => number = Date.now): ReplayLease {
|
|
49
|
+
const owner = `${String(now())}-${Math.random().toString(36).slice(2)}`;
|
|
50
|
+
const claim = (): boolean => {
|
|
51
|
+
const current = readLease(store.read(key));
|
|
52
|
+
if (current !== undefined && current.owner !== owner && current.until > now()) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
store.write(key, JSON.stringify({ owner, until: now() + STORAGE_LEASE_MS }));
|
|
56
|
+
return readLease(store.read(key))?.owner === owner;
|
|
57
|
+
};
|
|
58
|
+
return {
|
|
59
|
+
async hold(work) {
|
|
60
|
+
if (!claim()) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
const renewal = setInterval(claim, RENEW_EVERY_MS);
|
|
64
|
+
try {
|
|
65
|
+
await work();
|
|
66
|
+
} finally {
|
|
67
|
+
clearInterval(renewal);
|
|
68
|
+
if (readLease(store.read(key))?.owner === owner) {
|
|
69
|
+
store.remove(key);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return true;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The page's Web Locks, when it has them. */
|
|
78
|
+
function pageLocks(): LockManager | undefined {
|
|
79
|
+
return typeof navigator !== 'undefined' && 'locks' in navigator ? navigator.locks : undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The lease for game `slug`'s outbox: a Web Lock when available, else one in `store`. */
|
|
83
|
+
export function createReplayLease(slug: string, store: KeyValueStore): ReplayLease {
|
|
84
|
+
const name = `hub:${slug}:outbox-lease`;
|
|
85
|
+
const locks = pageLocks();
|
|
86
|
+
return locks === undefined ? storageLease(store, name) : webLockLease(locks, name);
|
|
87
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a failed replay does next, and how long it waits:
|
|
3
|
+
*
|
|
4
|
+
* - `offline`: the hub (or the network) can't be reached. The op stays at
|
|
5
|
+
* the head of the queue and replay waits for the connection.
|
|
6
|
+
* - `retry`: the hub answered with a passing problem (408, 429, 5xx) or a
|
|
7
|
+
* game handler's fetch failed. The op stays at the head and is retried with
|
|
8
|
+
* backoff, up to {@link MAX_RETRIES} times, then dropped.
|
|
9
|
+
* - `drop`: the hub refused it (any other status) or the handler threw
|
|
10
|
+
* something else. The op is dropped and reported, and the queue moves on.
|
|
11
|
+
*/
|
|
12
|
+
import { HubError } from '../errors';
|
|
13
|
+
|
|
14
|
+
/** What to do after a failed replay. */
|
|
15
|
+
export type FailureVerdict = 'offline' | 'retry' | 'drop';
|
|
16
|
+
|
|
17
|
+
/** The most tries for an op that keeps getting a passing failure. */
|
|
18
|
+
export const MAX_RETRIES = 8;
|
|
19
|
+
|
|
20
|
+
/** The first retry's delay. */
|
|
21
|
+
const BASE_DELAY_MS = 1000;
|
|
22
|
+
|
|
23
|
+
/** The longest delay between tries. */
|
|
24
|
+
const MAX_DELAY_MS = 60_000;
|
|
25
|
+
|
|
26
|
+
/** Statuses that mean "try again later". */
|
|
27
|
+
const PASSING_STATUSES: ReadonlySet<number> = new Set([408, 425, 429]);
|
|
28
|
+
|
|
29
|
+
/** The verdict for a failure. */
|
|
30
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: whatever a replay handler threw
|
|
31
|
+
export function verdictFor(failure: unknown): FailureVerdict {
|
|
32
|
+
if (failure instanceof HubError) {
|
|
33
|
+
if (failure.status === undefined) {
|
|
34
|
+
return 'offline';
|
|
35
|
+
}
|
|
36
|
+
return failure.status >= 500 || PASSING_STATUSES.has(failure.status) ? 'retry' : 'drop';
|
|
37
|
+
}
|
|
38
|
+
// A game handler's own fetch rejects with a TypeError when the network is down.
|
|
39
|
+
return failure instanceof TypeError ? 'retry' : 'drop';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The delay before try `attempts + 1`: doubling from 1 s, capped at 60 s, with jitter so tabs and players spread out. */
|
|
43
|
+
export function backoffMs(attempts: number, random: () => number = Math.random): number {
|
|
44
|
+
const ceiling = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** Math.max(0, attempts - 1));
|
|
45
|
+
return Math.round(ceiling / 2 + (ceiling / 2) * random());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A failure's message, for reports. */
|
|
49
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: whatever a replay handler threw
|
|
50
|
+
export function failureMessage(failure: unknown): string {
|
|
51
|
+
return failure instanceof Error ? failure.message : String(failure);
|
|
52
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who the page is signed in as, as the hub tells it, so `hub.userId()` and
|
|
3
|
+
* `hub.avatar` follow the account without the game calling `hub.me()`:
|
|
4
|
+
*
|
|
5
|
+
* - the menu's `hub:account` (every refresh: after it makes a new guest's
|
|
6
|
+
* account, after sign-in) and `hub:auth` (the account changed);
|
|
7
|
+
* - the social runtime's {@link SESSION_EVENT} each time its socket says
|
|
8
|
+
* hello (connected, or connected again after a drop);
|
|
9
|
+
* - this client's own sign-in, sign-up, claim and sign-out.
|
|
10
|
+
*
|
|
11
|
+
* One per page: every bundle's copy hears the same window events.
|
|
12
|
+
*/
|
|
13
|
+
import { readField } from '../realtime/cross-bundle';
|
|
14
|
+
import { SESSION_EVENT } from '../realtime/runtime-api';
|
|
15
|
+
import { isBrowserPage } from './page';
|
|
16
|
+
|
|
17
|
+
/** The window events that say who is signed in (`{ user }`). */
|
|
18
|
+
const ACCOUNT_EVENTS = ['hub:account', 'hub:auth'] as const;
|
|
19
|
+
|
|
20
|
+
/** The signed-in player as the page heard it. */
|
|
21
|
+
export interface PageUser {
|
|
22
|
+
readonly id: number;
|
|
23
|
+
/** Their look code when the source carried it, else `null`. */
|
|
24
|
+
readonly look: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Where the news came from: an account event, or a socket hello (a time to catch up on what was missed). */
|
|
28
|
+
export type AccountSource = 'account' | 'session';
|
|
29
|
+
|
|
30
|
+
/** Hears every report of who is signed in. */
|
|
31
|
+
export type AccountListener = (user: PageUser | null, source: AccountSource) => void;
|
|
32
|
+
|
|
33
|
+
/** The page's account, as last heard. */
|
|
34
|
+
export class PageAccount {
|
|
35
|
+
/** `undefined` until something said; `null` when signed out. */
|
|
36
|
+
private heard: PageUser | null | undefined = undefined;
|
|
37
|
+
private readonly listeners = new Set<AccountListener>();
|
|
38
|
+
private isListening = false;
|
|
39
|
+
|
|
40
|
+
constructor() {
|
|
41
|
+
this.listen();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The signed-in player, `null` when signed out, `undefined` before anything said. */
|
|
45
|
+
get user(): PageUser | null | undefined {
|
|
46
|
+
this.listen();
|
|
47
|
+
return this.heard;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Record who is signed in (this client's own sign-in or sign-out) and tell the listeners. */
|
|
51
|
+
note(user: PageUser | null, source: AccountSource = 'account'): void {
|
|
52
|
+
this.heard = user;
|
|
53
|
+
for (const listener of [...this.listeners]) {
|
|
54
|
+
listener(user, source);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Hear every report; returns the unsubscribe function. */
|
|
59
|
+
on(listener: AccountListener): () => void {
|
|
60
|
+
this.listen();
|
|
61
|
+
this.listeners.add(listener);
|
|
62
|
+
return () => {
|
|
63
|
+
this.listeners.delete(listener);
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Listen to the window events (at once in a page; made before a window existed, on first use). */
|
|
68
|
+
private listen(): void {
|
|
69
|
+
if (this.isListening || !isBrowserPage()) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
this.isListening = true;
|
|
73
|
+
for (const eventName of ACCOUNT_EVENTS) {
|
|
74
|
+
window.addEventListener(eventName, (event) => { this.onEvent(event, 'account'); });
|
|
75
|
+
}
|
|
76
|
+
window.addEventListener(SESSION_EVENT, (event) => { this.onEvent(event, 'session'); });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private onEvent(event: Event, source: AccountSource): void {
|
|
80
|
+
if (!(event instanceof CustomEvent)) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const user = readUser(event.detail);
|
|
84
|
+
if (user !== undefined) {
|
|
85
|
+
this.note(user, source);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A page user from an account the hub sent (`look` when it has one). */
|
|
91
|
+
export function toPageUser(user: { readonly id: number; readonly look?: string | undefined }): PageUser {
|
|
92
|
+
return { id: user.id, look: lookOrNull(user.look) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A non-empty look code, else `null`. */
|
|
96
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: a field of another bundle's event detail
|
|
97
|
+
function lookOrNull(look: unknown): string | null {
|
|
98
|
+
return typeof look === 'string' && look !== '' ? look : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The user in a `{ user }` detail: the user, `null` (signed out), or `undefined` (it doesn't say). */
|
|
102
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: another bundle's event detail, read field by field
|
|
103
|
+
function readUser(detail: unknown): PageUser | null | undefined {
|
|
104
|
+
const user = readField(detail, 'user');
|
|
105
|
+
if (user === null) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
const id = readField(user, 'id');
|
|
109
|
+
if (typeof id !== 'number' || !Number.isInteger(id)) {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
return { id, look: lookOrNull(readField(user, 'look')) };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let pageAccountSingleton: PageAccount | null = null;
|
|
116
|
+
|
|
117
|
+
/** The page's account tracker (made on first use; the hub client makes it at load). */
|
|
118
|
+
export function pageAccount(): PageAccount {
|
|
119
|
+
pageAccountSingleton ??= new PageAccount();
|
|
120
|
+
return pageAccountSingleton;
|
|
121
|
+
}
|
package/src/core/page.ts
CHANGED
|
@@ -37,12 +37,25 @@ export function injectedConfig(): InjectedConfig {
|
|
|
37
37
|
return { ...(slug === undefined ? {} : { slug }), ...(apiBase === undefined ? {} : { apiBase }) };
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
/** The game's
|
|
40
|
+
/** The game's display title: the host's injected one, else the page's `<title>`, else `fallback`. */
|
|
41
|
+
export function detectGameTitle(fallback: string): string {
|
|
42
|
+
const injected = isBrowserPage() && isRecord(window.__HUB__) ? optionalString(window.__HUB__.title) : undefined;
|
|
43
|
+
if (injected !== undefined && injected !== '') {
|
|
44
|
+
return injected;
|
|
45
|
+
}
|
|
46
|
+
const pageTitle = typeof document === 'undefined' ? '' : document.title.trim();
|
|
47
|
+
return pageTitle === '' ? fallback : pageTitle;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The game's slug: the injected one; `null` on an injected site page (the catalog, `/world`); else the first path segment. */
|
|
41
51
|
export function detectSlug(): string | null {
|
|
42
52
|
const injectedSlug = injectedConfig().slug;
|
|
43
53
|
if (injectedSlug !== undefined && injectedSlug !== '') {
|
|
44
54
|
return injectedSlug;
|
|
45
55
|
}
|
|
56
|
+
if (isHubInjectedPage()) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
46
59
|
if (typeof location === 'undefined') {
|
|
47
60
|
return null;
|
|
48
61
|
}
|
|
@@ -9,7 +9,7 @@ import { errorFor, HubError, readErrorFields, UNSUPPORTED_BY_HUB } from '../erro
|
|
|
9
9
|
import { isRecord } from '../storage/json-guards';
|
|
10
10
|
import { HUB_CLIENT_VERSION } from '../version/client-version.gen';
|
|
11
11
|
import type { EndpointFields, EndpointName, EndpointReply } from './endpoint-types';
|
|
12
|
-
import { ROUTES } from './routes.gen';
|
|
12
|
+
import { OFFLINE_OP_HEADER, ROUTES } from './routes.gen';
|
|
13
13
|
|
|
14
14
|
/** The header that tells the hub which client version is calling. */
|
|
15
15
|
export const CLIENT_VERSION_HEADER = 'x-hub-client';
|
|
@@ -38,6 +38,12 @@ export interface RestClient {
|
|
|
38
38
|
readonly apiBase: string;
|
|
39
39
|
/** Call an endpoint; resolves with its reply body, rejects with a {@link HubError}. */
|
|
40
40
|
call<K extends EndpointName>(name: K, ...fieldsArgument: CallArguments<K>): Promise<EndpointReply<K>>;
|
|
41
|
+
/**
|
|
42
|
+
* Call an endpoint as part of an offline operation: an endpoint marked for
|
|
43
|
+
* offline replay gets `opKey` in the operation header, so the hub runs it
|
|
44
|
+
* once however often it is sent; any other endpoint is called as usual.
|
|
45
|
+
*/
|
|
46
|
+
callOnce<K extends EndpointName>(opKey: string, name: K, ...fieldsArgument: CallArguments<K>): Promise<EndpointReply<K>>;
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
/** One field value as URL text. */
|
|
@@ -126,14 +132,18 @@ async function failureOf(name: EndpointName, response: Response): Promise<HubErr
|
|
|
126
132
|
/** A REST client for one API base. */
|
|
127
133
|
export function createRestClient(options: RestClientOptions): RestClient {
|
|
128
134
|
// eslint-disable-next-line no-restricted-syntax -- trust boundary: the caller's typed fields, walked generically
|
|
129
|
-
async function send<Reply>(name: EndpointName, fields: unknown): Promise<Reply> {
|
|
135
|
+
async function send<Reply>(name: EndpointName, fields: unknown, opKey?: string): Promise<Reply> {
|
|
130
136
|
const planned = planRequest(name, fields);
|
|
131
137
|
let response: Response;
|
|
132
138
|
try {
|
|
133
139
|
response = await options.fetch(options.apiBase + planned.pathAndQuery, {
|
|
134
140
|
method: planned.method,
|
|
135
141
|
credentials: 'same-origin',
|
|
136
|
-
headers: {
|
|
142
|
+
headers: {
|
|
143
|
+
'content-type': 'application/json',
|
|
144
|
+
[CLIENT_VERSION_HEADER]: HUB_CLIENT_VERSION,
|
|
145
|
+
...(opKey !== undefined && ROUTES[name].offline === 'replay' ? { [OFFLINE_OP_HEADER]: opKey } : {}),
|
|
146
|
+
},
|
|
137
147
|
...(planned.bodyText === undefined ? {} : { body: planned.bodyText }),
|
|
138
148
|
});
|
|
139
149
|
} catch (failure) {
|
|
@@ -148,5 +158,6 @@ export function createRestClient(options: RestClientOptions): RestClient {
|
|
|
148
158
|
return {
|
|
149
159
|
apiBase: options.apiBase,
|
|
150
160
|
call: (name, ...fieldsArgument) => send(name, fieldsArgument[0]),
|
|
161
|
+
callOnce: (opKey, name, ...fieldsArgument) => send(name, fieldsArgument[0], opKey),
|
|
151
162
|
};
|
|
152
163
|
}
|