svelte-realtime 0.6.0-next.3 → 0.6.0-next.4
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/README.md +45 -3
- package/client-multiplayer.d.ts +30 -17
- package/client-multiplayer.svelte.js +101 -11
- package/client-runtime.js +138 -0
- package/client.js +99 -73
- package/devtools.js +7 -5
- package/package.json +4 -1
- package/server.d.ts +76 -4
- package/server.js +392 -89
- package/shared/runtime.js +121 -0
- package/vite.js +79 -11
package/README.md
CHANGED
|
@@ -2920,6 +2920,7 @@ export const board = live.multiplayer({
|
|
|
2920
2920
|
- `me` - the local user's key, or `null` until you name it.
|
|
2921
2921
|
- `status` - the connection-status passthrough.
|
|
2922
2922
|
- `move(...)` / `reportViewport(...)` - forward to the cursor send path.
|
|
2923
|
+
- `typing` / `selections` / `locks` / `reactions` and their send methods, when the matching field surfaces are declared (see [Field surfaces](#field-surfaces-typing-selections-locks-reactions)).
|
|
2923
2924
|
|
|
2924
2925
|
Name the current user once with `board.identify(key)` - the same identity you already supply for presence, available from the SvelteKit page load. Calling `identify(key)` after `board.room(...)` still lights up `me` and self-exclusion. If you never call it the surface degrades gracefully: `others` is the full deduped roster and `me` reads `null`, never a crash.
|
|
2925
2926
|
|
|
@@ -2978,9 +2979,50 @@ The raw `data` / `presence` / `cursors` factory stores stay on the export for ba
|
|
|
2978
2979
|
{/each}
|
|
2979
2980
|
```
|
|
2980
2981
|
|
|
2981
|
-
###
|
|
2982
|
+
### Field surfaces: typing, selections, locks, reactions
|
|
2982
2983
|
|
|
2983
|
-
|
|
2984
|
+
Declare a collaborative field surface on the export and the room view lights up a reactive projection plus a send method for it. Typing, selections, and locks are published onto the same presence roster `room.others` reads, so they cost no extra subscription; reactions ride a dedicated stream.
|
|
2985
|
+
|
|
2986
|
+
```js
|
|
2987
|
+
export const board = live.multiplayer({
|
|
2988
|
+
topic: (ctx, boardId) => 'board:' + boardId,
|
|
2989
|
+
topicArgs: 1,
|
|
2990
|
+
init: async (ctx, boardId) => db.cards.forBoard(boardId),
|
|
2991
|
+
presence: (ctx) => ({ name: ctx.user.name }),
|
|
2992
|
+
cursors: true,
|
|
2993
|
+
typing: true,
|
|
2994
|
+
selections: 'offset',
|
|
2995
|
+
locks: ['title', 'body'],
|
|
2996
|
+
reactions: true
|
|
2997
|
+
});
|
|
2998
|
+
```
|
|
2999
|
+
|
|
3000
|
+
- **Typing** - `room.typing` is the list of remote collaborators' user keys currently flagged as typing (self excluded). Toggle the local flag with `room.setTyping(true)` / `room.setTyping(false)`. Typing is a transient flag and is never persisted on the roster.
|
|
3001
|
+
- **Selections** - `room.selections` is a `{ userKey: range }` map of remote selection ranges (self excluded). Publish the local offset-mode range with `room.setSelection({ start, end, nodePath })`; pass `null` to clear it. A selection is stamped on the caller's roster entry, so a late joiner loads the current selections from the roster snapshot instead of waiting for the next change.
|
|
3002
|
+
- **Locks** - `room.locks` is a `{ lockKey: holderUserKey }` map. `room.acquireLock('title')` announces an advisory claim on a key and `room.releaseLock('title')` clears it. A holder disconnecting drops its claims on the next roster push. These are soft, collaborative-awareness locks (they tell collaborators who is editing what), not distributed mutual exclusion - a second caller is not blocked. A held lock is stamped on the caller's roster entry, so a late joiner sees who holds what from the roster snapshot.
|
|
3003
|
+
- **Reactions** - `room.reactions` is a bounded ring of recent ephemeral emotes; `room.react('heart', { x, y })` emits one. Reactions are never coalesced, so a burst of taps all arrive, and old entries fall off the ring.
|
|
3004
|
+
|
|
3005
|
+
Selections and locks persist on the roster both single-instance and across a cluster (wire `platform.redis`); typing stays ephemeral. Because a field is stamped on a roster entry that only exists once presence is set, declaring `typing`, `selections`, or `locks` requires a `presence` function - the Vite plugin and `live.multiplayer()` both reject a presence field with no presence. `reactions` are exempt: they ride their own ephemeral stream and need no presence.
|
|
3006
|
+
|
|
3007
|
+
```svelte
|
|
3008
|
+
<script>
|
|
3009
|
+
import { board } from '$live/collab';
|
|
3010
|
+
let { boardId } = $props();
|
|
3011
|
+
const room = board.room(boardId);
|
|
3012
|
+
</script>
|
|
3013
|
+
|
|
3014
|
+
<input
|
|
3015
|
+
oninput={() => room.setTyping(true)}
|
|
3016
|
+
onblur={() => room.setTyping(false)} />
|
|
3017
|
+
|
|
3018
|
+
{#if room.typing.length}
|
|
3019
|
+
<p>{room.typing.length} editing...</p>
|
|
3020
|
+
{/if}
|
|
3021
|
+
|
|
3022
|
+
<button onclick={() => room.react('heart', { x: 100, y: 40 })}>React</button>
|
|
3023
|
+
```
|
|
3024
|
+
|
|
3025
|
+
A multiplayer export that declares no field surface is unchanged, and calling a field method on the namespace (rather than a `room(...)` instance) is a safe no-op that points you at `room(...)`.
|
|
2984
3026
|
|
|
2985
3027
|
### Deterministic user colors
|
|
2986
3028
|
|
|
@@ -3839,7 +3881,7 @@ Import from `svelte-realtime/server`.
|
|
|
3839
3881
|
| `live.effect(sources, fn, options?)` | Server-side reactive side effect |
|
|
3840
3882
|
| `live.aggregate(source, reducers, options)` | Real-time incremental aggregation |
|
|
3841
3883
|
| `live.room(config)` | Collaborative room (data + presence + cursors + actions) |
|
|
3842
|
-
| `live.multiplayer(config)` | Collaborative surface: room sub-streams plus an aggregated roster view (`room(...)` -> `others` / `cursors` / `me`, colored and deduped), live cursors (move / reportViewport), connection status, and `identify(key)` |
|
|
3884
|
+
| `live.multiplayer(config)` | Collaborative surface: room sub-streams plus an aggregated roster view (`room(...)` -> `others` / `cursors` / `me`, colored and deduped), live cursors (move / reportViewport), field surfaces (typing / selections / advisory locks / reactions), connection status, and `identify(key)` |
|
|
3843
3885
|
| `live.webhook(topic, config)` | HTTP webhook-to-stream bridge |
|
|
3844
3886
|
| `live.gate(predicate, fn)` | Conditional stream activation |
|
|
3845
3887
|
| `live.rateLimit(config, fn)` | Per-function sliding window rate limiter |
|
package/client-multiplayer.d.ts
CHANGED
|
@@ -40,10 +40,22 @@ export interface MultiplayerRoomDeps {
|
|
|
40
40
|
cursors: Readable<any[]>;
|
|
41
41
|
/** Connection-status store. */
|
|
42
42
|
status: Readable<string>;
|
|
43
|
+
/** Reactions sub-stream (the bounded ring of recent emotes), when enabled. */
|
|
44
|
+
reactions?: Readable<any[]>;
|
|
43
45
|
/** Outbound cursor-move send callback. */
|
|
44
46
|
move?: (...args: any[]) => any;
|
|
45
47
|
/** Outbound viewport-report send callback. Falls back to `move`. */
|
|
46
48
|
reportViewport?: (...args: any[]) => any;
|
|
49
|
+
/** Outbound presence-field send callback for typing toggles. */
|
|
50
|
+
setTyping?: (...args: any[]) => any;
|
|
51
|
+
/** Outbound presence-field send callback for selection ranges. */
|
|
52
|
+
setSelection?: (...args: any[]) => any;
|
|
53
|
+
/** Outbound presence-field send callback for advisory lock acquisition. */
|
|
54
|
+
acquireLock?: (...args: any[]) => any;
|
|
55
|
+
/** Outbound presence-field send callback for advisory lock release. */
|
|
56
|
+
releaseLock?: (...args: any[]) => any;
|
|
57
|
+
/** Outbound reaction send callback. */
|
|
58
|
+
react?: (...args: any[]) => any;
|
|
47
59
|
}
|
|
48
60
|
|
|
49
61
|
/**
|
|
@@ -60,8 +72,9 @@ export interface MultiplayerRoomDeps {
|
|
|
60
72
|
* - `me`: the local user's key, or `null` when the app never supplied one.
|
|
61
73
|
* - `status`: the connection-status passthrough.
|
|
62
74
|
*
|
|
63
|
-
* The `typing` / `locks` / `selections`
|
|
64
|
-
* methods
|
|
75
|
+
* The `typing` / `locks` / `selections` views are reactive projections of the
|
|
76
|
+
* presence roster, driven by the field-send methods; `reactions` is the bounded
|
|
77
|
+
* ring of recent ephemeral emotes.
|
|
65
78
|
*/
|
|
66
79
|
export class MultiplayerRoom {
|
|
67
80
|
constructor(deps: MultiplayerRoomDeps);
|
|
@@ -73,28 +86,28 @@ export class MultiplayerRoom {
|
|
|
73
86
|
get me(): string | null;
|
|
74
87
|
/** The connection status. */
|
|
75
88
|
get status(): string;
|
|
76
|
-
/**
|
|
77
|
-
get typing():
|
|
78
|
-
/**
|
|
89
|
+
/** The user keys of remote collaborators currently flagged as typing. */
|
|
90
|
+
get typing(): string[];
|
|
91
|
+
/** Advisory lock holders keyed by lock key: `{ lockKey: holderUserKey }`. */
|
|
79
92
|
get locks(): Record<string, any>;
|
|
80
|
-
/**
|
|
93
|
+
/** Remote selection ranges keyed by user (self excluded). */
|
|
81
94
|
get selections(): Record<string, any>;
|
|
82
|
-
/**
|
|
95
|
+
/** The bounded ring of recent reactions. */
|
|
83
96
|
get reactions(): any[];
|
|
84
97
|
/** Forward a cursor move to the injected send callback. */
|
|
85
98
|
move(...args: any[]): any;
|
|
86
99
|
/** Forward a viewport report to the injected send callback. */
|
|
87
100
|
reportViewport(...args: any[]): any;
|
|
88
|
-
/**
|
|
89
|
-
react(
|
|
90
|
-
/**
|
|
91
|
-
setTyping(
|
|
92
|
-
/**
|
|
93
|
-
acquireLock(
|
|
94
|
-
/**
|
|
95
|
-
releaseLock(
|
|
96
|
-
/**
|
|
97
|
-
setSelection(
|
|
101
|
+
/** Emit an ephemeral reaction (a token at an optional point). */
|
|
102
|
+
react(token: any, at?: any): any;
|
|
103
|
+
/** Toggle the local typing flag, published onto the presence roster. */
|
|
104
|
+
setTyping(on: boolean): any;
|
|
105
|
+
/** Claim an advisory lock on a key (collaborative awareness, not exclusion). */
|
|
106
|
+
acquireLock(lockKey: string): any;
|
|
107
|
+
/** Release an advisory lock on a key. */
|
|
108
|
+
releaseLock(lockKey: string): any;
|
|
109
|
+
/** Publish the local selection range; pass `null` to clear it. */
|
|
110
|
+
setSelection(selection: any): any;
|
|
98
111
|
/** Unsubscribe from the injected stores. Call when the room unmounts. */
|
|
99
112
|
destroy(): void;
|
|
100
113
|
}
|
|
@@ -50,6 +50,12 @@ function dedupeByUser(list) {
|
|
|
50
50
|
* means `identify(key)` called after the room is constructed updates self-
|
|
51
51
|
* exclusion live.
|
|
52
52
|
*
|
|
53
|
+
* The collaborative field surfaces - `typing`, `selections`, `locks` - are
|
|
54
|
+
* projections of the same presence roster: a caller stamps fields onto its own
|
|
55
|
+
* entry through the injected send callbacks, the presence merge layers them in,
|
|
56
|
+
* and these views read them back. `reactions` is the bounded ring of recent
|
|
57
|
+
* ephemeral emotes from the dedicated reactions stream.
|
|
58
|
+
*
|
|
53
59
|
* The views are `$derived` over `$state` snapshots of the injected stores, so a
|
|
54
60
|
* store push followed by a reactive flush refreshes every view.
|
|
55
61
|
*/
|
|
@@ -57,18 +63,32 @@ export class MultiplayerRoom {
|
|
|
57
63
|
#meSource;
|
|
58
64
|
#presence = $state([]);
|
|
59
65
|
#cursors = $state([]);
|
|
66
|
+
#reactions = $state([]);
|
|
60
67
|
#status = $state('idle');
|
|
61
68
|
#move;
|
|
62
69
|
#reportViewport;
|
|
70
|
+
#setTyping;
|
|
71
|
+
#setSelection;
|
|
72
|
+
#acquireLock;
|
|
73
|
+
#releaseLock;
|
|
74
|
+
#react;
|
|
63
75
|
#unsubs = [];
|
|
64
76
|
|
|
65
77
|
constructor(deps) {
|
|
66
78
|
this.#meSource = deps.me;
|
|
67
79
|
this.#move = deps.move;
|
|
68
80
|
this.#reportViewport = deps.reportViewport || deps.move;
|
|
81
|
+
this.#setTyping = deps.setTyping;
|
|
82
|
+
this.#setSelection = deps.setSelection;
|
|
83
|
+
this.#acquireLock = deps.acquireLock;
|
|
84
|
+
this.#releaseLock = deps.releaseLock;
|
|
85
|
+
this.#react = deps.react;
|
|
69
86
|
this.#unsubs.push(deps.presence.subscribe((v) => { this.#presence = v || []; }));
|
|
70
87
|
this.#unsubs.push(deps.cursors.subscribe((v) => { this.#cursors = v || []; }));
|
|
71
88
|
this.#unsubs.push(deps.status.subscribe((v) => { this.#status = v; }));
|
|
89
|
+
if (deps.reactions) {
|
|
90
|
+
this.#unsubs.push(deps.reactions.subscribe((v) => { this.#reactions = v || []; }));
|
|
91
|
+
}
|
|
72
92
|
}
|
|
73
93
|
|
|
74
94
|
// Reads the reactive holder when one was injected (so identify(key) after
|
|
@@ -95,21 +115,91 @@ export class MultiplayerRoom {
|
|
|
95
115
|
);
|
|
96
116
|
get cursors() { return this.#cursorsDerived; }
|
|
97
117
|
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
118
|
+
// Field surfaces are projections of the same presence roster `others` reads:
|
|
119
|
+
// a caller stamps fields onto its own roster entry through the send path, the
|
|
120
|
+
// presence merge layers them onto the entry, and these views read them back.
|
|
121
|
+
// They add no new store subscription - one derive pass over the roster the
|
|
122
|
+
// room already aggregates.
|
|
123
|
+
|
|
124
|
+
// The keys of remote collaborators currently flagged as typing.
|
|
125
|
+
#typingDerived = $derived(
|
|
126
|
+
dedupeByUser(this.#presence)
|
|
127
|
+
.filter((p) => p.typing === true && (this.me == null || p.key !== this.me))
|
|
128
|
+
.map((p) => p.key)
|
|
129
|
+
);
|
|
130
|
+
get typing() { return this.#typingDerived; }
|
|
131
|
+
|
|
132
|
+
// Advisory lock holders, keyed by lock key. A roster entry carries a held key
|
|
133
|
+
// as `lock:<key>` (truthy while held, cleared on release); the holder is the
|
|
134
|
+
// entry's own user key. This collapses the roster into a { lockKey: holder }
|
|
135
|
+
// map. A holder leaving drops its entry, so its locks recompute to absent on
|
|
136
|
+
// the next push - no stale grant survives.
|
|
137
|
+
#locksDerived = $derived(this.#deriveLocks(this.#presence));
|
|
138
|
+
get locks() { return this.#locksDerived; }
|
|
139
|
+
|
|
140
|
+
// Remote selections, keyed by user. Self is excluded so an app renders only
|
|
141
|
+
// collaborators' ranges. Each value is the selection payload the holder sent.
|
|
142
|
+
#selectionsDerived = $derived(
|
|
143
|
+
Object.fromEntries(
|
|
144
|
+
dedupeByUser(this.#presence)
|
|
145
|
+
.filter((p) => p.selection != null && (this.me == null || p.key !== this.me))
|
|
146
|
+
.map((p) => [p.key, p.selection])
|
|
147
|
+
)
|
|
148
|
+
);
|
|
149
|
+
get selections() { return this.#selectionsDerived; }
|
|
150
|
+
|
|
151
|
+
// The bounded ring of recent reactions. The send stream caps and GCs old
|
|
152
|
+
// taps, so an app renders the current window and lets entries fall off.
|
|
153
|
+
get reactions() { return this.#reactions; }
|
|
154
|
+
|
|
155
|
+
/** @param {Array<Record<string, any>>} roster */
|
|
156
|
+
#deriveLocks(roster) {
|
|
157
|
+
const out = /** @type {Record<string, any>} */ ({});
|
|
158
|
+
for (const entry of dedupeByUser(roster)) {
|
|
159
|
+
for (const field in entry) {
|
|
160
|
+
if (field.startsWith('lock:') && entry[field] != null && entry[field] !== false) {
|
|
161
|
+
out[field.slice(5)] = entry.key;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
104
167
|
|
|
105
168
|
move(...args) { return this.#move ? this.#move(...args) : undefined; }
|
|
106
169
|
reportViewport(...args) { return this.#reportViewport ? this.#reportViewport(...args) : undefined; }
|
|
107
170
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
171
|
+
// Toggle the local typing flag. Publishes a `{ typing }` delta onto the
|
|
172
|
+
// caller's presence entry so every collaborator's `typing` view updates.
|
|
173
|
+
setTyping(on) {
|
|
174
|
+
return this.#setTyping ? this.#setTyping({ typing: !!on }) : undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Publish the local selection range. `null` clears it. Offset selections are
|
|
178
|
+
// a plain `{ start, end, nodePath }` object; the value is sent verbatim.
|
|
179
|
+
setSelection(selection) {
|
|
180
|
+
return this.#setSelection ? this.#setSelection({ selection: selection ?? null }) : undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Claim an advisory lock on a key: stamps `lock:<key>` on the caller's
|
|
184
|
+
// presence entry, which the server keys by the caller's identity. Advisory
|
|
185
|
+
// only - it announces intent, it does not block another claimant.
|
|
186
|
+
acquireLock(lockKey) {
|
|
187
|
+
if (!this.#acquireLock || lockKey == null) return undefined;
|
|
188
|
+
return this.#acquireLock({ ['lock:' + lockKey]: true });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Release an advisory lock: clears `lock:<key>` on the caller's entry.
|
|
192
|
+
releaseLock(lockKey) {
|
|
193
|
+
if (!this.#releaseLock || lockKey == null) return undefined;
|
|
194
|
+
return this.#releaseLock({ ['lock:' + lockKey]: null });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Emit an ephemeral reaction (an emote token at an optional point). Rides the
|
|
198
|
+
// dedicated reactions stream, never the roster, so it is a one-off event.
|
|
199
|
+
react(token, at) {
|
|
200
|
+
if (!this.#react) return undefined;
|
|
201
|
+
return at !== undefined ? this.#react(token, at) : this.#react(token);
|
|
202
|
+
}
|
|
113
203
|
|
|
114
204
|
destroy() {
|
|
115
205
|
for (const off of this.#unsubs) off();
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Injectable runtime environment for the clock, RNG, and timers - browser build.
|
|
5
|
+
*
|
|
6
|
+
* Browser client code reads time, randomness, and schedules timers exclusively
|
|
7
|
+
* through the named helpers exported here instead of touching `Date.now`,
|
|
8
|
+
* `Math.random`, `crypto`, or `setTimeout` directly. The helper names and the
|
|
9
|
+
* environment shape are kept byte-identical with the node `shared/runtime.js`
|
|
10
|
+
* copy so the two never drift and callers are interchangeable. The difference is
|
|
11
|
+
* the binding: this copy is backed entirely by browser globals (no `node:`
|
|
12
|
+
* imports), so it bundles cleanly for the browser, while a controlled simulation
|
|
13
|
+
* harness running the same client code under node can still swap in seeded
|
|
14
|
+
* virtual implementations via `setRuntimeEnv`.
|
|
15
|
+
*
|
|
16
|
+
* In production the helpers bind the native globals with zero measurable
|
|
17
|
+
* overhead: one read over a frozen, never-swapped environment object that V8
|
|
18
|
+
* keeps monomorphic and inlines straight through to the native calls.
|
|
19
|
+
*
|
|
20
|
+
* @module svelte-realtime/client-runtime
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// The browser clock is a direct wall read: the clients never had a 1Hz cache,
|
|
24
|
+
// so reading `Date.now()` per call preserves their existing behavior (and lets a
|
|
25
|
+
// test's fake-timer Date mock propagate directly). `performance.now()` drives
|
|
26
|
+
// monotonic duration math when present, falling back to the wall clock when not.
|
|
27
|
+
const _hasPerf = typeof globalThis.performance !== 'undefined' && typeof globalThis.performance.now === 'function';
|
|
28
|
+
const _processStartEpoch = _hasPerf ? Date.now() - globalThis.performance.now() : 0;
|
|
29
|
+
const _webcrypto = (typeof globalThis.crypto !== 'undefined') ? globalThis.crypto : undefined;
|
|
30
|
+
|
|
31
|
+
// v4-shaped fallback when Web Crypto randomUUID is unavailable (non-secure
|
|
32
|
+
// context / old browser). Uses the env RNG so a seeded run still reproduces it.
|
|
33
|
+
function _uuidFallback(rngFloat) {
|
|
34
|
+
let out = '';
|
|
35
|
+
for (let i = 0; i < 36; i++) {
|
|
36
|
+
if (i === 8 || i === 13 || i === 18 || i === 23) { out += '-'; continue; }
|
|
37
|
+
if (i === 14) { out += '4'; continue; }
|
|
38
|
+
const r = (rngFloat() * 16) | 0;
|
|
39
|
+
out += (i === 19 ? ((r & 0x3) | 0x8) : r).toString(16);
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// One frozen environment object, one stable hidden class. In production
|
|
45
|
+
// `current === defaultEnv` for the whole page lifetime (no override ever
|
|
46
|
+
// installs), so V8 sees a monomorphic shape and inlines the helpers to the
|
|
47
|
+
// native primitives - zero measurable overhead on the hot path.
|
|
48
|
+
const defaultEnv = Object.freeze({
|
|
49
|
+
clock: Object.freeze({
|
|
50
|
+
now: () => Date.now(), // wall, direct read
|
|
51
|
+
monotonic: _hasPerf ? () => _processStartEpoch + globalThis.performance.now() : () => Date.now(), // strictly-forward duration math
|
|
52
|
+
wallEpoch: () => Date.now() // exact wall clock; identity baseline
|
|
53
|
+
}),
|
|
54
|
+
rng: Object.freeze({
|
|
55
|
+
float: () => Math.random(),
|
|
56
|
+
u32: () => (Math.random() * 0x100000000) >>> 0,
|
|
57
|
+
uuid: (_webcrypto && typeof _webcrypto.randomUUID === 'function')
|
|
58
|
+
? () => _webcrypto.randomUUID()
|
|
59
|
+
: () => _uuidFallback(() => Math.random()),
|
|
60
|
+
bytes: (_webcrypto && typeof _webcrypto.getRandomValues === 'function')
|
|
61
|
+
? (n) => _webcrypto.getRandomValues(new Uint8Array(n))
|
|
62
|
+
: (n) => { const a = new Uint8Array(n); for (let i = 0; i < n; i++) a[i] = (Math.random() * 256) | 0; return a; }
|
|
63
|
+
}),
|
|
64
|
+
timers: Object.freeze({
|
|
65
|
+
set: (cb, ms, ...a) => setTimeout(cb, ms, ...a),
|
|
66
|
+
setInterval: (cb, ms, ...a) => setInterval(cb, ms, ...a),
|
|
67
|
+
// No setImmediate in the browser: a zero-delay macrotask is the closest.
|
|
68
|
+
setImmediate: (cb, ...a) => setTimeout(cb, 0, ...a),
|
|
69
|
+
clear: (h) => clearTimeout(h),
|
|
70
|
+
clearInterval: (h) => clearInterval(h),
|
|
71
|
+
queueMicrotask: (typeof globalThis.queueMicrotask === 'function')
|
|
72
|
+
? (cb) => globalThis.queueMicrotask(cb)
|
|
73
|
+
: (cb) => Promise.resolve().then(cb)
|
|
74
|
+
}),
|
|
75
|
+
tz: undefined // effective timezone for cron evaluation; undefined = real local TZ
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
let current = defaultEnv;
|
|
79
|
+
|
|
80
|
+
// The named helpers are the ONLY thing browser client code imports. Each is a
|
|
81
|
+
// one-line read over `current` - monomorphic in prod, inlined by V8.
|
|
82
|
+
export const now = () => current.clock.now();
|
|
83
|
+
export const monotonicNow = () => current.clock.monotonic();
|
|
84
|
+
export const wallEpoch = () => current.clock.wallEpoch();
|
|
85
|
+
export const randomFloat = () => current.rng.float();
|
|
86
|
+
export const randomU32 = () => current.rng.u32();
|
|
87
|
+
export const randomUuid = () => current.rng.uuid();
|
|
88
|
+
export const randomBytes = (n) => current.rng.bytes(n);
|
|
89
|
+
export const setTimer = (cb, ms, ...a) => current.timers.set(cb, ms, ...a);
|
|
90
|
+
export const setIntervalTimer = (cb, ms, ...a) => current.timers.setInterval(cb, ms, ...a);
|
|
91
|
+
export const setImmediateTimer = (cb, ...a) => current.timers.setImmediate(cb, ...a);
|
|
92
|
+
export const clearTimer = (h) => current.timers.clear(h);
|
|
93
|
+
export const clearIntervalTimer = (h) => current.timers.clearInterval(h);
|
|
94
|
+
export const microtask = (cb) => current.timers.queueMicrotask(cb);
|
|
95
|
+
export const effectiveTimeZone = () => current.tz;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Install a virtual environment (the simulator / test harness only). Refuses in
|
|
99
|
+
* a node production build unless explicitly forced, so a stray call can never
|
|
100
|
+
* swap the clock under a live deployment; in a real browser there is no
|
|
101
|
+
* `process` and nothing calls this anyway. A partial env merges over the native
|
|
102
|
+
* defaults, so a harness can override just the clock and keep native rng / timers.
|
|
103
|
+
*
|
|
104
|
+
* @param {object} [env] - Partial environment. Any of `clock`, `rng`, `timers`
|
|
105
|
+
* may carry a subset of their fields; provided fields override the native
|
|
106
|
+
* default, omitted fields fall through to native. `tz` overrides only when the
|
|
107
|
+
* property is present (including an explicit `undefined`).
|
|
108
|
+
* @param {object} [opts]
|
|
109
|
+
* @param {boolean} [opts.force] - Allow the swap even when
|
|
110
|
+
* `process.env.NODE_ENV === 'production'`. Use only inside a controlled
|
|
111
|
+
* simulation harness.
|
|
112
|
+
* @returns {object} The newly installed frozen environment.
|
|
113
|
+
*/
|
|
114
|
+
export function setRuntimeEnv(env, opts) {
|
|
115
|
+
const force = opts && opts.force === true;
|
|
116
|
+
if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production' && !force) {
|
|
117
|
+
throw new Error('client runtime: setRuntimeEnv refused in production (pass { force: true } only inside a controlled simulation harness)');
|
|
118
|
+
}
|
|
119
|
+
current = Object.freeze({
|
|
120
|
+
clock: Object.freeze({ ...defaultEnv.clock, ...(env && env.clock) }),
|
|
121
|
+
rng: Object.freeze({ ...defaultEnv.rng, ...(env && env.rng) }),
|
|
122
|
+
timers: Object.freeze({ ...defaultEnv.timers, ...(env && env.timers) }),
|
|
123
|
+
tz: env && Object.prototype.hasOwnProperty.call(env, 'tz') ? env.tz : defaultEnv.tz
|
|
124
|
+
});
|
|
125
|
+
return current;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Restore the native environment. Cheap wholesale reassignment (no per-field
|
|
130
|
+
* mutation), so the hidden class stays stable.
|
|
131
|
+
*/
|
|
132
|
+
export function resetRuntimeEnv() { current = defaultEnv; }
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Read-only accessor for the active environment (test / sim introspection only).
|
|
136
|
+
* @returns {object} The active frozen environment.
|
|
137
|
+
*/
|
|
138
|
+
export function getRuntimeEnv() { return current; }
|