effect-inspect 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.
Files changed (38) hide show
  1. package/README.md +122 -0
  2. package/app/dist/client/assets/index-smV05cfr.js +25 -0
  3. package/app/dist/client/assets/rolldown-runtime-CbXtAM7H.js +1 -0
  4. package/app/dist/client/assets/routes-TKgeFdSW.js +5 -0
  5. package/app/dist/client/assets/styles-B3rAZGvS.css +2 -0
  6. package/app/dist/server/assets/_tanstack-start-manifest_v-Co953HeC.js +20 -0
  7. package/app/dist/server/assets/empty-plugin-adapters-D9UWiqvJ.js +5 -0
  8. package/app/dist/server/assets/router-CN98Ramo.js +491 -0
  9. package/app/dist/server/assets/routes-eZ4XqxE9.js +3999 -0
  10. package/app/dist/server/assets/start-5Z2QO8AU.js +4 -0
  11. package/app/dist/server/server.js +1812 -0
  12. package/dist/cli.d.ts +3 -0
  13. package/dist/cli.js +29 -0
  14. package/dist/client/Client.d.ts +52 -0
  15. package/dist/client/Client.js +224 -0
  16. package/dist/client/Edge.d.ts +31 -0
  17. package/dist/client/Edge.js +108 -0
  18. package/dist/client/Inspect.d.ts +49 -0
  19. package/dist/client/Inspect.js +55 -0
  20. package/dist/client/Tracer.d.ts +31 -0
  21. package/dist/client/Tracer.js +119 -0
  22. package/dist/collector/Config.d.ts +8 -0
  23. package/dist/collector/Config.js +9 -0
  24. package/dist/collector/Server.d.ts +24 -0
  25. package/dist/collector/Server.js +172 -0
  26. package/dist/collector/Store.d.ts +86 -0
  27. package/dist/collector/Store.js +119 -0
  28. package/dist/collector/WebApp.d.ts +3 -0
  29. package/dist/collector/WebApp.js +36 -0
  30. package/dist/collector/main.d.ts +1 -0
  31. package/dist/collector/main.js +22 -0
  32. package/dist/index.d.ts +3 -0
  33. package/dist/index.js +3 -0
  34. package/dist/protocol/Codec.d.ts +575 -0
  35. package/dist/protocol/Codec.js +50 -0
  36. package/dist/protocol/Schema.d.ts +1237 -0
  37. package/dist/protocol/Schema.js +327 -0
  38. package/package.json +85 -0
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The tracer and logger that observe the host program.
3
+ *
4
+ * Both delegate rather than replace: the tracer wraps whatever tracer is
5
+ * already installed and the logger is merged into the existing set, so adding
6
+ * the inspect layer never removes behaviour the program already had. Every
7
+ * method here is called on the host program's own fibers, so none of them may
8
+ * fail, suspend, or do real work — they narrow a value to the protocol's domain
9
+ * and hand it to a queue.
10
+ */
11
+ import { Cause, Clock, Effect, Logger, References, Tracer } from 'effect';
12
+ import { toAttributes, toJson, toOutcome } from './Edge.js';
13
+ const parentOf = (span) => {
14
+ const parent = span.parent;
15
+ if (parent._tag === 'None')
16
+ return undefined;
17
+ const value = parent.value;
18
+ return value._tag === 'Span'
19
+ ? { _tag: 'LocalParent', spanId: value.spanId }
20
+ : {
21
+ _tag: 'ExternalParent',
22
+ spanId: value.spanId,
23
+ traceId: value.traceId,
24
+ sampled: value.sampled,
25
+ };
26
+ };
27
+ /**
28
+ * Creates a tracer that mirrors every span to the client while delegating to
29
+ * the current tracer.
30
+ *
31
+ * `SpanStart` is sent once when the span opens and `SpanEnd` when it closes,
32
+ * carrying the end time, the flattened outcome, and the span's attributes for
33
+ * the collector to merge. See `span` for why the attributes are not a strict
34
+ * delta.
35
+ */
36
+ export const make = (client) => Effect.map(Effect.tracer, (currentTracer) => Tracer.make({
37
+ span(options) {
38
+ const span = currentTracer.span(options);
39
+ // `SpanStart` goes out immediately, so a live trace shows the span as
40
+ // soon as it opens. Its attributes are usually empty at this instant:
41
+ // Effect applies the ones passed to `Effect.withSpan` through
42
+ // `span.attribute` *after* this method returns, and it does so in the
43
+ // same synchronous burst as any later annotation, so there is no timing
44
+ // signal that separates "initial" from "late".
45
+ //
46
+ // Rather than guess, `SpanEnd` carries the span's whole attribute map.
47
+ // The protocol has the collector merge those over what it already has,
48
+ // and merging is idempotent, so a resent attribute costs a few bytes
49
+ // and changes nothing — which is the cheaper error than dropping an
50
+ // attribute the user set.
51
+ const parent = parentOf(span);
52
+ client.sendUnsafe({
53
+ _tag: 'SpanStart',
54
+ sessionId: client.sessionId,
55
+ spanId: span.spanId,
56
+ traceId: span.traceId,
57
+ ...(parent === undefined ? {} : { parent }),
58
+ name: span.name,
59
+ kind: span.kind,
60
+ startTime: options.startTime,
61
+ attributes: toAttributes(span.attributes),
62
+ sampled: span.sampled,
63
+ });
64
+ const inheritedEvent = span.event.bind(span);
65
+ span.event = (name, startTime, attributes) => {
66
+ client.sendUnsafe({
67
+ _tag: 'SpanEvent',
68
+ sessionId: client.sessionId,
69
+ spanId: span.spanId,
70
+ name,
71
+ time: startTime,
72
+ attributes: toAttributes(attributes ?? {}),
73
+ });
74
+ return inheritedEvent(name, startTime, attributes);
75
+ };
76
+ const inheritedEnd = span.end.bind(span);
77
+ span.end = (endTime, exit) => {
78
+ inheritedEnd(endTime, exit);
79
+ client.sendUnsafe({
80
+ _tag: 'SpanEnd',
81
+ sessionId: client.sessionId,
82
+ spanId: span.spanId,
83
+ endTime,
84
+ outcome: toOutcome(exit),
85
+ attributes: toAttributes(span.attributes),
86
+ });
87
+ };
88
+ return span;
89
+ },
90
+ context: currentTracer.context,
91
+ }));
92
+ /**
93
+ * Creates a logger that mirrors every log record to the client, correlated with
94
+ * the span the logging fiber is inside.
95
+ *
96
+ * Effect's own tracer logger turns logs into span events, which loses logs
97
+ * emitted outside any span. These are sent as `Log` instead, so they keep their
98
+ * level and appear in the stream whether or not a span was active.
99
+ */
100
+ export const makeLogger = (client) => Logger.make(({ cause, fiber, logLevel, message }) => {
101
+ const span = fiber.cache.span;
102
+ const annotations = {
103
+ ...toAttributes(fiber.getRef(References.CurrentLogAnnotations)),
104
+ };
105
+ if (cause.reasons.length > 0)
106
+ annotations['effect.cause'] = Cause.pretty(cause);
107
+ client.sendUnsafe({
108
+ _tag: 'Log',
109
+ sessionId: client.sessionId,
110
+ // The fiber's own clock, so log times share the span time base rather
111
+ // than mixing in a wall clock the webapp would have to re-anchor.
112
+ time: fiber.getRef(Clock.Clock).currentTimeNanosUnsafe(),
113
+ level: logLevel,
114
+ message: toJson(Array.isArray(message) && message.length === 1 ? message[0] : message),
115
+ ...(span === undefined || span._tag === 'ExternalSpan' ? {} : { spanId: span.spanId }),
116
+ fiberId: fiber.id,
117
+ annotations,
118
+ });
119
+ });
@@ -0,0 +1,8 @@
1
+ import { Config } from 'effect';
2
+ /** Port instrumented programs and the webapp both dial. */
3
+ export declare const defaultPort = 34437;
4
+ /** Shared settings for the standalone collector and the installed CLI. */
5
+ export declare const collectorConfig: Config.Config<{
6
+ port: number;
7
+ capacity: number;
8
+ }>;
@@ -0,0 +1,9 @@
1
+ import { Config } from 'effect';
2
+ import { defaultCapacity } from './Store.js';
3
+ /** Port instrumented programs and the webapp both dial. */
4
+ export const defaultPort = 34437;
5
+ /** Shared settings for the standalone collector and the installed CLI. */
6
+ export const collectorConfig = Config.all({
7
+ port: Config.Port('EFFECT_INSPECT_PORT').pipe(Config.withDefault(defaultPort)),
8
+ capacity: Config.Int('EFFECT_INSPECT_CAPACITY').pipe(Config.withDefault(defaultCapacity)),
9
+ });
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The collector's WebSocket server.
3
+ *
4
+ * One `SocketServer` serves both sides on one port, routed by request path:
5
+ * `/webapp` is a webapp client, anything else is an instrumented program. The
6
+ * socket server forks and supervises each connection independently, so a
7
+ * program that crashes mid-message, or a webapp client that disappears, cannot
8
+ * take the collector down.
9
+ */
10
+ import { Effect } from 'effect';
11
+ import { HttpServer } from 'effect/unstable/http';
12
+ import { Socket } from 'effect/unstable/socket';
13
+ import { Store } from './Store.ts';
14
+ /** Path a webapp client connects on; anything else is an instrumented program. */
15
+ export declare const webappPath = "/webapp";
16
+ /**
17
+ * Handles one accepted connection, routed by its request path.
18
+ *
19
+ * Scoped per connection: the writer and every fiber a handler forks are
20
+ * released when that one connection ends, and nothing outlives it.
21
+ */
22
+ export declare const handleConnection: (socket: Socket.Socket, path: string) => Effect.Effect<void, never, Store>;
23
+ /** Serves instrumented clients, webapp sockets, and the bundled web UI. */
24
+ export declare const run: (fetch?: (request: Request) => Promise<Response>) => Effect.Effect<never, never, HttpServer.HttpServer | import("effect/Scope").Scope | Store>;
@@ -0,0 +1,172 @@
1
+ /**
2
+ * The collector's WebSocket server.
3
+ *
4
+ * One `SocketServer` serves both sides on one port, routed by request path:
5
+ * `/webapp` is a webapp client, anything else is an instrumented program. The
6
+ * socket server forks and supervises each connection independently, so a
7
+ * program that crashes mid-message, or a webapp client that disappears, cannot
8
+ * take the collector down.
9
+ */
10
+ import { Effect, Fiber, PubSub, Result } from 'effect';
11
+ import { HttpServer, HttpServerRequest, HttpServerResponse } from 'effect/unstable/http';
12
+ import { Socket } from 'effect/unstable/socket';
13
+ import { clientCodec, collectorCodec, webappCodec, webappRequestCodec } from '../protocol/Codec.js';
14
+ import { Store } from './Store.js';
15
+ /** Path a webapp client connects on; anything else is an instrumented program. */
16
+ export const webappPath = '/webapp';
17
+ /**
18
+ * Splits a stream of arbitrary chunks into `\n`-terminated lines.
19
+ *
20
+ * A WebSocket frame is usually exactly one line, but nothing guarantees it, so
21
+ * an unterminated remainder is carried across pulls.
22
+ */
23
+ const lineSplitter = () => {
24
+ let rest = '';
25
+ return {
26
+ push: (chunk) => {
27
+ const parts = (rest + chunk).split('\n');
28
+ rest = parts.pop() ?? '';
29
+ return parts;
30
+ },
31
+ /** Trailing unterminated text, once the peer has stopped sending. */
32
+ flush: () => {
33
+ const last = rest;
34
+ rest = '';
35
+ return last;
36
+ },
37
+ };
38
+ };
39
+ /**
40
+ * Reads NDJSON lines off a socket, skipping any line that will not decode.
41
+ *
42
+ * This is the line-level recovery the codec deliberately does not do:
43
+ * `Codec.decodeAll` fails a whole chunk on one bad line, which would cost a
44
+ * session every message that shared a frame with it. Here a bad line is counted
45
+ * and dropped and the connection carries on — one malformed line must never
46
+ * drop a session or kill the collector.
47
+ *
48
+ * Returns when the peer disconnects. A socket error ends the loop the same way
49
+ * a clean close does: there is nothing left to read either way.
50
+ */
51
+ const readLines = (pull, decode, onMessage, onSkipped) => Effect.suspend(() => {
52
+ const splitter = lineSplitter();
53
+ const handle = (line) => line.trim() === ''
54
+ ? Effect.void
55
+ : Result.match(decode(line), { onSuccess: onMessage, onFailure: () => onSkipped(line) });
56
+ return Effect.forever(Effect.flatMap(pull, (chunk) => Effect.forEach(chunk.flatMap(splitter.push), handle, { discard: true }))).pipe(Effect.catchTag('SocketError', () => Effect.void), Effect.ensuring(Effect.suspend(() => handle(splitter.flush()))));
57
+ });
58
+ /**
59
+ * Acquires a socket's line reader for the current scope.
60
+ *
61
+ * The reader must be acquired before anything writes: a `Socket` writer blocks
62
+ * until the reader has latched onto the underlying WebSocket, so a handler that
63
+ * sends first and reads later would deadlock.
64
+ */
65
+ const readerFor = (socket) => Socket.readerString(socket).pipe(
66
+ // An already-dead socket is a disconnect, not a collector error: hand back
67
+ // a pull that reports the close so the read loop exits immediately.
68
+ Effect.catchTag('SocketError', (error) => Effect.succeed(Effect.fail(error))));
69
+ /** Handles one instrumented program's connection for its lifetime. */
70
+ const handleClient = Effect.fnUntraced(function* (socket) {
71
+ const store = yield* Store;
72
+ const pull = yield* readerFor(socket);
73
+ const writer = yield* socket.writer;
74
+ /** The session this connection belongs to, learned from its `Hello`. */
75
+ let sessionId;
76
+ const onMessage = (message) => Effect.gen(function* () {
77
+ sessionId = message.sessionId;
78
+ switch (message._tag) {
79
+ case 'Hello':
80
+ yield* store.hello(message);
81
+ return;
82
+ case 'Ping':
83
+ // A failed write means the client is gone; the read loop notices.
84
+ yield* Effect.ignore(writer.write(collectorCodec.encode({ _tag: 'Pong', sessionId: message.sessionId })));
85
+ return;
86
+ default:
87
+ yield* store.append(message);
88
+ }
89
+ });
90
+ yield* readLines(pull, clientCodec.decode, onMessage, () => Effect.suspend(() => store.skipLine(sessionId))).pipe(
91
+ // However the connection ends — clean close, crash, kill -9 — the session
92
+ // is marked ended. A reconnect with the same id resumes it.
93
+ Effect.ensuring(Effect.suspend(() => (sessionId === undefined ? Effect.void : store.end(sessionId)))));
94
+ });
95
+ /** Handles one webapp client's connection for its lifetime. */
96
+ const handleWebapp = Effect.fnUntraced(function* (socket) {
97
+ const store = yield* Store;
98
+ const pull = yield* readerFor(socket);
99
+ const writer = yield* socket.writer;
100
+ const send = (message) => Effect.ignore(writer.write(webappCodec.encode(message)));
101
+ const sendSessionList = Effect.flatMap(store.sessions, (sessions) => send({ _tag: 'SessionList', sessions }));
102
+ /** One live-tail fiber per subscribed session, so `Unsubscribe` interrupts. */
103
+ const tails = new Map();
104
+ const unsubscribe = (sessionId) => Effect.suspend(() => {
105
+ const fiber = tails.get(sessionId);
106
+ if (fiber === undefined)
107
+ return Effect.void;
108
+ tails.delete(sessionId);
109
+ return Effect.asVoid(Fiber.interrupt(fiber));
110
+ });
111
+ const subscribe = (sessionId) => Effect.gen(function* () {
112
+ if (tails.has(sessionId))
113
+ return;
114
+ const fiber = yield* Effect.forkScoped(Effect.scoped(Effect.gen(function* () {
115
+ // Subscribe before snapshotting: an overlap is deduplicated by span
116
+ // id downstream, a gap is a span the webapp never sees.
117
+ const pubsub = yield* store.live(sessionId);
118
+ if (pubsub === undefined)
119
+ return;
120
+ const live = yield* PubSub.subscribe(pubsub);
121
+ const snapshot = yield* store.snapshot(sessionId);
122
+ yield* send({
123
+ _tag: 'Backlog',
124
+ sessionId,
125
+ messages: snapshot?.messages ?? [],
126
+ complete: true,
127
+ });
128
+ return yield* Effect.forever(Effect.flatMap(PubSub.take(live), (message) => send({ _tag: 'Live', message })));
129
+ })));
130
+ tails.set(sessionId, fiber);
131
+ });
132
+ yield* sendSessionList;
133
+ // Re-send the list whenever a session opens, resumes or ends.
134
+ yield* Effect.forkScoped(Effect.scoped(Effect.gen(function* () {
135
+ const subscription = yield* PubSub.subscribe(store.changes);
136
+ return yield* Effect.forever(Effect.flatMap(PubSub.take(subscription), () => sendSessionList));
137
+ })));
138
+ yield* readLines(pull, webappRequestCodec.decode, (request) => request._tag === 'Subscribe' ? subscribe(request.sessionId) : unsubscribe(request.sessionId),
139
+ // A webapp sending garbage is a webapp bug: drop the line, keep the socket.
140
+ () => Effect.void);
141
+ });
142
+ /**
143
+ * Handles one accepted connection, routed by its request path.
144
+ *
145
+ * Scoped per connection: the writer and every fiber a handler forks are
146
+ * released when that one connection ends, and nothing outlives it.
147
+ */
148
+ export const handleConnection = (socket, path) => Effect.scoped(path === webappPath ? handleWebapp(socket) : handleClient(socket));
149
+ /** Serves instrumented clients, webapp sockets, and the bundled web UI. */
150
+ export const run = (fetch) => Effect.gen(function* () {
151
+ const server = yield* HttpServer.HttpServer;
152
+ yield* server.serve(Effect.gen(function* () {
153
+ const request = yield* HttpServerRequest.HttpServerRequest;
154
+ const path = new URL(request.url, 'http://localhost').pathname;
155
+ if (request.headers.upgrade?.toLowerCase() === 'websocket') {
156
+ const socket = yield* request.upgrade;
157
+ yield* handleConnection(socket, path);
158
+ return HttpServerResponse.empty();
159
+ }
160
+ if (fetch === undefined) {
161
+ return HttpServerResponse.text('effect-inspect collector: websocket only', {
162
+ status: 426,
163
+ });
164
+ }
165
+ const response = yield* Effect.promise(() => fetch(new Request(new URL(request.url, 'http://localhost').href, {
166
+ method: request.method,
167
+ headers: request.headers,
168
+ })));
169
+ return HttpServerResponse.fromWeb(response);
170
+ }));
171
+ return yield* Effect.never;
172
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * In-memory, bounded store of every session the collector has seen.
3
+ *
4
+ * Each session keeps its telemetry in a bounded ring (oldest dropped first,
5
+ * drops counted) plus a sliding `PubSub` that webapp clients subscribe to for
6
+ * the live tail. Nothing is persisted: M1 is live-only.
7
+ *
8
+ * Ingest never blocks on a consumer. The ring drops its oldest message when
9
+ * full and the live `PubSub` is sliding, so neither a long-running program nor
10
+ * a stalled webapp client can apply backpressure to the instrumented program.
11
+ */
12
+ import { Context, Effect, Layer, PubSub } from 'effect';
13
+ import * as Protocol from '../protocol/Schema.ts';
14
+ /** Messages a single session retains before dropping its oldest. */
15
+ export declare const defaultCapacity = 200000;
16
+ /** A session's retained telemetry plus the counters describing what was lost. */
17
+ export interface SessionSnapshot {
18
+ readonly session: Protocol.Session;
19
+ readonly messages: ReadonlyArray<Protocol.ClientMessage>;
20
+ /** Messages evicted by the capacity bound. */
21
+ readonly droppedMessages: number;
22
+ /** Lines received for this session that could not be decoded. */
23
+ readonly skippedLines: number;
24
+ }
25
+ declare const Store_base: Context.ServiceClass<Store, "effect-inspect/collector/Store", {
26
+ /** Opens a session, or resumes the existing one when a program reconnects. */
27
+ readonly hello: (message: Protocol.Hello) => Effect.Effect<void>;
28
+ /** Records one decoded message and fans it out to live subscribers. */
29
+ readonly append: (message: Protocol.ClientMessage) => Effect.Effect<void>;
30
+ /** Counts one line that could not be decoded. */
31
+ readonly skipLine: (sessionId: Protocol.SessionId | undefined) => Effect.Effect<void>;
32
+ /** Marks a session ended because its program disconnected. */
33
+ readonly end: (sessionId: Protocol.SessionId) => Effect.Effect<void>;
34
+ /** Every known session, in the order they first said `Hello`. */
35
+ readonly sessions: Effect.Effect<ReadonlyArray<Protocol.Session>>;
36
+ /** One session's retained messages and loss counters. */
37
+ readonly snapshot: (sessionId: Protocol.SessionId) => Effect.Effect<SessionSnapshot | undefined>;
38
+ /**
39
+ * A session's live-tail `PubSub`, or `undefined` when it is unknown.
40
+ *
41
+ * Subscribe to it before reading the snapshot: an overlapping message is
42
+ * deduplicated downstream by span id, a missed one is a hole in the trace.
43
+ */
44
+ readonly live: (sessionId: Protocol.SessionId) => Effect.Effect<PubSub.PubSub<Protocol.ClientMessage> | undefined>;
45
+ /** Published whenever the session list changes, so clients can re-send it. */
46
+ readonly changes: PubSub.PubSub<void>;
47
+ }>;
48
+ /**
49
+ * The collector's session store.
50
+ *
51
+ * @see {@link layer} for the live implementation and {@link make} to build one
52
+ * with a smaller capacity in tests.
53
+ */
54
+ export declare class Store extends Store_base {
55
+ }
56
+ /** Builds a `Store`, retaining `capacity` messages per session. */
57
+ export declare const make: (options?: {
58
+ readonly capacity?: number;
59
+ } | undefined) => Effect.Effect<{
60
+ /** Opens a session, or resumes the existing one when a program reconnects. */
61
+ readonly hello: (message: Protocol.Hello) => Effect.Effect<void>;
62
+ /** Records one decoded message and fans it out to live subscribers. */
63
+ readonly append: (message: Protocol.ClientMessage) => Effect.Effect<void>;
64
+ /** Counts one line that could not be decoded. */
65
+ readonly skipLine: (sessionId: Protocol.SessionId | undefined) => Effect.Effect<void>;
66
+ /** Marks a session ended because its program disconnected. */
67
+ readonly end: (sessionId: Protocol.SessionId) => Effect.Effect<void>;
68
+ /** Every known session, in the order they first said `Hello`. */
69
+ readonly sessions: Effect.Effect<ReadonlyArray<Protocol.Session>>;
70
+ /** One session's retained messages and loss counters. */
71
+ readonly snapshot: (sessionId: Protocol.SessionId) => Effect.Effect<SessionSnapshot | undefined>;
72
+ /**
73
+ * A session's live-tail `PubSub`, or `undefined` when it is unknown.
74
+ *
75
+ * Subscribe to it before reading the snapshot: an overlapping message is
76
+ * deduplicated downstream by span id, a missed one is a hole in the trace.
77
+ */
78
+ readonly live: (sessionId: Protocol.SessionId) => Effect.Effect<PubSub.PubSub<Protocol.ClientMessage> | undefined>;
79
+ /** Published whenever the session list changes, so clients can re-send it. */
80
+ readonly changes: PubSub.PubSub<void>;
81
+ }, never, never>;
82
+ /** The live `Store`, retaining {@link defaultCapacity} messages per session. */
83
+ export declare const layer: (options?: {
84
+ readonly capacity?: number;
85
+ }) => Layer.Layer<Store>;
86
+ export {};
@@ -0,0 +1,119 @@
1
+ /**
2
+ * In-memory, bounded store of every session the collector has seen.
3
+ *
4
+ * Each session keeps its telemetry in a bounded ring (oldest dropped first,
5
+ * drops counted) plus a sliding `PubSub` that webapp clients subscribe to for
6
+ * the live tail. Nothing is persisted: M1 is live-only.
7
+ *
8
+ * Ingest never blocks on a consumer. The ring drops its oldest message when
9
+ * full and the live `PubSub` is sliding, so neither a long-running program nor
10
+ * a stalled webapp client can apply backpressure to the instrumented program.
11
+ */
12
+ import { Clock, Context, Effect, Layer, PubSub } from 'effect';
13
+ import * as Protocol from '../protocol/Schema.js';
14
+ /** Messages a single session retains before dropping its oldest. */
15
+ export const defaultCapacity = 200_000;
16
+ /** Live messages buffered per webapp subscriber before the oldest is dropped. */
17
+ const liveBufferSize = 4096;
18
+ /**
19
+ * The collector's session store.
20
+ *
21
+ * @see {@link layer} for the live implementation and {@link make} to build one
22
+ * with a smaller capacity in tests.
23
+ */
24
+ export class Store extends Context.Service()('effect-inspect/collector/Store') {
25
+ }
26
+ /** Builds a `Store`, retaining `capacity` messages per session. */
27
+ export const make = Effect.fnUntraced(function* (options) {
28
+ const capacity = options?.capacity ?? defaultCapacity;
29
+ const sessions = new Map();
30
+ const changes = yield* PubSub.sliding(1);
31
+ const notify = PubSub.publish(changes, undefined).pipe(Effect.asVoid);
32
+ const hello = (message) => Effect.gen(function* () {
33
+ const existing = sessions.get(message.sessionId);
34
+ if (existing !== undefined) {
35
+ // A reconnect resumes the session rather than starting a new one, so a
36
+ // program that is killed and restarted keeps one continuous trace.
37
+ existing.session = { ...existing.session, active: true };
38
+ delete existing.session.endedAtEpochMillis;
39
+ yield* notify;
40
+ return;
41
+ }
42
+ sessions.set(message.sessionId, {
43
+ session: {
44
+ sessionId: message.sessionId,
45
+ program: message.program,
46
+ pid: message.pid,
47
+ runtime: message.runtime,
48
+ clock: message.clock,
49
+ active: true,
50
+ },
51
+ ring: [],
52
+ head: 0,
53
+ droppedMessages: 0,
54
+ skippedLines: 0,
55
+ live: yield* PubSub.sliding(liveBufferSize),
56
+ });
57
+ yield* notify;
58
+ });
59
+ const append = (message) => Effect.suspend(() => {
60
+ const state = sessions.get(message.sessionId);
61
+ // A message for a session that never said Hello has nowhere to go. It is
62
+ // a client bug, not a reason to drop the connection.
63
+ if (state === undefined)
64
+ return Effect.void;
65
+ if (state.ring.length < capacity) {
66
+ state.ring.push(message);
67
+ }
68
+ else {
69
+ state.ring[state.head] = message;
70
+ state.head = (state.head + 1) % capacity;
71
+ state.droppedMessages += 1;
72
+ }
73
+ return PubSub.publish(state.live, message).pipe(Effect.asVoid);
74
+ });
75
+ // A line from a connection that never sent a usable `Hello` has no session
76
+ // to count it against, so it is skipped without a counter.
77
+ const skipLine = (sessionId) => Effect.sync(() => {
78
+ const state = sessionId === undefined ? undefined : sessions.get(sessionId);
79
+ if (state !== undefined)
80
+ state.skippedLines += 1;
81
+ });
82
+ const end = (sessionId) => Effect.flatMap(Clock.currentTimeMillis, (now) => {
83
+ const state = sessions.get(sessionId);
84
+ if (state === undefined || !state.session.active)
85
+ return Effect.void;
86
+ state.session = {
87
+ ...state.session,
88
+ active: false,
89
+ endedAtEpochMillis: now,
90
+ };
91
+ return notify;
92
+ });
93
+ const snapshot = (sessionId) => Effect.sync(() => {
94
+ const state = sessions.get(sessionId);
95
+ if (state === undefined)
96
+ return undefined;
97
+ return {
98
+ session: state.session,
99
+ messages: state.head === 0
100
+ ? state.ring.slice()
101
+ : state.ring.slice(state.head).concat(state.ring.slice(0, state.head)),
102
+ droppedMessages: state.droppedMessages,
103
+ skippedLines: state.skippedLines,
104
+ };
105
+ });
106
+ const live = (sessionId) => Effect.sync(() => sessions.get(sessionId)?.live);
107
+ return Store.of({
108
+ hello,
109
+ append,
110
+ skipLine,
111
+ end,
112
+ sessions: Effect.sync(() => Array.from(sessions.values(), (state) => state.session)),
113
+ snapshot,
114
+ live,
115
+ changes,
116
+ });
117
+ });
118
+ /** The live `Store`, retaining {@link defaultCapacity} messages per session. */
119
+ export const layer = (options) => Layer.effect(Store)(make(options));
@@ -0,0 +1,3 @@
1
+ import { Effect } from 'effect';
2
+ /** Serves the built TanStack Start app from paths relative to this package. */
3
+ export declare const loadWebApp: Effect.Effect<(request: Request) => Promise<Response>, never, never>;
@@ -0,0 +1,36 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import { Effect } from 'effect';
10
+ // Reading packaged assets is a Node runtime boundary; the app itself returns Web Responses.
11
+ // oxlint-disable-next-line effecttsgo/node-builtin-import
12
+ import { readFile } from 'node:fs/promises';
13
+ import { fileURLToPath } from 'node:url';
14
+ /** Serves the built TanStack Start app from paths relative to this package. */
15
+ export const loadWebApp = Effect.gen(function* () {
16
+ const serverEntry = new URL('../../app/dist/server/server.js', import.meta.url);
17
+ const clientRoot = new URL('../../app/dist/client/', import.meta.url);
18
+ const app = (yield* Effect.promise(() => import(__rewriteRelativeImportExtension(serverEntry.href))));
19
+ return (request) => {
20
+ const path = new URL(request.url).pathname;
21
+ if (path.startsWith('/assets/')) {
22
+ const name = path.slice('/assets/'.length);
23
+ if (!/^[\w.-]+$/.test(name))
24
+ return Promise.resolve(new Response('Not found', { status: 404 }));
25
+ const file = fileURLToPath(new URL(`assets/${name}`, clientRoot));
26
+ return readFile(file).then((bytes) => new Response(bytes, {
27
+ headers: { 'content-type': name.endsWith('.css') ? 'text/css' : 'text/javascript' },
28
+ }), (error) => {
29
+ if (error.code === 'ENOENT')
30
+ return new Response('Not found', { status: 404 });
31
+ throw error;
32
+ });
33
+ }
34
+ return app.default.fetch(request);
35
+ };
36
+ });
@@ -0,0 +1 @@
1
+ export { defaultPort } from './Config.ts';
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Collector entry point — `bun run collector`.
3
+ *
4
+ * Long-lived: it outlives the programs it collects from, so an instrumented
5
+ * program can be killed and restarted without losing its trace.
6
+ */
7
+ import { Effect, Layer } from 'effect';
8
+ import * as NodeHttpServer from '@effect/platform-node/NodeHttpServer';
9
+ import * as NodeRuntime from '@effect/platform-node/NodeRuntime';
10
+ // NodeHttpServer needs a native server constructor to own the HTTP and upgrade listeners.
11
+ // oxlint-disable-next-line effecttsgo/node-builtin-import
12
+ import { createServer } from 'node:http';
13
+ import { run } from './Server.js';
14
+ import { layer as storeLayer } from './Store.js';
15
+ import { collectorConfig } from './Config.js';
16
+ export { defaultPort } from './Config.js';
17
+ const main = Effect.gen(function* () {
18
+ const { capacity, port } = yield* collectorConfig;
19
+ yield* Effect.logInfo(`effect-inspect collector listening on ws://localhost:${port}`);
20
+ return yield* Effect.provide(run(), Layer.mergeAll(storeLayer({ capacity }), NodeHttpServer.layer(createServer, { port })));
21
+ });
22
+ NodeRuntime.runMain(Effect.scoped(main));
@@ -0,0 +1,3 @@
1
+ export * as Codec from './protocol/Codec.ts';
2
+ export * as Inspect from './client/Inspect.ts';
3
+ export * as Protocol from './protocol/Schema.ts';
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export * as Codec from './protocol/Codec.js';
2
+ export * as Inspect from './client/Inspect.js';
3
+ export * as Protocol from './protocol/Schema.js';