@dxos/log 0.8.4-main.84f28bd → 0.8.4-main.8baae0fced

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 (96) hide show
  1. package/LICENSE +102 -5
  2. package/dist/lib/browser/chunk-IEP6GGEX.mjs +23 -0
  3. package/dist/lib/browser/chunk-IEP6GGEX.mjs.map +7 -0
  4. package/dist/lib/browser/chunk-V7FYKT4H.mjs +311 -0
  5. package/dist/lib/browser/chunk-V7FYKT4H.mjs.map +7 -0
  6. package/dist/lib/browser/index.mjs +352 -200
  7. package/dist/lib/browser/index.mjs.map +4 -4
  8. package/dist/lib/browser/meta.json +1 -1
  9. package/dist/lib/browser/platform/browser/index.mjs +26 -0
  10. package/dist/lib/browser/platform/browser/index.mjs.map +7 -0
  11. package/dist/lib/browser/platform/node/index.mjs +21 -0
  12. package/dist/lib/browser/platform/node/index.mjs.map +7 -0
  13. package/dist/lib/browser/processors/console-processor.mjs +102 -0
  14. package/dist/lib/browser/processors/console-processor.mjs.map +7 -0
  15. package/dist/lib/browser/processors/console-stub.mjs +9 -0
  16. package/dist/lib/browser/processors/console-stub.mjs.map +7 -0
  17. package/dist/lib/node-esm/chunk-2SZHAWBN.mjs +24 -0
  18. package/dist/lib/node-esm/chunk-2SZHAWBN.mjs.map +7 -0
  19. package/dist/lib/node-esm/chunk-5TBDXMQF.mjs +313 -0
  20. package/dist/lib/node-esm/chunk-5TBDXMQF.mjs.map +7 -0
  21. package/dist/lib/node-esm/index.mjs +354 -287
  22. package/dist/lib/node-esm/index.mjs.map +4 -4
  23. package/dist/lib/node-esm/meta.json +1 -1
  24. package/dist/lib/node-esm/platform/browser/index.mjs +27 -0
  25. package/dist/lib/node-esm/platform/browser/index.mjs.map +7 -0
  26. package/dist/lib/node-esm/platform/node/index.mjs +22 -0
  27. package/dist/lib/node-esm/platform/node/index.mjs.map +7 -0
  28. package/dist/lib/node-esm/processors/console-processor.mjs +103 -0
  29. package/dist/lib/node-esm/processors/console-processor.mjs.map +7 -0
  30. package/dist/lib/node-esm/processors/console-stub.mjs +10 -0
  31. package/dist/lib/node-esm/processors/console-stub.mjs.map +7 -0
  32. package/dist/types/src/config.d.ts +2 -3
  33. package/dist/types/src/config.d.ts.map +1 -1
  34. package/dist/types/src/context.d.ts +79 -3
  35. package/dist/types/src/context.d.ts.map +1 -1
  36. package/dist/types/src/dbg.d.ts +23 -0
  37. package/dist/types/src/dbg.d.ts.map +1 -0
  38. package/dist/types/src/decorators.d.ts +1 -1
  39. package/dist/types/src/decorators.d.ts.map +1 -1
  40. package/dist/types/src/environment.d.ts +24 -0
  41. package/dist/types/src/environment.d.ts.map +1 -0
  42. package/dist/types/src/environment.test.d.ts +2 -0
  43. package/dist/types/src/environment.test.d.ts.map +1 -0
  44. package/dist/types/src/experimental/ownership.d.ts.map +1 -1
  45. package/dist/types/src/index.d.ts +7 -3
  46. package/dist/types/src/index.d.ts.map +1 -1
  47. package/dist/types/src/jsonl.d.ts +53 -0
  48. package/dist/types/src/jsonl.d.ts.map +1 -0
  49. package/dist/types/src/jsonl.test.d.ts +2 -0
  50. package/dist/types/src/jsonl.test.d.ts.map +1 -0
  51. package/dist/types/src/log-buffer.d.ts +20 -0
  52. package/dist/types/src/log-buffer.d.ts.map +1 -0
  53. package/dist/types/src/log-buffer.test.d.ts +2 -0
  54. package/dist/types/src/log-buffer.test.d.ts.map +1 -0
  55. package/dist/types/src/log.d.ts +55 -18
  56. package/dist/types/src/log.d.ts.map +1 -1
  57. package/dist/types/src/meta.d.ts +20 -1
  58. package/dist/types/src/meta.d.ts.map +1 -1
  59. package/dist/types/src/options.d.ts +1 -6
  60. package/dist/types/src/options.d.ts.map +1 -1
  61. package/dist/types/src/platform/browser/index.d.ts.map +1 -1
  62. package/dist/types/src/platform/index.d.ts +1 -1
  63. package/dist/types/src/platform/index.d.ts.map +1 -1
  64. package/dist/types/src/platform/node/index.d.ts.map +1 -1
  65. package/dist/types/src/processors/browser-processor.d.ts.map +1 -1
  66. package/dist/types/src/processors/common.d.ts.map +1 -1
  67. package/dist/types/src/processors/console-processor.d.ts +1 -1
  68. package/dist/types/src/processors/console-processor.d.ts.map +1 -1
  69. package/dist/types/src/processors/file-processor.d.ts.map +1 -1
  70. package/dist/types/src/processors/index.d.ts +3 -3
  71. package/dist/types/src/processors/index.d.ts.map +1 -1
  72. package/dist/types/src/scope.d.ts.map +1 -1
  73. package/dist/types/tsconfig.tsbuildinfo +1 -1
  74. package/package.json +33 -18
  75. package/src/config.ts +3 -2
  76. package/src/context.ts +280 -10
  77. package/src/dbg.ts +34 -0
  78. package/src/decorators.ts +3 -3
  79. package/src/environment.test.ts +222 -0
  80. package/src/environment.ts +129 -0
  81. package/src/experimental/classes.test.ts +1 -1
  82. package/src/index.ts +7 -4
  83. package/src/jsonl.test.ts +121 -0
  84. package/src/jsonl.ts +104 -0
  85. package/src/log-buffer.test.ts +158 -0
  86. package/src/log-buffer.ts +89 -0
  87. package/src/log.test.ts +58 -23
  88. package/src/log.ts +147 -60
  89. package/src/meta.ts +29 -1
  90. package/src/options.ts +27 -11
  91. package/src/platform/index.ts +1 -1
  92. package/src/processors/browser-processor.ts +32 -29
  93. package/src/processors/console-processor.ts +11 -15
  94. package/src/processors/file-processor.ts +9 -8
  95. package/src/processors/index.ts +3 -3
  96. package/src/scope.ts +1 -1
@@ -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
+ });
@@ -0,0 +1,129 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ /**
6
+ * Per-tab session-storage key used to stabilize the random suffix for the main thread.
7
+ * `sessionStorage` is per-tab and survives reloads of the same tab.
8
+ */
9
+ const TAB_SUFFIX_STORAGE_KEY = '@dxos/log:env-suffix';
10
+
11
+ const SUFFIX_LENGTH = 6;
12
+
13
+ /**
14
+ * Generate a 6-character lowercase-alphanumeric suffix.
15
+ * Cryptographically random when `crypto` is available; falls back to `Math.random`.
16
+ */
17
+ const randomSuffix = (): string => {
18
+ const cryptoRef = (globalThis as any).crypto as Crypto | undefined;
19
+ if (cryptoRef?.getRandomValues) {
20
+ const bytes = new Uint8Array(SUFFIX_LENGTH);
21
+ cryptoRef.getRandomValues(bytes);
22
+ // Each byte mod 36 maps to one base-36 char; mild modulo bias is acceptable for IDs.
23
+ let suffix = '';
24
+ for (const byte of bytes) {
25
+ suffix += (byte % 36).toString(36);
26
+ }
27
+ return suffix;
28
+ }
29
+ return Math.random()
30
+ .toString(36)
31
+ .slice(2, 2 + SUFFIX_LENGTH)
32
+ .padEnd(SUFFIX_LENGTH, '0');
33
+ };
34
+
35
+ /**
36
+ * Look up (or create) a stable per-tab suffix in `sessionStorage`.
37
+ * Same browser tab keeps the same suffix across reloads; different tabs get
38
+ * different suffixes; private/blocked storage falls back to a fresh random.
39
+ */
40
+ const getOrCreateTabSuffix = (session: Storage | undefined): string => {
41
+ if (!session) {
42
+ return randomSuffix();
43
+ }
44
+ try {
45
+ const existing = session.getItem(TAB_SUFFIX_STORAGE_KEY);
46
+ if (existing && existing.length > 0) {
47
+ return existing;
48
+ }
49
+ const suffix = randomSuffix();
50
+ session.setItem(TAB_SUFFIX_STORAGE_KEY, suffix);
51
+ return suffix;
52
+ } catch {
53
+ return randomSuffix();
54
+ }
55
+ };
56
+
57
+ const isInstanceOf = (scope: unknown, ctorName: string): boolean => {
58
+ const ctor = (scope as any)?.[ctorName];
59
+ return typeof ctor === 'function' && scope instanceof ctor;
60
+ };
61
+
62
+ /**
63
+ * Cloudflare Workers set `navigator.userAgent` to this exact string, regardless of
64
+ * whether the worker is using modules or service-worker syntax. This is the most
65
+ * stable signal and is checked before browser worker scopes because CF workers in
66
+ * service-worker syntax also expose a `ServiceWorkerGlobalScope`.
67
+ */
68
+ const CF_WORKER_USER_AGENT = 'Cloudflare-Workers';
69
+
70
+ /**
71
+ * Options for {@link inferEnvironmentName}.
72
+ * Tests pass a custom `scope` to simulate worker / window globals.
73
+ */
74
+ export type InferEnvironmentNameOptions = {
75
+ scope?: unknown;
76
+ };
77
+
78
+ /**
79
+ * Infer a writer/environment identifier from the current execution context.
80
+ *
81
+ * Safe to invoke in any JS runtime — never throws. Falls back to `unknown::<suffix>`
82
+ * when the runtime can't be classified.
83
+ *
84
+ * Format is always three colon-separated segments: `<scope>:<name>:<suffix>`.
85
+ * - `scope` — `tab | dedicated-worker | shared-worker | service-worker | cf-worker | node | unknown`.
86
+ * - `name` — `location.origin` for tabs, `self.name` for browser workers, `process.pid`
87
+ * for node, empty for cf-workers / service workers / anonymous workers.
88
+ * Note that `name` may itself contain `:` (e.g. `http://localhost:5173`); when parsing,
89
+ * take the first segment as scope and the last as suffix, and treat everything in
90
+ * between as the name.
91
+ * - `suffix` — 6-char random; stable per-tab via `sessionStorage`, fresh per worker / process instance.
92
+ */
93
+ export const inferEnvironmentName = (options: InferEnvironmentNameOptions = {}): string => {
94
+ const scope: any = options.scope ?? globalThis;
95
+
96
+ // Cloudflare Workers — checked first because in service-worker syntax mode CF
97
+ // workers also report as `ServiceWorkerGlobalScope`.
98
+ if (scope.navigator?.userAgent === CF_WORKER_USER_AGENT) {
99
+ return `cf-worker::${randomSuffix()}`;
100
+ }
101
+
102
+ if (isInstanceOf(scope, 'SharedWorkerGlobalScope')) {
103
+ return `shared-worker:${scope.name ?? ''}:${randomSuffix()}`;
104
+ }
105
+
106
+ if (isInstanceOf(scope, 'ServiceWorkerGlobalScope')) {
107
+ return `service-worker::${randomSuffix()}`;
108
+ }
109
+
110
+ if (isInstanceOf(scope, 'DedicatedWorkerGlobalScope')) {
111
+ return `dedicated-worker:${scope.name ?? ''}:${randomSuffix()}`;
112
+ }
113
+
114
+ if (scope.window !== undefined && scope.window === scope) {
115
+ const origin = scope.location?.origin ?? '';
116
+ return `tab:${origin}:${getOrCreateTabSuffix(scope.sessionStorage)}`;
117
+ }
118
+
119
+ // Node.js — `process.versions.node` is the canonical signal.
120
+ // Avoid touching the real `process` global directly so tests passing `scope: {}`
121
+ // can opt out of node detection.
122
+ const proc = scope.process;
123
+ if (proc && typeof proc === 'object' && proc.versions?.node) {
124
+ const pid = typeof proc.pid === 'number' ? String(proc.pid) : '';
125
+ return `node:${pid}:${randomSuffix()}`;
126
+ }
127
+
128
+ return `unknown::${randomSuffix()}`;
129
+ };
@@ -4,8 +4,8 @@
4
4
 
5
5
  import { describe, test } from 'vitest';
6
6
 
7
- import { debugInfo, ownershipClass } from './ownership';
8
7
  import { log } from '../log';
8
+ import { debugInfo, ownershipClass } from './ownership';
9
9
 
10
10
  describe('classes', function () {
11
11
  test('field instance', function () {
package/src/index.ts CHANGED
@@ -2,18 +2,21 @@
2
2
  // Copyright 2022 DXOS.org
3
3
  //
4
4
 
5
- import omit from 'lodash.omit';
6
-
7
- import { pick } from '@dxos/util';
5
+ import { omit, pick } from '@dxos/util';
8
6
 
9
7
  export { omit, pick };
10
8
 
11
9
  export * from './config';
12
10
  export * from './context';
11
+ export * from './environment';
12
+ export * from './jsonl';
13
13
  export * from './log';
14
14
  export { parseFilter } from './options';
15
15
  export * from './processors';
16
16
  export * from './scope';
17
- export * from './meta';
17
+ export type { CallMetadata } from './meta';
18
+ export { LOG_META_MARKER, isLogMeta } from './meta';
19
+ export { dbg } from './dbg';
20
+ export * from './log-buffer';
18
21
 
19
22
  export { getCurrentOwnershipScope } from './experimental/ownership';
@@ -0,0 +1,121 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import { LogEntry, type LogEntryInit, LogLevel } from './index';
8
+ import { serializeToJsonl } from './jsonl';
9
+
10
+ const createEntry = (overrides: Partial<LogEntryInit> = {}): LogEntry =>
11
+ new LogEntry({
12
+ level: LogLevel.INFO,
13
+ message: 'test message',
14
+ ...overrides,
15
+ });
16
+
17
+ const parseLine = (line: string | undefined) => {
18
+ if (line === undefined) {
19
+ throw new Error('expected line, got undefined');
20
+ }
21
+ return JSON.parse(line) as Record<string, unknown>;
22
+ };
23
+
24
+ describe('serializeToJsonl', () => {
25
+ test('returns undefined for TRACE level entries', ({ expect }) => {
26
+ expect(serializeToJsonl(createEntry({ level: LogLevel.TRACE }))).toBeUndefined();
27
+ });
28
+
29
+ test('emits compact record with t/l/m fields', ({ expect }) => {
30
+ const record = parseLine(serializeToJsonl(createEntry({ message: 'hello' })));
31
+ expect(record.t).toBeTypeOf('string');
32
+ expect(record.l).toBe('I');
33
+ expect(record.m).toBe('hello');
34
+ });
35
+
36
+ test('uses level letters D V I W E', ({ expect }) => {
37
+ const levels: Array<[LogLevel, string]> = [
38
+ [LogLevel.DEBUG, 'D'],
39
+ [LogLevel.VERBOSE, 'V'],
40
+ [LogLevel.INFO, 'I'],
41
+ [LogLevel.WARN, 'W'],
42
+ [LogLevel.ERROR, 'E'],
43
+ ];
44
+ for (const [level, letter] of levels) {
45
+ const record = parseLine(serializeToJsonl(createEntry({ level, message: '' })));
46
+ expect(record.l).toBe(letter);
47
+ }
48
+ });
49
+
50
+ test('omits optional fields when not present', ({ expect }) => {
51
+ const record = parseLine(serializeToJsonl(createEntry({ message: 'plain' })));
52
+ expect(record.f).toBeUndefined();
53
+ expect(record.n).toBeUndefined();
54
+ expect(record.o).toBeUndefined();
55
+ expect(record.e).toBeUndefined();
56
+ expect(record.c).toBeUndefined();
57
+ expect(record.i).toBeUndefined();
58
+ });
59
+
60
+ test('captures filename and line metadata', ({ expect }) => {
61
+ const record = parseLine(
62
+ serializeToJsonl(
63
+ createEntry({
64
+ meta: { F: '/home/user/project/packages/sdk/test.ts', L: 42, S: undefined },
65
+ }),
66
+ ),
67
+ );
68
+ expect(record.f).toBe('packages/sdk/test.ts');
69
+ expect(record.n).toBe(42);
70
+ });
71
+
72
+ test('captures error stack via computedError', ({ expect }) => {
73
+ const error = new Error('boom');
74
+ const record = parseLine(serializeToJsonl(createEntry({ error })));
75
+ expect(record.e).toContain('boom');
76
+ });
77
+
78
+ test('captures context as flat JSON string', ({ expect }) => {
79
+ const record = parseLine(serializeToJsonl(createEntry({ context: { count: 3, name: 'x' } })));
80
+ expect(record.c).toBeTypeOf('string');
81
+ const ctx = JSON.parse(record.c as string);
82
+ expect(ctx.count).toBe(3);
83
+ expect(ctx.name).toBe('x');
84
+ });
85
+
86
+ test('flattens nested objects in context (one level)', ({ expect }) => {
87
+ const record = parseLine(serializeToJsonl(createEntry({ context: { nested: { a: 1, b: 2 } } })));
88
+ const ctx = JSON.parse(record.c as string);
89
+ // computedContext converts nested objects to JSON strings via stringifyOneLevel.
90
+ expect(typeof ctx.nested).toBe('string');
91
+ expect(ctx.nested).toBe('{"a":1,"b":2}');
92
+ });
93
+
94
+ test('does not truncate long context (idb-store style)', ({ expect }) => {
95
+ const longValue = 'x'.repeat(2_000);
96
+ const record = parseLine(serializeToJsonl(createEntry({ context: { data: longValue } })));
97
+ const ctx = JSON.parse(record.c as string);
98
+ expect(ctx.data).toBe(longValue);
99
+ });
100
+
101
+ test('embeds env identifier in `i` field', ({ expect }) => {
102
+ const record = parseLine(
103
+ serializeToJsonl(createEntry({ message: 'm' }), { env: 'tab:http://localhost:5173:abc123' }),
104
+ );
105
+ expect(record.i).toBe('tab:http://localhost:5173:abc123');
106
+ });
107
+
108
+ test('omits `i` when env is not provided', ({ expect }) => {
109
+ const record = parseLine(serializeToJsonl(createEntry({ message: 'm' })));
110
+ expect(record.i).toBeUndefined();
111
+ });
112
+
113
+ test('output is valid single-line JSON (no embedded newlines)', ({ expect }) => {
114
+ const line = serializeToJsonl(createEntry({ message: 'multi\nline\nmessage' }));
115
+ expect(line).toBeDefined();
116
+ expect(line!.indexOf('\n')).toBe(-1);
117
+ // The newlines inside the message are escaped by JSON.stringify.
118
+ const record = JSON.parse(line!);
119
+ expect(record.m).toBe('multi\nline\nmessage');
120
+ });
121
+ });
package/src/jsonl.ts ADDED
@@ -0,0 +1,104 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { LogLevel, shortLevelName } from './config';
6
+ import { type LogEntry } from './context';
7
+
8
+ /**
9
+ * Compact JSONL log record with short property names for small serialized size.
10
+ *
11
+ * Field names are intentionally one character to keep on-disk / on-wire size minimal —
12
+ * a single record can be emitted thousands of times per session.
13
+ */
14
+ export type LogRecord = {
15
+ /** ISO timestamp. */
16
+ t: string;
17
+ /** Level letter (D, V, I, W, E). */
18
+ l: string;
19
+ /** Message. */
20
+ m: string;
21
+ /** File path. */
22
+ f?: string;
23
+ /** Line number. */
24
+ n?: number;
25
+ /** Object/scope from which the log was emitted. */
26
+ o?: string;
27
+ /** Error stack. */
28
+ e?: string;
29
+ /** Context JSON (already a string of pre-stringified flat key/values). */
30
+ c?: string;
31
+ /** Environment identifier (see {@link inferEnvironmentName}). */
32
+ i?: string;
33
+ };
34
+
35
+ /**
36
+ * Options for {@link serializeToJsonl}.
37
+ */
38
+ export type SerializeToJsonlOptions = {
39
+ /**
40
+ * Environment identifier embedded in the record's `i` field.
41
+ * Use {@link inferEnvironmentName} for a default that disambiguates tabs and workers.
42
+ */
43
+ env?: string;
44
+ };
45
+
46
+ /**
47
+ * Serialize a {@link LogEntry} to a single compact JSON line (no trailing newline).
48
+ *
49
+ * Returns `undefined` for entries at TRACE level — callers can use this to skip them.
50
+ *
51
+ * The output is compatible with newline-delimited JSON (NDJSON / JSONL): join multiple
52
+ * results with `\n` for a stream / file representation.
53
+ *
54
+ * Context is taken from {@link LogEntry.computedContext}, which has already flattened
55
+ * nested objects to strings (see `stringifyOneLevel` in `context.ts`), so the resulting
56
+ * `c` field is a JSON string of a flat `Record<string, primitive>` map. The function
57
+ * does **not** truncate context — callers that care about line size should set their own
58
+ * cap before calling, or trim post-hoc.
59
+ */
60
+ export const serializeToJsonl = (entry: LogEntry, opts: SerializeToJsonlOptions = {}): string | undefined => {
61
+ if (entry.level <= LogLevel.TRACE) {
62
+ return undefined;
63
+ }
64
+
65
+ const { filename, line, context: scopeName } = entry.computedMeta;
66
+
67
+ const record: LogRecord = {
68
+ t: new Date(entry.timestamp).toISOString(),
69
+ l: shortLevelName[entry.level] ?? '?',
70
+ m: entry.message ?? '',
71
+ };
72
+
73
+ if (filename !== undefined) {
74
+ record.f = filename;
75
+ }
76
+ if (line !== undefined) {
77
+ record.n = line;
78
+ }
79
+ if (scopeName !== undefined) {
80
+ record.o = scopeName;
81
+ }
82
+ if (entry.computedError !== undefined) {
83
+ record.e = entry.computedError;
84
+ }
85
+ if (opts.env !== undefined) {
86
+ record.i = opts.env;
87
+ }
88
+
89
+ const computedContext = entry.computedContext;
90
+ if (Object.keys(computedContext).length > 0) {
91
+ try {
92
+ record.c = JSON.stringify(computedContext);
93
+ } catch {
94
+ // Skip context that throws during serialization. `computedContext` is already flattened
95
+ // via `stringifyOneLevel`, so this is best-effort belt-and-suspenders.
96
+ }
97
+ }
98
+
99
+ try {
100
+ return JSON.stringify(record);
101
+ } catch {
102
+ return undefined;
103
+ }
104
+ };