@dxos/log 0.8.4-main.fbb7a13 → 0.8.4-main.fcc0d83b33

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.
Files changed (73) hide show
  1. package/dist/lib/browser/chunk-V7FYKT4H.mjs +311 -0
  2. package/dist/lib/browser/chunk-V7FYKT4H.mjs.map +7 -0
  3. package/dist/lib/browser/index.mjs +236 -43
  4. package/dist/lib/browser/index.mjs.map +4 -4
  5. package/dist/lib/browser/meta.json +1 -1
  6. package/dist/lib/browser/platform/node/index.mjs +1 -1
  7. package/dist/lib/browser/platform/node/index.mjs.map +3 -3
  8. package/dist/lib/browser/processors/console-processor.mjs +6 -11
  9. package/dist/lib/browser/processors/console-processor.mjs.map +3 -3
  10. package/dist/lib/node-esm/chunk-5TBDXMQF.mjs +313 -0
  11. package/dist/lib/node-esm/chunk-5TBDXMQF.mjs.map +7 -0
  12. package/dist/lib/node-esm/index.mjs +236 -43
  13. package/dist/lib/node-esm/index.mjs.map +4 -4
  14. package/dist/lib/node-esm/meta.json +1 -1
  15. package/dist/lib/node-esm/platform/node/index.mjs +1 -1
  16. package/dist/lib/node-esm/platform/node/index.mjs.map +3 -3
  17. package/dist/lib/node-esm/processors/console-processor.mjs +6 -11
  18. package/dist/lib/node-esm/processors/console-processor.mjs.map +3 -3
  19. package/dist/types/src/context.d.ts +78 -2
  20. package/dist/types/src/context.d.ts.map +1 -1
  21. package/dist/types/src/dbg.d.ts +23 -0
  22. package/dist/types/src/dbg.d.ts.map +1 -0
  23. package/dist/types/src/decorators.d.ts.map +1 -1
  24. package/dist/types/src/environment.d.ts +24 -0
  25. package/dist/types/src/environment.d.ts.map +1 -0
  26. package/dist/types/src/environment.test.d.ts +2 -0
  27. package/dist/types/src/environment.test.d.ts.map +1 -0
  28. package/dist/types/src/experimental/ownership.d.ts.map +1 -1
  29. package/dist/types/src/index.d.ts +7 -3
  30. package/dist/types/src/index.d.ts.map +1 -1
  31. package/dist/types/src/jsonl.d.ts +53 -0
  32. package/dist/types/src/jsonl.d.ts.map +1 -0
  33. package/dist/types/src/jsonl.test.d.ts +2 -0
  34. package/dist/types/src/jsonl.test.d.ts.map +1 -0
  35. package/dist/types/src/log-buffer.d.ts +20 -0
  36. package/dist/types/src/log-buffer.d.ts.map +1 -0
  37. package/dist/types/src/log-buffer.test.d.ts +2 -0
  38. package/dist/types/src/log-buffer.test.d.ts.map +1 -0
  39. package/dist/types/src/log.d.ts +44 -1
  40. package/dist/types/src/log.d.ts.map +1 -1
  41. package/dist/types/src/meta.d.ts +20 -1
  42. package/dist/types/src/meta.d.ts.map +1 -1
  43. package/dist/types/src/platform/browser/index.d.ts.map +1 -1
  44. package/dist/types/src/platform/node/index.d.ts.map +1 -1
  45. package/dist/types/src/processors/browser-processor.d.ts.map +1 -1
  46. package/dist/types/src/processors/common.d.ts.map +1 -1
  47. package/dist/types/src/processors/console-processor.d.ts.map +1 -1
  48. package/dist/types/src/processors/file-processor.d.ts.map +1 -1
  49. package/dist/types/src/scope.d.ts.map +1 -1
  50. package/dist/types/tsconfig.tsbuildinfo +1 -1
  51. package/package.json +4 -9
  52. package/src/context.ts +242 -2
  53. package/src/dbg.ts +34 -0
  54. package/src/decorators.ts +1 -2
  55. package/src/environment.test.ts +222 -0
  56. package/src/environment.ts +129 -0
  57. package/src/experimental/classes.test.ts +0 -1
  58. package/src/index.ts +7 -4
  59. package/src/jsonl.test.ts +121 -0
  60. package/src/jsonl.ts +104 -0
  61. package/src/log-buffer.test.ts +158 -0
  62. package/src/log-buffer.ts +89 -0
  63. package/src/log.test.ts +0 -1
  64. package/src/log.ts +56 -12
  65. package/src/meta.ts +29 -1
  66. package/src/platform/node/index.ts +1 -2
  67. package/src/processors/browser-processor.ts +27 -28
  68. package/src/processors/console-processor.ts +5 -13
  69. package/src/processors/file-processor.ts +7 -9
  70. package/dist/lib/browser/chunk-GPOFUMLO.mjs +0 -133
  71. package/dist/lib/browser/chunk-GPOFUMLO.mjs.map +0 -7
  72. package/dist/lib/node-esm/chunk-QPYJZ4SO.mjs +0 -135
  73. package/dist/lib/node-esm/chunk-QPYJZ4SO.mjs.map +0 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/log",
3
- "version": "0.8.4-main.fbb7a13",
3
+ "version": "0.8.4-main.fcc0d83b33",
4
4
  "description": "Logger",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -43,9 +43,6 @@
43
43
  }
44
44
  },
45
45
  "types": "dist/types/src/index.d.ts",
46
- "typesVersions": {
47
- "*": {}
48
- },
49
46
  "files": [
50
47
  "dist",
51
48
  "src"
@@ -54,14 +51,12 @@
54
51
  "chalk": "^4.1.2",
55
52
  "js-yaml": "4.1.1",
56
53
  "lodash.defaultsdeep": "^4.6.1",
57
- "lodash.omit": "^4.5.0",
58
- "@dxos/node-std": "0.8.4-main.fbb7a13",
59
- "@dxos/util": "0.8.4-main.fbb7a13"
54
+ "@dxos/util": "0.8.4-main.fcc0d83b33",
55
+ "@dxos/node-std": "0.8.4-main.fcc0d83b33"
60
56
  },
61
57
  "devDependencies": {
62
58
  "@types/js-yaml": "^4.0.5",
63
- "@types/lodash.defaultsdeep": "^4.6.6",
64
- "@types/lodash.omit": "^4.5.7"
59
+ "@types/lodash.defaultsdeep": "^4.6.6"
65
60
  },
66
61
  "publishConfig": {
67
62
  "access": "public"
package/src/context.ts CHANGED
@@ -2,8 +2,11 @@
2
2
  // Copyright 2022 DXOS.org
3
3
  //
4
4
 
5
+ import { getDebugName } from '@dxos/util';
6
+
5
7
  import { type LogConfig, type LogFilter, type LogLevel } from './config';
6
8
  import { type CallMetadata } from './meta';
9
+ import { getRelativeFilename } from './processors/common';
7
10
  import { gatherLogInfoFromScope } from './scope';
8
11
 
9
12
  /**
@@ -12,14 +15,140 @@ import { gatherLogInfoFromScope } from './scope';
12
15
  export type LogContext = Record<string, any> | Error | any;
13
16
 
14
17
  /**
15
- * Record for current log line.
18
+ * Normalized call-site metadata suitable for display and serialization.
19
+ */
20
+ export interface ComputedLogMeta {
21
+ /** Relative filename (normalized via {@link getRelativeFilename}). */
22
+ filename?: string;
23
+
24
+ /** Line number within the file. */
25
+ line?: number;
26
+
27
+ /** Debug name of the enclosing scope (class instance), e.g. `MyClass#3`. */
28
+ context?: string;
29
+ }
30
+
31
+ /**
32
+ * Fields required to construct a {@link LogEntry}.
16
33
  */
17
- export interface LogEntry {
34
+ export interface LogEntryInit {
18
35
  level: LogLevel;
19
36
  message?: string;
20
37
  context?: LogContext;
21
38
  meta?: CallMetadata;
22
39
  error?: Error;
40
+ /** Overrides the default timestamp ({@link Date.now}). */
41
+ timestamp?: number;
42
+ }
43
+
44
+ /**
45
+ * Record for a single log line processed by the logging pipeline.
46
+ *
47
+ * Raw fields (`level`, `message`, `context`, `meta`, `error`) are preserved so processors
48
+ * can access unmodified inputs. Derived, lazily computed getters
49
+ * ({@link computedContext}, {@link computedError}, {@link computedMeta}) centralize
50
+ * the formatting logic shared across processors that write to serialized stores.
51
+ */
52
+ export class LogEntry {
53
+ /** Severity of this entry. */
54
+ readonly level: LogLevel;
55
+
56
+ /** Human-readable log message, if any. */
57
+ readonly message?: string;
58
+
59
+ /**
60
+ * Raw context value passed at the call site. May be a record, an Error, a function
61
+ * returning either, or any other value. Processors that need the flattened /
62
+ * JSON-safe view should prefer {@link computedContext}.
63
+ */
64
+ readonly context?: LogContext;
65
+
66
+ /** Raw call-site metadata injected by the log transform plugin. */
67
+ readonly meta?: CallMetadata;
68
+
69
+ /** Error passed to `log.catch()` / `log.error(err)`, if any. */
70
+ readonly error?: Error;
71
+
72
+ /** Unix timestamp in milliseconds of when the entry was created. */
73
+ readonly timestamp: number;
74
+
75
+ #computedContext: Record<string, unknown> | undefined;
76
+ #computedContextComputed = false;
77
+ #computedError: string | undefined;
78
+ #computedErrorComputed = false;
79
+ #computedMeta: ComputedLogMeta | undefined;
80
+ #resolvedContext: unknown;
81
+ #resolvedContextComputed = false;
82
+
83
+ constructor(init: LogEntryInit) {
84
+ this.level = init.level;
85
+ this.message = init.message;
86
+ this.context = init.context;
87
+ this.meta = init.meta;
88
+ this.error = init.error;
89
+ this.timestamp = init.timestamp ?? Date.now();
90
+ }
91
+
92
+ /**
93
+ * Resolve a function-valued {@link context} once and cache, so getters that
94
+ * independently consult the raw context don't trigger duplicate evaluation.
95
+ */
96
+ #resolveContext(): unknown {
97
+ if (!this.#resolvedContextComputed) {
98
+ this.#resolvedContext = typeof this.context === 'function' ? this.context() : this.context;
99
+ this.#resolvedContextComputed = true;
100
+ }
101
+ return this.#resolvedContext;
102
+ }
103
+
104
+ /**
105
+ * Flattened, JSON-safe context intended for serialized stores.
106
+ *
107
+ * - Single-level key-value map.
108
+ * - Primitives (`boolean`, `number`, `string`, `null`, `undefined`) pass through.
109
+ * - Non-primitive values are stringified one level deep via `JSON.stringify` (no recursion).
110
+ * - The reserved `error` / `err` keys are stripped — use {@link computedError} instead.
111
+ * - Properties from `@logInfo`-decorated members of the scope (`meta.S`) are inlined.
112
+ *
113
+ * Lazily computed and memoized on first access.
114
+ */
115
+ get computedContext(): Record<string, unknown> {
116
+ if (!this.#computedContextComputed) {
117
+ this.#computedContext = computeContext(this, this.#resolveContext());
118
+ this.#computedContextComputed = true;
119
+ }
120
+ return this.#computedContext ?? {};
121
+ }
122
+
123
+ /**
124
+ * Stringified error for this entry, sourced (in priority order) from:
125
+ * 1. {@link error} (e.g. `log.catch(err)`),
126
+ * 2. {@link context} when the context itself is an {@link Error},
127
+ * 3. `context.error` or `context.err`.
128
+ *
129
+ * Formatted as `.stack` when available, falling back to `.message` or `String(err)`.
130
+ *
131
+ * Lazily computed and memoized on first access.
132
+ */
133
+ get computedError(): string | undefined {
134
+ if (!this.#computedErrorComputed) {
135
+ this.#computedError = computeError(this, this.#resolveContext());
136
+ this.#computedErrorComputed = true;
137
+ }
138
+ return this.#computedError;
139
+ }
140
+
141
+ /**
142
+ * Normalized call-site metadata suitable for display / serialization.
143
+ *
144
+ * Lazily computed and memoized on first access.
145
+ */
146
+ get computedMeta(): ComputedLogMeta {
147
+ if (this.#computedMeta === undefined) {
148
+ this.#computedMeta = computeMeta(this);
149
+ }
150
+ return this.#computedMeta;
151
+ }
23
152
  }
24
153
 
25
154
  /**
@@ -73,6 +202,12 @@ export const shouldLog = (entry: LogEntry, filters?: LogFilter[]): boolean => {
73
202
  return results.length > 0 && !results.some((results) => results === false);
74
203
  };
75
204
 
205
+ /**
206
+ * Merges scope info, entry context, and error into a single record — preserving nested
207
+ * objects and Error instances so rich consumers (console inspect, devtools) can format them.
208
+ *
209
+ * Prefer {@link LogEntry.computedContext} for serialized / JSON outputs.
210
+ */
76
211
  export const getContextFromEntry = (entry: LogEntry): Record<string, any> | undefined => {
77
212
  let context;
78
213
  if (entry.meta) {
@@ -100,3 +235,108 @@ export const getContextFromEntry = (entry: LogEntry): Record<string, any> | unde
100
235
 
101
236
  return context && Object.keys(context).length > 0 ? context : undefined;
102
237
  };
238
+
239
+ const RESERVED_ERROR_KEYS = new Set(['error', 'err']);
240
+
241
+ const stringifyOneLevel = (value: unknown): unknown => {
242
+ if (value === null || value === undefined) {
243
+ return value;
244
+ }
245
+ const type = typeof value;
246
+ if (type === 'boolean' || type === 'number' || type === 'string') {
247
+ return value;
248
+ }
249
+ if (type === 'bigint') {
250
+ return (value as bigint).toString();
251
+ }
252
+ try {
253
+ return JSON.stringify(value);
254
+ } catch {
255
+ return String(value);
256
+ }
257
+ };
258
+
259
+ const computeContext = (entry: LogEntry, rawContext: unknown): Record<string, unknown> => {
260
+ const result: Record<string, unknown> = {};
261
+
262
+ const mergeInto = (source: unknown): void => {
263
+ if (!source || typeof source !== 'object') {
264
+ return;
265
+ }
266
+ for (const [key, value] of Object.entries(source)) {
267
+ if (RESERVED_ERROR_KEYS.has(key)) {
268
+ continue;
269
+ }
270
+ result[key] = stringifyOneLevel(value);
271
+ }
272
+ };
273
+
274
+ if (entry.meta?.S !== undefined && entry.meta.S !== null) {
275
+ mergeInto(gatherLogInfoFromScope(entry.meta.S));
276
+ }
277
+
278
+ if (rawContext instanceof Error) {
279
+ // Structured debug info attached to thrown errors lives on `.context`.
280
+ mergeInto((rawContext as any).context);
281
+ } else {
282
+ mergeInto(rawContext);
283
+ }
284
+
285
+ if (entry.error) {
286
+ mergeInto((entry.error as any).context);
287
+ }
288
+
289
+ return result;
290
+ };
291
+
292
+ const stringifyError = (err: unknown): string | undefined => {
293
+ if (err === null || err === undefined) {
294
+ return undefined;
295
+ }
296
+ if (err instanceof Error) {
297
+ return err.stack ?? err.message;
298
+ }
299
+ return String(err);
300
+ };
301
+
302
+ const computeError = (entry: LogEntry, rawContext: unknown): string | undefined => {
303
+ if (entry.error !== undefined) {
304
+ return stringifyError(entry.error);
305
+ }
306
+
307
+ if (rawContext instanceof Error) {
308
+ return stringifyError(rawContext);
309
+ }
310
+ if (rawContext && typeof rawContext === 'object') {
311
+ const ctxErr = (rawContext as any).error ?? (rawContext as any).err;
312
+ if (ctxErr !== undefined && ctxErr !== null) {
313
+ return stringifyError(ctxErr);
314
+ }
315
+ }
316
+
317
+ return undefined;
318
+ };
319
+
320
+ const computeMeta = (entry: LogEntry): ComputedLogMeta => {
321
+ if (!entry.meta) {
322
+ return {};
323
+ }
324
+
325
+ const scope = entry.meta.S;
326
+ // Skip globalThis and plain object scopes (module-level logs); only report class instances.
327
+ let scopeContext: string | undefined;
328
+ if (
329
+ typeof scope === 'object' &&
330
+ scope !== null &&
331
+ scope !== globalThis &&
332
+ Object.getPrototypeOf(scope) !== Object.prototype
333
+ ) {
334
+ scopeContext = getDebugName(scope);
335
+ }
336
+
337
+ return {
338
+ filename: getRelativeFilename(entry.meta.F),
339
+ line: entry.meta.L,
340
+ context: scopeContext,
341
+ };
342
+ };
package/src/dbg.ts ADDED
@@ -0,0 +1,34 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type CallMetadata } from './meta';
6
+ /**
7
+ * Debug-log value to console.
8
+ * Log's the expression being evaluated.
9
+ *
10
+ * If only one argument is provided, it will also be returned.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * dbg(foo, bar);
15
+ * // foo = 1
16
+ * // bar = 2
17
+ *
18
+ * bar = dbg(foo * 2);
19
+ * // foo * 2 = 2
20
+ * ```
21
+ *
22
+ * NOTE: The second argument is injected by the log transform plugin.
23
+ */
24
+ export const dbg: {
25
+ <T>(value: T, _meta?: CallMetadata): T;
26
+ } = <T>(arg: T, meta?: CallMetadata): T => {
27
+ if (meta?.A) {
28
+ console.log(`${meta.A[0]} =`, arg);
29
+ } else {
30
+ console.log(arg);
31
+ }
32
+
33
+ return arg;
34
+ };
package/src/decorators.ts CHANGED
@@ -2,9 +2,8 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { inspect } from 'node:util';
6
-
7
5
  import chalk from 'chalk';
6
+ import { inspect } from 'node:util';
8
7
 
9
8
  import { type LogMethods } from './log';
10
9
  import { type CallMetadata } from './meta';
@@ -0,0 +1,222 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import { inferEnvironmentName } from './environment';
8
+
9
+ const SUFFIX_PATTERN = /^[0-9a-z]{6}$/;
10
+
11
+ const splitId = (id: string): { scope: string; name: string; suffix: string } => {
12
+ const firstColon = id.indexOf(':');
13
+ const lastColon = id.lastIndexOf(':');
14
+ return {
15
+ scope: id.slice(0, firstColon),
16
+ name: id.slice(firstColon + 1, lastColon),
17
+ suffix: id.slice(lastColon + 1),
18
+ };
19
+ };
20
+
21
+ class FakeStorage implements Storage {
22
+ #map = new Map<string, string>();
23
+ get length(): number {
24
+ return this.#map.size;
25
+ }
26
+ clear(): void {
27
+ this.#map.clear();
28
+ }
29
+ getItem(key: string): string | null {
30
+ return this.#map.get(key) ?? null;
31
+ }
32
+ key(index: number): string | null {
33
+ return Array.from(this.#map.keys())[index] ?? null;
34
+ }
35
+ removeItem(key: string): void {
36
+ this.#map.delete(key);
37
+ }
38
+ setItem(key: string, value: string): void {
39
+ this.#map.set(key, value);
40
+ }
41
+ }
42
+
43
+ class FakeSharedWorkerGlobalScope {}
44
+ class FakeDedicatedWorkerGlobalScope {}
45
+ class FakeServiceWorkerGlobalScope {}
46
+
47
+ const makeWorkerScope = (Ctor: new () => object, ctorName: string, name?: string): Record<string, unknown> => {
48
+ const scope = Object.create(Ctor.prototype) as Record<string, unknown>;
49
+ scope[ctorName] = Ctor;
50
+ if (name !== undefined) {
51
+ scope.name = name;
52
+ }
53
+ return scope;
54
+ };
55
+
56
+ const makeWindowScope = (origin: string, sessionStorage?: FakeStorage): Record<string, unknown> => {
57
+ const scope: Record<string, unknown> = {
58
+ location: { origin },
59
+ sessionStorage,
60
+ };
61
+ scope.window = scope;
62
+ return scope;
63
+ };
64
+
65
+ describe('inferEnvironmentName', () => {
66
+ test('falls back to unknown:: when no recognized globals are present', ({ expect }) => {
67
+ const id = inferEnvironmentName({ scope: {} });
68
+ const { scope, name, suffix } = splitId(id);
69
+ expect(scope).toBe('unknown');
70
+ expect(name).toBe('');
71
+ expect(suffix).toMatch(SUFFIX_PATTERN);
72
+ });
73
+
74
+ test('shared worker: emits shared-worker:<name>:<suffix>', ({ expect }) => {
75
+ const scope = makeWorkerScope(FakeSharedWorkerGlobalScope, 'SharedWorkerGlobalScope', 'dxos-client-worker');
76
+ const id = inferEnvironmentName({ scope });
77
+ const parts = splitId(id);
78
+ expect(parts.scope).toBe('shared-worker');
79
+ expect(parts.name).toBe('dxos-client-worker');
80
+ expect(parts.suffix).toMatch(SUFFIX_PATTERN);
81
+ });
82
+
83
+ test('dedicated worker: emits dedicated-worker:<name>:<suffix>', ({ expect }) => {
84
+ const scope = makeWorkerScope(FakeDedicatedWorkerGlobalScope, 'DedicatedWorkerGlobalScope', 'dxos-client-worker');
85
+ const id = inferEnvironmentName({ scope });
86
+ const parts = splitId(id);
87
+ expect(parts.scope).toBe('dedicated-worker');
88
+ expect(parts.name).toBe('dxos-client-worker');
89
+ expect(parts.suffix).toMatch(SUFFIX_PATTERN);
90
+ });
91
+
92
+ test('service worker: emits service-worker::<suffix> (no name on global)', ({ expect }) => {
93
+ const scope = makeWorkerScope(FakeServiceWorkerGlobalScope, 'ServiceWorkerGlobalScope');
94
+ const id = inferEnvironmentName({ scope });
95
+ const parts = splitId(id);
96
+ expect(parts.scope).toBe('service-worker');
97
+ expect(parts.name).toBe('');
98
+ expect(parts.suffix).toMatch(SUFFIX_PATTERN);
99
+ });
100
+
101
+ test('anonymous worker: empty name segment', ({ expect }) => {
102
+ const scope = makeWorkerScope(FakeDedicatedWorkerGlobalScope, 'DedicatedWorkerGlobalScope');
103
+ const id = inferEnvironmentName({ scope });
104
+ const parts = splitId(id);
105
+ expect(parts.scope).toBe('dedicated-worker');
106
+ expect(parts.name).toBe('');
107
+ expect(parts.suffix).toMatch(SUFFIX_PATTERN);
108
+ });
109
+
110
+ test('window: emits tab:<origin>:<suffix>', ({ expect }) => {
111
+ const scope = makeWindowScope('https://composer.dxos.org', new FakeStorage());
112
+ const id = inferEnvironmentName({ scope });
113
+ const parts = splitId(id);
114
+ expect(parts.scope).toBe('tab');
115
+ expect(parts.name).toBe('https://composer.dxos.org');
116
+ expect(parts.suffix).toMatch(SUFFIX_PATTERN);
117
+ });
118
+
119
+ test('window: origin containing colons is preserved (split on first/last colon)', ({ expect }) => {
120
+ const scope = makeWindowScope('http://localhost:5173', new FakeStorage());
121
+ const id = inferEnvironmentName({ scope });
122
+ const parts = splitId(id);
123
+ expect(parts.scope).toBe('tab');
124
+ expect(parts.name).toBe('http://localhost:5173');
125
+ expect(parts.suffix).toMatch(SUFFIX_PATTERN);
126
+ });
127
+
128
+ test('window: suffix is stable across calls (sessionStorage backed)', ({ expect }) => {
129
+ const session = new FakeStorage();
130
+ const scope = makeWindowScope('http://localhost:5173', session);
131
+ const first = inferEnvironmentName({ scope });
132
+ const second = inferEnvironmentName({ scope });
133
+ expect(splitId(first).suffix).toBe(splitId(second).suffix);
134
+ });
135
+
136
+ test('window: different sessionStorage instances yield different suffixes (different tabs)', ({ expect }) => {
137
+ const scopeA = makeWindowScope('http://localhost:5173', new FakeStorage());
138
+ const scopeB = makeWindowScope('http://localhost:5173', new FakeStorage());
139
+ const idA = inferEnvironmentName({ scope: scopeA });
140
+ const idB = inferEnvironmentName({ scope: scopeB });
141
+ // Stronger than the typical "all suffixes differ" check since the chance of collision
142
+ // for a 6-char base-36 suffix is ~5e-10 per pair.
143
+ expect(splitId(idA).suffix).not.toBe(splitId(idB).suffix);
144
+ });
145
+
146
+ test('window: missing sessionStorage falls back to fresh random per call', ({ expect }) => {
147
+ const scope = makeWindowScope('https://example.com', undefined);
148
+ const first = inferEnvironmentName({ scope });
149
+ const second = inferEnvironmentName({ scope });
150
+ expect(splitId(first).suffix).toMatch(SUFFIX_PATTERN);
151
+ expect(splitId(second).suffix).toMatch(SUFFIX_PATTERN);
152
+ // Without storage we can't pin them; assert behavior, not equality.
153
+ });
154
+
155
+ test('worker suffixes vary across calls (no persistence)', ({ expect }) => {
156
+ const scope = makeWorkerScope(FakeDedicatedWorkerGlobalScope, 'DedicatedWorkerGlobalScope', 'w');
157
+ const ids = new Set<string>();
158
+ for (let index = 0; index < 16; index++) {
159
+ ids.add(splitId(inferEnvironmentName({ scope })).suffix);
160
+ }
161
+ expect(ids.size).toBeGreaterThan(1);
162
+ });
163
+
164
+ test('node: emits node:<pid>:<suffix>', ({ expect }) => {
165
+ const scope = { process: { pid: 12345, versions: { node: '24.1.0' } } };
166
+ const id = inferEnvironmentName({ scope });
167
+ const parts = splitId(id);
168
+ expect(parts.scope).toBe('node');
169
+ expect(parts.name).toBe('12345');
170
+ expect(parts.suffix).toMatch(SUFFIX_PATTERN);
171
+ });
172
+
173
+ test('node: missing pid yields empty name segment', ({ expect }) => {
174
+ const scope = { process: { versions: { node: '24.1.0' } } };
175
+ const parts = splitId(inferEnvironmentName({ scope }));
176
+ expect(parts.scope).toBe('node');
177
+ expect(parts.name).toBe('');
178
+ expect(parts.suffix).toMatch(SUFFIX_PATTERN);
179
+ });
180
+
181
+ test('node: process without versions.node is not detected as node', ({ expect }) => {
182
+ // e.g. a userland `process`-like object — should fall through to unknown.
183
+ const scope = { process: { pid: 1, versions: {} } };
184
+ const parts = splitId(inferEnvironmentName({ scope }));
185
+ expect(parts.scope).toBe('unknown');
186
+ });
187
+
188
+ test('cloudflare worker: emits cf-worker::<suffix>', ({ expect }) => {
189
+ const scope = { navigator: { userAgent: 'Cloudflare-Workers' } };
190
+ const id = inferEnvironmentName({ scope });
191
+ const parts = splitId(id);
192
+ expect(parts.scope).toBe('cf-worker');
193
+ expect(parts.name).toBe('');
194
+ expect(parts.suffix).toMatch(SUFFIX_PATTERN);
195
+ });
196
+
197
+ test('cloudflare worker: detected even when service-worker scope is also present', ({ expect }) => {
198
+ // CF workers in service-worker syntax expose ServiceWorkerGlobalScope; CF should win.
199
+ const scope = makeWorkerScope(FakeServiceWorkerGlobalScope, 'ServiceWorkerGlobalScope') as any;
200
+ scope.navigator = { userAgent: 'Cloudflare-Workers' };
201
+ const parts = splitId(inferEnvironmentName({ scope }));
202
+ expect(parts.scope).toBe('cf-worker');
203
+ });
204
+
205
+ test('cloudflare worker: detected even when process is also exposed (nodejs_compat)', ({ expect }) => {
206
+ // CF workers with the nodejs_compat flag expose a partial `process` polyfill;
207
+ // CF detection must still win over the node fallback.
208
+ const scope: Record<string, unknown> = {
209
+ navigator: { userAgent: 'Cloudflare-Workers' },
210
+ process: { pid: 1, versions: { node: '20.0.0' } },
211
+ };
212
+ const parts = splitId(inferEnvironmentName({ scope }));
213
+ expect(parts.scope).toBe('cf-worker');
214
+ });
215
+
216
+ test('safe to invoke with no arguments in the host runtime (does not throw)', ({ expect }) => {
217
+ expect(() => inferEnvironmentName()).not.toThrow();
218
+ const id = inferEnvironmentName();
219
+ // Format must always be 3 colon-separated segments with a valid suffix.
220
+ expect(splitId(id).suffix).toMatch(SUFFIX_PATTERN);
221
+ });
222
+ });