@diffenderfer-games/hub 2.0.0-next.0 → 2.0.0-next.2
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 +36 -0
- package/LICENSE +21 -21
- package/README.md +206 -139
- package/dist/auto.d.ts +6 -6
- package/dist/auto.js +2 -2
- package/dist/doctor/cli.mjs +62 -25
- package/dist/doctor/cli.mjs.map +1 -1
- package/dist/index.d.ts +6 -6
- package/dist/index.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/shared/bridge.js +9 -3
- package/dist/shared/bridge.js.map +1 -1
- package/dist/shared/client-version.gen.js +1 -1
- package/dist/shared/daily.d.ts +10 -4
- package/dist/shared/daily.d.ts.map +1 -1
- package/dist/shared/daily.js +12 -2
- package/dist/shared/daily.js.map +1 -1
- package/dist/shared/globals.js +8 -3
- package/dist/shared/globals.js.map +1 -1
- package/dist/shared/index.d.ts +314 -35
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/input.js +7 -2
- package/dist/shared/input.js.map +1 -1
- package/dist/shared/latest-state.js +1 -1
- 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/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 +10 -2
- package/dist/shared/rooms.js.map +1 -1
- package/dist/shared/runtime-opener.js +1 -1
- package/dist/shared/social.d.ts +23 -8
- 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 +1588 -388
- package/dist/shared/src.js.map +1 -1
- 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 +1 -1
- package/package.json +89 -89
- package/src/auto.ts +39 -39
- package/src/core/connection.ts +394 -299
- package/src/core/errors.ts +147 -147
- package/src/core/game-data.ts +40 -38
- package/src/core/hub-economy.ts +216 -179
- package/src/core/hub-progress.ts +202 -205
- package/src/core/hub.ts +133 -131
- package/src/core/menu-control.ts +61 -61
- package/src/core/offline/events.ts +50 -50
- package/src/core/offline/favorites.ts +21 -21
- 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 +35 -0
- package/src/core/offline/hub-kinds/legacy-ops.ts +37 -0
- package/src/core/offline/hub-kinds/progress-kinds.ts +148 -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 +259 -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.ts +74 -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 +108 -87
- 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/types.ts +134 -134
- package/src/core/version/client-version.gen.ts +1 -1
- package/src/core/version/hub-version.ts +62 -62
- package/src/core/version/semver.ts +97 -97
- package/src/daily/daily-system.ts +168 -161
- package/src/daily/page-daily.ts +36 -36
- package/src/daily/types.ts +68 -67
- package/src/daily.ts +10 -10
- package/src/doctor/checks.ts +86 -76
- package/src/doctor/cli.ts +98 -106
- package/src/doctor/hub-version-fetch.ts +43 -0
- package/src/doctor/tsconfig.json +12 -12
- package/src/globals.ts +115 -110
- package/src/index.ts +55 -50
- package/src/input/device-state.ts +146 -146
- package/src/input/edges.ts +55 -55
- 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 +393 -393
- package/src/input/legacy-controls.ts +64 -64
- package/src/input/nav-state.ts +191 -191
- package/src/input/navigator.ts +171 -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 +177 -177
- package/src/input.ts +22 -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 +208 -208
- package/src/overlay/styles.ts +9 -9
- 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 +91 -79
- package/src/realtime/runtime-api.ts +60 -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 +53 -53
- package/src/realtime/wire-types.ts +79 -72
- package/src/rooms.ts +10 -10
- package/src/social.ts +18 -18
- 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
|
@@ -1,171 +1,126 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The offline
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
readonly
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
| { readonly kind: '
|
|
44
|
-
| { readonly kind: '
|
|
45
|
-
| { readonly kind: '
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
case '
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
case '
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
return
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
/** The better of two scores on a board sorted `sort` (`desc` by default). */
|
|
128
|
-
export function betterScore(first: number, second: number, sort: SortDir | undefined): number {
|
|
129
|
-
return sort === 'asc' ? Math.min(first, second) : Math.max(first, second);
|
|
130
|
-
}
|
|
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
|
-
}
|
|
1
|
+
/**
|
|
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.
|
|
6
|
+
*/
|
|
7
|
+
import type { Json } from '@diffenderfer-games/hub-contract';
|
|
8
|
+
import type { SortDir } from '@diffenderfer-games/hub-contract';
|
|
9
|
+
import { isJson, isNullableString, isRecord, optionalNumber, optionalString, type UncheckedRecord } from '../storage/json-guards';
|
|
10
|
+
|
|
11
|
+
/** A score submission's options as queued (the metadata already JSON). */
|
|
12
|
+
export interface QueuedScoreOptions {
|
|
13
|
+
/** The board's title; only used when the board key is first created. */
|
|
14
|
+
readonly title?: string;
|
|
15
|
+
/** The sort direction; only used on board creation. Default `desc`. */
|
|
16
|
+
readonly sort?: SortDir;
|
|
17
|
+
/** Any JSON stored with the entry (a character, a run seed). */
|
|
18
|
+
readonly meta?: Json;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A profile change: either part may be left out. */
|
|
22
|
+
export interface ProfilePatch {
|
|
23
|
+
/** Shown to other players; `null` clears it. */
|
|
24
|
+
displayName?: string | null;
|
|
25
|
+
/** The game's own profile data. */
|
|
26
|
+
data?: Json;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A queued op's id and when it was queued. */
|
|
30
|
+
interface QueueStamp {
|
|
31
|
+
/** Unique within this page: `<epoch ms>-<sequence>`. */
|
|
32
|
+
readonly id: string;
|
|
33
|
+
/** When it was queued (epoch ms). */
|
|
34
|
+
readonly ts: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A queued write, before it is stamped. */
|
|
38
|
+
export type QueueOpInput =
|
|
39
|
+
| { readonly kind: 'putSave'; readonly slot: string; readonly data: Json; readonly label: string | null }
|
|
40
|
+
| { readonly kind: 'deleteSave'; readonly slot: string }
|
|
41
|
+
| { readonly kind: 'setStat'; readonly key: string; readonly value: number }
|
|
42
|
+
| { readonly kind: 'maxStat'; readonly key: string; readonly value: number }
|
|
43
|
+
| { readonly kind: 'incrStat'; readonly key: string; readonly amount: number }
|
|
44
|
+
| { readonly kind: 'submitScore'; readonly key: string; readonly score: number; readonly opts: QueuedScoreOptions }
|
|
45
|
+
| { readonly kind: 'setProfile'; readonly patch: ProfilePatch };
|
|
46
|
+
|
|
47
|
+
/** A queued write, as stored under `hub:<slug>:queue`. */
|
|
48
|
+
export type QueueOp = QueueOpInput & QueueStamp;
|
|
49
|
+
|
|
50
|
+
/** The score options in a stored op, or `undefined` when they are malformed. */
|
|
51
|
+
function readScoreOptions(stored: UncheckedRecord): QueuedScoreOptions | undefined {
|
|
52
|
+
const title = optionalString(stored.title);
|
|
53
|
+
const sort = stored.sort === 'asc' || stored.sort === 'desc' ? stored.sort : undefined;
|
|
54
|
+
if (stored.meta !== undefined && !isJson(stored.meta)) {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
return { ...(title === undefined ? {} : { title }), ...(sort === undefined ? {} : { sort }), ...(stored.meta === undefined ? {} : { meta: stored.meta }) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The profile patch in a stored op, or `undefined` when it is malformed. */
|
|
61
|
+
function readProfilePatch(stored: UncheckedRecord): ProfilePatch | undefined {
|
|
62
|
+
if ((stored.displayName !== undefined && !isNullableString(stored.displayName)) || (stored.data !== undefined && !isJson(stored.data))) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
const patch: ProfilePatch = {};
|
|
66
|
+
if (stored.displayName !== undefined) {
|
|
67
|
+
patch.displayName = stored.displayName;
|
|
68
|
+
}
|
|
69
|
+
if (stored.data !== undefined) {
|
|
70
|
+
patch.data = stored.data;
|
|
71
|
+
}
|
|
72
|
+
return patch;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** One stored op's write, or `undefined` when it is not a known, well-formed op. */
|
|
76
|
+
function readOpInput(stored: UncheckedRecord): QueueOpInput | undefined {
|
|
77
|
+
const slot = optionalString(stored.slot);
|
|
78
|
+
const key = optionalString(stored.key);
|
|
79
|
+
const statValue = optionalNumber(stored.value);
|
|
80
|
+
switch (stored.kind) {
|
|
81
|
+
case 'putSave':
|
|
82
|
+
return slot !== undefined && isJson(stored.data) && isNullableString(stored.label) ? { kind: 'putSave', slot, data: stored.data, label: stored.label } : undefined;
|
|
83
|
+
case 'deleteSave':
|
|
84
|
+
return slot === undefined ? undefined : { kind: 'deleteSave', slot };
|
|
85
|
+
case 'setStat':
|
|
86
|
+
case 'maxStat':
|
|
87
|
+
return key !== undefined && statValue !== undefined ? { kind: stored.kind, key, value: statValue } : undefined;
|
|
88
|
+
case 'incrStat': {
|
|
89
|
+
const amount = optionalNumber(stored.amount);
|
|
90
|
+
return key !== undefined && amount !== undefined ? { kind: 'incrStat', key, amount } : undefined;
|
|
91
|
+
}
|
|
92
|
+
case 'submitScore': {
|
|
93
|
+
const score = optionalNumber(stored.score);
|
|
94
|
+
const opts = isRecord(stored.opts) ? readScoreOptions(stored.opts) : undefined;
|
|
95
|
+
return key !== undefined && score !== undefined && opts !== undefined ? { kind: 'submitScore', key, score, opts } : undefined;
|
|
96
|
+
}
|
|
97
|
+
case 'setProfile': {
|
|
98
|
+
const patch = isRecord(stored.patch) ? readProfilePatch(stored.patch) : undefined;
|
|
99
|
+
return patch === undefined ? undefined : { kind: 'setProfile', patch };
|
|
100
|
+
}
|
|
101
|
+
default:
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The well-formed ops of a stored queue, in order; anything malformed is dropped. */
|
|
107
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: a queue read back from localStorage (possibly written by a v1 client)
|
|
108
|
+
export function readQueue(stored: unknown): QueueOp[] {
|
|
109
|
+
if (!Array.isArray(stored)) {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
return stored.flatMap((entry): QueueOp[] => {
|
|
113
|
+
if (!isRecord(entry)) {
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
const input = readOpInput(entry);
|
|
117
|
+
const id = optionalString(entry.id);
|
|
118
|
+
const ts = optionalNumber(entry.ts);
|
|
119
|
+
return input === undefined || id === undefined || ts === undefined ? [] : [{ ...input, id, ts }];
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The better of two scores on a board sorted `sort` (`desc` by default). */
|
|
124
|
+
export function betterScore(first: number, second: number, sort: SortDir | undefined): number {
|
|
125
|
+
return sort === 'asc' ? Math.min(first, second) : Math.max(first, second);
|
|
126
|
+
}
|
|
@@ -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
|
+
}
|
package/src/core/page.ts
CHANGED
|
@@ -1,74 +1,74 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* What the page tells the client: the host's injected `window.__HUB__`
|
|
3
|
-
* (contract `HubInject`), the game's slug, and the API base. A page the host
|
|
4
|
-
* did not inject (`game.hub: false`, local development) has no `__HUB__`.
|
|
5
|
-
*/
|
|
6
|
-
import type { HubInject } from '@diffenderfer-games/hub-contract';
|
|
7
|
-
import { isRecord, optionalString } from './storage/json-guards';
|
|
8
|
-
|
|
9
|
-
declare global {
|
|
10
|
-
interface Window {
|
|
11
|
-
/** The host's injected config (contract `HubInject`); read through {@link injectedConfig}. */
|
|
12
|
-
// eslint-disable-next-line no-restricted-syntax -- trust boundary: written by the host's inline script, read field by field
|
|
13
|
-
__HUB__?: unknown;
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/** The fields of `window.__HUB__` the core client reads. */
|
|
18
|
-
export type InjectedConfig = Partial<Pick<HubInject, 'slug'>> & { readonly apiBase?: string };
|
|
19
|
-
|
|
20
|
-
/** Whether this code runs in a browser page. */
|
|
21
|
-
export function isBrowserPage(): boolean {
|
|
22
|
-
return typeof window !== 'undefined';
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** Whether the host injected this page (`window.__HUB__` exists), so the menu and social runtime can arrive. */
|
|
26
|
-
export function isHubInjectedPage(): boolean {
|
|
27
|
-
return isBrowserPage() && isRecord(window.__HUB__);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** The injected config's slug and API base, read defensively (empty outside a hub page). */
|
|
31
|
-
export function injectedConfig(): InjectedConfig {
|
|
32
|
-
if (!isBrowserPage() || !isRecord(window.__HUB__)) {
|
|
33
|
-
return {};
|
|
34
|
-
}
|
|
35
|
-
const slug = optionalString(window.__HUB__.slug);
|
|
36
|
-
const apiBase = optionalString(window.__HUB__.apiBase);
|
|
37
|
-
return { ...(slug === undefined ? {} : { slug }), ...(apiBase === undefined ? {} : { apiBase }) };
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** The game's slug: the injected one, else the first URL path segment, else `null`. */
|
|
41
|
-
export function detectSlug(): string | null {
|
|
42
|
-
const injectedSlug = injectedConfig().slug;
|
|
43
|
-
if (injectedSlug !== undefined && injectedSlug !== '') {
|
|
44
|
-
return injectedSlug;
|
|
45
|
-
}
|
|
46
|
-
if (typeof location === 'undefined') {
|
|
47
|
-
return null;
|
|
48
|
-
}
|
|
49
|
-
return location.pathname.split('/').find((segment) => segment !== '') ?? null;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** An API base made absolute against the page origin (`http://localhost` outside a browser), without a trailing slash. */
|
|
53
|
-
export function absoluteApiBase(apiBase: string): string {
|
|
54
|
-
const origin = typeof location === 'undefined' ? 'http://localhost' : location.origin;
|
|
55
|
-
return new URL(apiBase, origin).toString().replace(/\/$/, '');
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** The page's API base: the injected one, else `/_api`, made absolute. */
|
|
59
|
-
export function detectApiBase(): string {
|
|
60
|
-
return absoluteApiBase(injectedConfig().apiBase ?? '/_api');
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Dispatch a `CustomEvent` on `window` (no-op outside a browser; listener errors never reach the caller). */
|
|
64
|
-
// eslint-disable-next-line no-restricted-syntax -- trust boundary: event details are whatever the event documents; listeners read them
|
|
65
|
-
export function emitWindowEvent(eventName: string, detail?: unknown): void {
|
|
66
|
-
if (!isBrowserPage()) {
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
try {
|
|
70
|
-
window.dispatchEvent(new CustomEvent(eventName, { detail }));
|
|
71
|
-
} catch {
|
|
72
|
-
// A page without CustomEvent (very old browsers) misses the event.
|
|
73
|
-
}
|
|
74
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* What the page tells the client: the host's injected `window.__HUB__`
|
|
3
|
+
* (contract `HubInject`), the game's slug, and the API base. A page the host
|
|
4
|
+
* did not inject (`game.hub: false`, local development) has no `__HUB__`.
|
|
5
|
+
*/
|
|
6
|
+
import type { HubInject } from '@diffenderfer-games/hub-contract';
|
|
7
|
+
import { isRecord, optionalString } from './storage/json-guards';
|
|
8
|
+
|
|
9
|
+
declare global {
|
|
10
|
+
interface Window {
|
|
11
|
+
/** The host's injected config (contract `HubInject`); read through {@link injectedConfig}. */
|
|
12
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: written by the host's inline script, read field by field
|
|
13
|
+
__HUB__?: unknown;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The fields of `window.__HUB__` the core client reads. */
|
|
18
|
+
export type InjectedConfig = Partial<Pick<HubInject, 'slug'>> & { readonly apiBase?: string };
|
|
19
|
+
|
|
20
|
+
/** Whether this code runs in a browser page. */
|
|
21
|
+
export function isBrowserPage(): boolean {
|
|
22
|
+
return typeof window !== 'undefined';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Whether the host injected this page (`window.__HUB__` exists), so the menu and social runtime can arrive. */
|
|
26
|
+
export function isHubInjectedPage(): boolean {
|
|
27
|
+
return isBrowserPage() && isRecord(window.__HUB__);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The injected config's slug and API base, read defensively (empty outside a hub page). */
|
|
31
|
+
export function injectedConfig(): InjectedConfig {
|
|
32
|
+
if (!isBrowserPage() || !isRecord(window.__HUB__)) {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
const slug = optionalString(window.__HUB__.slug);
|
|
36
|
+
const apiBase = optionalString(window.__HUB__.apiBase);
|
|
37
|
+
return { ...(slug === undefined ? {} : { slug }), ...(apiBase === undefined ? {} : { apiBase }) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The game's slug: the injected one, else the first URL path segment, else `null`. */
|
|
41
|
+
export function detectSlug(): string | null {
|
|
42
|
+
const injectedSlug = injectedConfig().slug;
|
|
43
|
+
if (injectedSlug !== undefined && injectedSlug !== '') {
|
|
44
|
+
return injectedSlug;
|
|
45
|
+
}
|
|
46
|
+
if (typeof location === 'undefined') {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return location.pathname.split('/').find((segment) => segment !== '') ?? null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** An API base made absolute against the page origin (`http://localhost` outside a browser), without a trailing slash. */
|
|
53
|
+
export function absoluteApiBase(apiBase: string): string {
|
|
54
|
+
const origin = typeof location === 'undefined' ? 'http://localhost' : location.origin;
|
|
55
|
+
return new URL(apiBase, origin).toString().replace(/\/$/, '');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The page's API base: the injected one, else `/_api`, made absolute. */
|
|
59
|
+
export function detectApiBase(): string {
|
|
60
|
+
return absoluteApiBase(injectedConfig().apiBase ?? '/_api');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Dispatch a `CustomEvent` on `window` (no-op outside a browser; listener errors never reach the caller). */
|
|
64
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: event details are whatever the event documents; listeners read them
|
|
65
|
+
export function emitWindowEvent(eventName: string, detail?: unknown): void {
|
|
66
|
+
if (!isBrowserPage()) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
window.dispatchEvent(new CustomEvent(eventName, { detail }));
|
|
71
|
+
} catch {
|
|
72
|
+
// A page without CustomEvent (very old browsers) misses the event.
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Per-endpoint request and reply types, derived from the contract's spec
|
|
3
|
-
* **type** only (`import type`, erased at build): the browser bundle carries
|
|
4
|
-
* the route table, never the schemas (plan D16).
|
|
5
|
-
*/
|
|
6
|
-
import type { CallReturn, ClientData } from '@ayepi/core/client';
|
|
7
|
-
import type { PublicEndpoints } from '@diffenderfer-games/hub-contract';
|
|
8
|
-
|
|
9
|
-
/** The name of a public REST endpoint (`getCurrentUser`, `writeSave`, ...). */
|
|
10
|
-
export type EndpointName = keyof PublicEndpoints;
|
|
11
|
-
|
|
12
|
-
/** One endpoint's request fields: path params, query and body merged into one object. */
|
|
13
|
-
export type EndpointFields<K extends EndpointName> = ClientData<PublicEndpoints[K]>;
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* An endpoint that declares several success statuses comes back from ayepi's
|
|
17
|
-
* client as `{ status, data }`; this client always hands back the body.
|
|
18
|
-
*/
|
|
19
|
-
type ReplyBody<Reply> = Reply extends { readonly status: number; readonly data: infer Body } ? Body : Reply;
|
|
20
|
-
|
|
21
|
-
/** One endpoint's success reply body. */
|
|
22
|
-
export type EndpointReply<K extends EndpointName> = ReplyBody<Awaited<CallReturn<PublicEndpoints[K]>>>;
|
|
1
|
+
/**
|
|
2
|
+
* Per-endpoint request and reply types, derived from the contract's spec
|
|
3
|
+
* **type** only (`import type`, erased at build): the browser bundle carries
|
|
4
|
+
* the route table, never the schemas (plan D16).
|
|
5
|
+
*/
|
|
6
|
+
import type { CallReturn, ClientData } from '@ayepi/core/client';
|
|
7
|
+
import type { PublicEndpoints } from '@diffenderfer-games/hub-contract';
|
|
8
|
+
|
|
9
|
+
/** The name of a public REST endpoint (`getCurrentUser`, `writeSave`, ...). */
|
|
10
|
+
export type EndpointName = keyof PublicEndpoints;
|
|
11
|
+
|
|
12
|
+
/** One endpoint's request fields: path params, query and body merged into one object. */
|
|
13
|
+
export type EndpointFields<K extends EndpointName> = ClientData<PublicEndpoints[K]>;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* An endpoint that declares several success statuses comes back from ayepi's
|
|
17
|
+
* client as `{ status, data }`; this client always hands back the body.
|
|
18
|
+
*/
|
|
19
|
+
type ReplyBody<Reply> = Reply extends { readonly status: number; readonly data: infer Body } ? Body : Reply;
|
|
20
|
+
|
|
21
|
+
/** One endpoint's success reply body. */
|
|
22
|
+
export type EndpointReply<K extends EndpointName> = ReplyBody<Awaited<CallReturn<PublicEndpoints[K]>>>;
|