browsermesh-kernel 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Clawser Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,59 @@
1
+ # browsermesh-kernel
2
+
3
+ Capability-secure browser microkernel: resource handles, ByteStreams, IPC,
4
+ service mesh, structured tracing, chaos engineering, and tenant isolation —
5
+ zero npm dependencies, pure ES modules.
6
+
7
+ ## Modules
8
+
9
+ | Module | Key Exports |
10
+ |--------|-------------|
11
+ | constants / errors | `KERNEL_DEFAULTS`, `KERNEL_CAP`, `KERNEL_ERROR`, `KernelError` + 7 subclasses |
12
+ | resource-table | `ResourceTable` — handle-based `res_N` resource allocation |
13
+ | byte-stream | `BYTE_STREAM`, `isByteStream`, `asByteStream`, `createPipe`, `pipe`, `devNull`, `compose` |
14
+ | clock / rng | `Clock` (fixed for testing), `RNG` (seeded xorshift128+) |
15
+ | caps | `buildCaps`, `requireCap`, `CapsBuilder` — capability enforcement |
16
+ | message-port | `KernelMessagePort`, `createChannel` — IPC |
17
+ | service-registry | `ServiceRegistry` — `svc://` service lookup with `onLookupMiss` |
18
+ | tracer | `Tracer` — ring-buffer, `AsyncIterable` trace event stream |
19
+ | logger | `Logger`, `LOG_LEVEL` |
20
+ | chaos | `ChaosEngine` — fault injection |
21
+ | env | `Environment` — immutable env vars |
22
+ | signal / stdio | `SIGNAL`, `SignalController` (TERM/INT/HUP + `AbortSignal`), `Stdio` |
23
+ | kernel | `Kernel` — the facade tying every subsystem together |
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ npm install browsermesh-kernel
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ ```js
34
+ import { Kernel, KERNEL_CAP } from 'browsermesh-kernel'
35
+
36
+ const kernel = new Kernel()
37
+
38
+ // Create a tenant with scoped capabilities
39
+ const tenant = kernel.createTenant({
40
+ capabilities: [KERNEL_CAP.CLOCK, KERNEL_CAP.IPC, KERNEL_CAP.STDIO],
41
+ env: { MODE: 'sandbox' },
42
+ })
43
+
44
+ // Use kernel subsystems
45
+ const handle = kernel.resources.allocate('stream', myStream, tenant.id)
46
+ kernel.tracer.emit({ type: 'custom', tenant: tenant.id })
47
+
48
+ // Clean up
49
+ kernel.destroyTenant(tenant.id)
50
+ kernel.close()
51
+ ```
52
+
53
+ ## Origin
54
+
55
+ Extracted from the [clawser](https://github.com/johnhenry/clawser) browser
56
+ agent workspace, where it underpins workspace tenants, shell pipes, MCP
57
+ service registration, provider cost tracing, sandboxed code execution, and
58
+ daemon IPC — all as opt-in hooks (`clawser-kernel-integration.js`) that are
59
+ no-ops when the kernel isn't active.
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "browsermesh-kernel",
3
+ "version": "0.1.0",
4
+ "description": "Capability-secure browser microkernel with resource handles, IPC, tracing, and chaos engineering",
5
+ "type": "module",
6
+ "main": "src/index.mjs",
7
+ "exports": {
8
+ ".": "./src/index.mjs"
9
+ },
10
+ "files": [
11
+ "src/",
12
+ "LICENSE",
13
+ "README.md"
14
+ ],
15
+ "keywords": [
16
+ "microkernel",
17
+ "capabilities",
18
+ "ipc",
19
+ "tracing",
20
+ "chaos",
21
+ "browser"
22
+ ],
23
+ "license": "MIT",
24
+ "repository": { "type": "git", "url": "https://github.com/johnhenry/clawser" }
25
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * ByteStream — duck-typed stream protocol with utilities.
3
+ *
4
+ * Provides a protocol symbol, type checking, pipe utilities, and an in-memory
5
+ * pipe factory built on an internal AsyncBuffer (matching netway's pattern).
6
+ * EOF propagation on error ensures paired endpoints close cleanly.
7
+ *
8
+ * @module byte-stream
9
+ */
10
+
11
+ import { StreamClosedError } from './errors.mjs';
12
+ import { KERNEL_DEFAULTS } from './constants.mjs';
13
+
14
+ /**
15
+ * Symbol used to tag objects as ByteStream-compliant.
16
+ */
17
+ export const BYTE_STREAM = Symbol.for('kernel.ByteStream');
18
+
19
+ /**
20
+ * Check whether an object conforms to the ByteStream protocol.
21
+ * A ByteStream must have `read`, `write`, and `close` methods.
22
+ *
23
+ * @param {*} obj - The object to check.
24
+ * @returns {boolean}
25
+ */
26
+ export function isByteStream(obj) {
27
+ return obj != null &&
28
+ typeof obj.read === 'function' &&
29
+ typeof obj.write === 'function' &&
30
+ typeof obj.close === 'function';
31
+ }
32
+
33
+ /**
34
+ * Tag an object with the ByteStream symbol. Idempotent — already tagged
35
+ * objects are returned as-is.
36
+ *
37
+ * @param {Object} obj - The object to tag (must have read/write/close).
38
+ * @returns {Object} The tagged object.
39
+ */
40
+ export function asByteStream(obj) {
41
+ if (obj[BYTE_STREAM]) return obj;
42
+ obj[BYTE_STREAM] = true;
43
+ return obj;
44
+ }
45
+
46
+ /**
47
+ * Simple asynchronous FIFO buffer with blocking pull semantics.
48
+ * Matches netway's AsyncBuffer pattern. EOF propagation on error.
49
+ *
50
+ * @private
51
+ */
52
+ class AsyncBuffer {
53
+ #queue = [];
54
+ #waiters = [];
55
+ #writeClosed = false;
56
+ #readClosed = false;
57
+ #paused = false;
58
+ #highWaterMark;
59
+
60
+ constructor({ highWaterMark = 0 } = {}) {
61
+ this.#highWaterMark = highWaterMark;
62
+ }
63
+
64
+ push(data) {
65
+ if (this.#writeClosed || this.#readClosed) return false;
66
+ if (this.#waiters.length > 0) {
67
+ this.#waiters.shift()(data);
68
+ return true;
69
+ }
70
+ this.#queue.push(data);
71
+ if (this.#highWaterMark > 0 && this.#queue.length >= this.#highWaterMark) {
72
+ this.#paused = true;
73
+ return false;
74
+ }
75
+ return true;
76
+ }
77
+
78
+ pull() {
79
+ if (this.#queue.length > 0) {
80
+ const item = this.#queue.shift();
81
+ // Resume writes when queue drains below high-water mark
82
+ if (this.#paused && this.#queue.length < this.#highWaterMark) {
83
+ this.#paused = false;
84
+ }
85
+ return Promise.resolve(item);
86
+ }
87
+ if (this.#writeClosed || this.#readClosed) return Promise.resolve(null);
88
+ return new Promise(resolve => this.#waiters.push(resolve));
89
+ }
90
+
91
+ /** @returns {boolean} Whether the buffer is accepting writes */
92
+ get writable() { return !this.#writeClosed && !this.#readClosed && !this.#paused; }
93
+
94
+ /** Signal no more writes — existing buffered data can still be drained. */
95
+ closeWrite() {
96
+ this.#writeClosed = true;
97
+ this.#paused = false;
98
+ // Resolve waiters so blocked reads get null after queue is drained
99
+ for (const w of this.#waiters) w(null);
100
+ this.#waiters.length = 0;
101
+ }
102
+
103
+ /** Hard close — discard everything. */
104
+ closeRead() {
105
+ this.#readClosed = true;
106
+ this.#writeClosed = true;
107
+ this.#paused = false;
108
+ for (const w of this.#waiters) w(null);
109
+ this.#waiters.length = 0;
110
+ this.#queue.length = 0;
111
+ }
112
+
113
+ get isClosed() { return this.#writeClosed && this.#readClosed; }
114
+ }
115
+
116
+ /**
117
+ * Create an in-memory pipe returning `[reader, writer]` ByteStreams.
118
+ * Data written to `writer` can be read from `reader`. Closing either
119
+ * endpoint propagates EOF to the other.
120
+ *
121
+ * @param {Object} [opts={}]
122
+ * @param {number} [opts.highWaterMark=1024] - Maximum queue depth.
123
+ * @returns {[Object, Object]} `[reader, writer]` ByteStream pair.
124
+ */
125
+ export function createPipe({ highWaterMark = KERNEL_DEFAULTS.DEFAULT_STREAM_BUFFER_SIZE } = {}) {
126
+ const buffer = new AsyncBuffer({ highWaterMark });
127
+ let readerClosed = false;
128
+ let writerClosed = false;
129
+
130
+ const reader = {
131
+ [BYTE_STREAM]: true,
132
+ async read() {
133
+ if (readerClosed) return null;
134
+ return buffer.pull();
135
+ },
136
+ async write() {
137
+ throw new StreamClosedError();
138
+ },
139
+ async close() {
140
+ if (readerClosed) return;
141
+ readerClosed = true;
142
+ buffer.closeRead();
143
+ },
144
+ get closed() { return readerClosed; },
145
+ };
146
+
147
+ const writer = {
148
+ [BYTE_STREAM]: true,
149
+ async read() {
150
+ throw new StreamClosedError();
151
+ },
152
+ async write(data) {
153
+ if (writerClosed) throw new StreamClosedError();
154
+ if (!buffer.push(data)) {
155
+ writerClosed = true;
156
+ throw new StreamClosedError();
157
+ }
158
+ },
159
+ async close() {
160
+ if (writerClosed) return;
161
+ writerClosed = true;
162
+ // Signal EOF — existing data can still be drained by reader
163
+ buffer.closeWrite();
164
+ },
165
+ get closed() { return writerClosed; },
166
+ };
167
+
168
+ return [reader, writer];
169
+ }
170
+
171
+ /**
172
+ * Pipe all data from a source ByteStream to a destination ByteStream.
173
+ * Reads from src until null (EOF), writes each chunk to dst.
174
+ * On transport error, closes both endpoints for clean EOF propagation.
175
+ *
176
+ * @param {Object} src - Source ByteStream.
177
+ * @param {Object} dst - Destination ByteStream.
178
+ * @returns {Promise<void>}
179
+ */
180
+ export async function pipe(src, dst) {
181
+ try {
182
+ let chunk;
183
+ while ((chunk = await src.read()) !== null) {
184
+ await dst.write(chunk);
185
+ }
186
+ } catch (err) {
187
+ // EOF propagation on error — close both endpoints
188
+ await src.close().catch(() => {});
189
+ await dst.close().catch(() => {});
190
+ throw err;
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Create a sink ByteStream that discards all writes and returns null on read.
196
+ *
197
+ * @returns {Object} A ByteStream that acts as `/dev/null`.
198
+ */
199
+ export function devNull() {
200
+ return {
201
+ [BYTE_STREAM]: true,
202
+ async read() { return null; },
203
+ async write() {},
204
+ async close() {},
205
+ get closed() { return false; },
206
+ };
207
+ }
208
+
209
+ /**
210
+ * Transform protocol. A transform has a single method:
211
+ * `transform(chunk: Uint8Array) → Uint8Array | Promise<Uint8Array>`
212
+ *
213
+ * Transforms can be composed onto any ByteStream to create processing
214
+ * pipelines (compression, encryption, tracing, rate-limiting, etc.).
215
+ */
216
+
217
+ /**
218
+ * Compose one or more transforms onto a ByteStream, returning a new
219
+ * ByteStream that applies the transforms in order on read and in
220
+ * reverse order on write.
221
+ *
222
+ * @param {Object} stream - The underlying ByteStream.
223
+ * @param {...{ transform: function(Uint8Array): Uint8Array|Promise<Uint8Array> }} transforms - Transform objects.
224
+ * @returns {Object} A new ByteStream with transforms applied.
225
+ */
226
+ export function compose(stream, ...transforms) {
227
+ if (transforms.length === 0) return stream;
228
+
229
+ return {
230
+ [BYTE_STREAM]: true,
231
+
232
+ async read() {
233
+ const chunk = await stream.read();
234
+ if (chunk === null) return null;
235
+ let result = chunk;
236
+ for (const t of transforms) {
237
+ result = await t.transform(result);
238
+ }
239
+ return result;
240
+ },
241
+
242
+ async write(data) {
243
+ let result = data;
244
+ // Apply inverse transforms in reverse for writes (fall back to transform if no untransform)
245
+ for (let i = transforms.length - 1; i >= 0; i--) {
246
+ const t = transforms[i];
247
+ result = await (t.untransform ? t.untransform(result) : t.transform(result));
248
+ }
249
+ await stream.write(result);
250
+ },
251
+
252
+ async close() {
253
+ await stream.close();
254
+ },
255
+
256
+ get closed() { return stream.closed; },
257
+ };
258
+ }
package/src/caps.mjs ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Caps — capability builder and enforcement.
3
+ *
4
+ * Builds a frozen capabilities object from a kernel instance and a set
5
+ * of granted capability tags. The `requireCap` function enforces access
6
+ * control by throwing CapabilityDeniedError for missing capabilities.
7
+ *
8
+ * @module caps
9
+ */
10
+
11
+ import { KERNEL_CAP } from './constants.mjs';
12
+ import { CapabilityDeniedError } from './errors.mjs';
13
+
14
+ /**
15
+ * Build a frozen capabilities object from granted capability tags.
16
+ * Each granted tag maps to the corresponding kernel subsystem reference.
17
+ *
18
+ * @param {Object} kernel - Kernel instance with subsystem accessors.
19
+ * @param {string[]} grantedCaps - Array of KERNEL_CAP tags to grant.
20
+ * @returns {Readonly<Object>} Frozen capabilities object.
21
+ */
22
+ export function buildCaps(kernel, grantedCaps) {
23
+ const caps = {};
24
+ const granted = new Set(grantedCaps);
25
+ const hasAll = granted.has(KERNEL_CAP.ALL);
26
+
27
+ if (hasAll || granted.has(KERNEL_CAP.CLOCK)) {
28
+ caps.clock = kernel.clock;
29
+ }
30
+ if (hasAll || granted.has(KERNEL_CAP.RNG)) {
31
+ caps.rng = kernel.rng;
32
+ }
33
+ if (hasAll || granted.has(KERNEL_CAP.NET)) {
34
+ caps.net = true; // Network access marker — actual net object provided by netway
35
+ }
36
+ if (hasAll || granted.has(KERNEL_CAP.FS)) {
37
+ caps.fs = true; // FS access marker
38
+ }
39
+ if (hasAll || granted.has(KERNEL_CAP.IPC)) {
40
+ caps.ipc = kernel.services;
41
+ }
42
+ if (hasAll || granted.has(KERNEL_CAP.STDIO)) {
43
+ caps.stdio = true; // Stdio access marker — actual stdio is per-tenant
44
+ }
45
+ if (hasAll || granted.has(KERNEL_CAP.TRACE)) {
46
+ caps.trace = kernel.tracer;
47
+ }
48
+ if (hasAll || granted.has(KERNEL_CAP.CHAOS)) {
49
+ caps.chaos = kernel.chaos;
50
+ }
51
+ if (hasAll || granted.has(KERNEL_CAP.ENV)) {
52
+ caps.env = true; // Env access marker — actual env is per-tenant
53
+ }
54
+ if (hasAll || granted.has(KERNEL_CAP.SIGNAL)) {
55
+ caps.signal = true; // Signal access marker
56
+ }
57
+ if (hasAll || granted.has(KERNEL_CAP.MESH)) {
58
+ caps.mesh = true; // Mesh networking access marker
59
+ }
60
+ if (hasAll || granted.has(KERNEL_CAP.PAYMENT)) {
61
+ caps.payment = true; // Payment channel access marker
62
+ }
63
+ if (hasAll || granted.has(KERNEL_CAP.CONSENSUS)) {
64
+ caps.consensus = true; // Consensus protocol access marker
65
+ }
66
+
67
+ // Store the granted set for requireCap checks
68
+ caps._granted = Object.freeze([...granted]);
69
+
70
+ return Object.freeze(caps);
71
+ }
72
+
73
+ /**
74
+ * Require that a capability tag is present in a caps object.
75
+ *
76
+ * @param {Object} caps - Capabilities object from buildCaps.
77
+ * @param {string} capTag - The required KERNEL_CAP tag.
78
+ * @throws {CapabilityDeniedError} If the capability is not granted.
79
+ */
80
+ export function requireCap(caps, capTag) {
81
+ if (!caps || !caps._granted) throw new CapabilityDeniedError(capTag);
82
+ const granted = new Set(caps._granted);
83
+ if (granted.has(KERNEL_CAP.ALL)) return;
84
+ if (granted.has(capTag)) return;
85
+ throw new CapabilityDeniedError(capTag);
86
+ }
87
+
88
+ /**
89
+ * Builder class for constructing capabilities (alternative to buildCaps).
90
+ */
91
+ export class CapsBuilder {
92
+ /**
93
+ * Build capabilities from kernel and granted tags.
94
+ *
95
+ * @param {Object} kernel - Kernel instance.
96
+ * @param {string[]} grantedCaps - Granted capability tags.
97
+ * @returns {Readonly<Object>} Frozen capabilities object.
98
+ */
99
+ build(kernel, grantedCaps) {
100
+ return buildCaps(kernel, grantedCaps);
101
+ }
102
+ }
package/src/chaos.mjs ADDED
@@ -0,0 +1,151 @@
1
+ /**
2
+ * ChaosEngine — fault injection for testing and chaos engineering.
3
+ *
4
+ * Injects configurable latency, packet drops, disconnects, and network
5
+ * partitions. Supports global defaults and per-scope overrides. Uses RNG
6
+ * for deterministic fault patterns in replay mode.
7
+ *
8
+ * @module chaos
9
+ */
10
+
11
+ /**
12
+ * Fault injection engine with global and per-scope configuration.
13
+ */
14
+ export class ChaosEngine {
15
+ #enabled = false;
16
+ #globalConfig = { latencyMs: 0, dropRate: 0, disconnectRate: 0, partitionTargets: [] };
17
+ #scopeConfigs = new Map();
18
+ #rng;
19
+ #clock;
20
+
21
+ /**
22
+ * @param {Object} [opts={}]
23
+ * @param {Object} [opts.rng] - RNG instance for deterministic fault patterns.
24
+ * @param {Object} [opts.clock] - Clock instance for delay implementation.
25
+ */
26
+ constructor({ rng, clock } = {}) {
27
+ this.#rng = rng || null;
28
+ this.#clock = clock || null;
29
+ }
30
+
31
+ /**
32
+ * Enable the chaos engine. When disabled, all injection points are no-ops.
33
+ */
34
+ enable() {
35
+ this.#enabled = true;
36
+ }
37
+
38
+ /**
39
+ * Disable the chaos engine.
40
+ */
41
+ disable() {
42
+ this.#enabled = false;
43
+ }
44
+
45
+ /** Whether the engine is enabled. */
46
+ get enabled() { return this.#enabled; }
47
+
48
+ /**
49
+ * Configure global fault injection defaults.
50
+ *
51
+ * @param {Object} config
52
+ * @param {number} [config.latencyMs=0] - Added latency in ms.
53
+ * @param {number} [config.dropRate=0] - Drop probability (0-1).
54
+ * @param {number} [config.disconnectRate=0] - Disconnect probability (0-1).
55
+ * @param {string[]} [config.partitionTargets=[]] - Addresses to partition.
56
+ */
57
+ configure(config) {
58
+ this.#globalConfig = { ...this.#globalConfig, ...config };
59
+ }
60
+
61
+ /**
62
+ * Configure fault injection for a specific scope (overrides global).
63
+ *
64
+ * @param {string} scopeId - Scope identifier.
65
+ * @param {Object} config - Scope-specific configuration.
66
+ */
67
+ configureScope(scopeId, config) {
68
+ this.#scopeConfigs.set(scopeId, { ...config });
69
+ }
70
+
71
+ /**
72
+ * Remove scope-specific configuration, falling back to global defaults.
73
+ *
74
+ * @param {string} scopeId - Scope identifier.
75
+ */
76
+ removeScopeConfig(scopeId) {
77
+ this.#scopeConfigs.delete(scopeId);
78
+ }
79
+
80
+ /**
81
+ * Maybe inject latency delay.
82
+ *
83
+ * @param {string} [scopeId] - Optional scope for override lookup.
84
+ * @returns {Promise<void>}
85
+ */
86
+ async maybeDelay(scopeId) {
87
+ if (!this.#enabled) return;
88
+ const config = this.#getConfig(scopeId);
89
+ if (config.latencyMs <= 0) return;
90
+ if (this.#clock) {
91
+ await this.#clock.sleep(config.latencyMs);
92
+ } else {
93
+ await new Promise(resolve => setTimeout(resolve, config.latencyMs));
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Check whether a packet/message should be dropped.
99
+ *
100
+ * @param {string} [scopeId] - Optional scope for override lookup.
101
+ * @returns {boolean}
102
+ */
103
+ shouldDrop(scopeId) {
104
+ if (!this.#enabled) return false;
105
+ const config = this.#getConfig(scopeId);
106
+ if (config.dropRate <= 0) return false;
107
+ return this.#random() < config.dropRate;
108
+ }
109
+
110
+ /**
111
+ * Check whether a connection should be forcibly disconnected.
112
+ *
113
+ * @param {string} [scopeId] - Optional scope for override lookup.
114
+ * @returns {boolean}
115
+ */
116
+ shouldDisconnect(scopeId) {
117
+ if (!this.#enabled) return false;
118
+ const config = this.#getConfig(scopeId);
119
+ if (config.disconnectRate <= 0) return false;
120
+ return this.#random() < config.disconnectRate;
121
+ }
122
+
123
+ /**
124
+ * Check whether an address is partitioned (unreachable).
125
+ *
126
+ * @param {string} addr - Target address.
127
+ * @param {string} [scopeId] - Optional scope for override lookup.
128
+ * @returns {boolean}
129
+ */
130
+ isPartitioned(addr, scopeId) {
131
+ if (!this.#enabled) return false;
132
+ const config = this.#getConfig(scopeId);
133
+ return (config.partitionTargets || []).includes(addr);
134
+ }
135
+
136
+ #getConfig(scopeId) {
137
+ if (scopeId && this.#scopeConfigs.has(scopeId)) {
138
+ return this.#scopeConfigs.get(scopeId);
139
+ }
140
+ return this.#globalConfig;
141
+ }
142
+
143
+ #random() {
144
+ if (this.#rng) {
145
+ const bytes = this.#rng.get(4);
146
+ const val = (bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24)) >>> 0;
147
+ return val / 0x100000000;
148
+ }
149
+ return Math.random();
150
+ }
151
+ }
package/src/clock.mjs ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Clock — monotonic and wall-clock time abstraction.
3
+ *
4
+ * Provides both real and deterministic (fixed) clocks. The fixed clock
5
+ * is useful for testing — scheduled job logic becomes unit-testable
6
+ * without real timers.
7
+ *
8
+ * @module clock
9
+ */
10
+
11
+ /**
12
+ * Clock providing monotonic and wall-clock time plus async sleep.
13
+ */
14
+ export class Clock {
15
+ #monoFn;
16
+ #wallFn;
17
+ #sleepFn;
18
+
19
+ /**
20
+ * @param {Object} [opts={}]
21
+ * @param {function(): number} [opts.monoFn] - Monotonic time source (ms).
22
+ * @param {function(): number} [opts.wallFn] - Wall-clock time source (ms since epoch).
23
+ * @param {function(number): Promise<void>} [opts.sleepFn] - Async sleep implementation.
24
+ */
25
+ constructor({ monoFn, wallFn, sleepFn } = {}) {
26
+ this.#monoFn = monoFn || (() => performance.now());
27
+ this.#wallFn = wallFn || (() => Date.now());
28
+ this.#sleepFn = sleepFn || (ms => new Promise(resolve => setTimeout(resolve, ms)));
29
+ }
30
+
31
+ /**
32
+ * Get the current monotonic time in milliseconds.
33
+ * Monotonic time only moves forward and is not affected by clock adjustments.
34
+ *
35
+ * @returns {number} Monotonic timestamp in milliseconds.
36
+ */
37
+ nowMonotonic() {
38
+ return this.#monoFn();
39
+ }
40
+
41
+ /**
42
+ * Get the current wall-clock time in milliseconds since the Unix epoch.
43
+ *
44
+ * @returns {number} Wall-clock timestamp (ms since epoch).
45
+ */
46
+ nowWall() {
47
+ return this.#wallFn();
48
+ }
49
+
50
+ /**
51
+ * Sleep for the given number of milliseconds.
52
+ *
53
+ * @param {number} ms - Duration to sleep.
54
+ * @returns {Promise<void>}
55
+ */
56
+ async sleep(ms) {
57
+ return this.#sleepFn(ms);
58
+ }
59
+
60
+ /**
61
+ * Create a fixed (deterministic) clock for testing.
62
+ * The clock always returns the same values unless manually advanced.
63
+ *
64
+ * @param {number} mono - Fixed monotonic time value.
65
+ * @param {number} wall - Fixed wall-clock time value.
66
+ * @returns {Clock}
67
+ */
68
+ static fixed(mono, wall) {
69
+ let currentMono = mono;
70
+ let currentWall = wall;
71
+ return new Clock({
72
+ monoFn: () => currentMono,
73
+ wallFn: () => currentWall,
74
+ sleepFn: async (ms) => {
75
+ currentMono += ms;
76
+ currentWall += ms;
77
+ },
78
+ });
79
+ }
80
+ }