@fougere/calls 0.5.0-alpha.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 +21 -0
- package/README.md +31 -0
- package/dist/CallRing.d.ts +45 -0
- package/dist/CallRing.d.ts.map +1 -0
- package/dist/CallRing.js +119 -0
- package/dist/CallRing.js.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +179 -0
- package/dist/index.js.map +1 -0
- package/dist/page.d.ts +23 -0
- package/dist/page.d.ts.map +1 -0
- package/dist/page.js +536 -0
- package/dist/page.js.map +1 -0
- package/dist/panel.d.ts +30 -0
- package/dist/panel.d.ts.map +1 -0
- package/dist/panel.js +102 -0
- package/dist/panel.js.map +1 -0
- package/dist/rings.d.ts +88 -0
- package/dist/rings.d.ts.map +1 -0
- package/dist/rings.js +112 -0
- package/dist/rings.js.map +1 -0
- package/package.json +58 -0
- package/src/CallRing.ts +119 -0
- package/src/index.ts +205 -0
- package/src/page.ts +535 -0
- package/src/panel.ts +123 -0
- package/src/rings.ts +158 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { onLog, type App, type Extension, type InvocationContext } from '@fougere/core';
|
|
2
|
+
import { CallRing } from './CallRing.js';
|
|
3
|
+
import { ErrorRing, LogRing, QueryRing } from './rings.js';
|
|
4
|
+
import { servePanel, type PanelOptions } from './panel.js';
|
|
5
|
+
|
|
6
|
+
export type { CallPage, CallRecord } from '@fougere/core';
|
|
7
|
+
export { CallRing } from './CallRing.js';
|
|
8
|
+
export { LogRing, QueryRing, ErrorRing } from './rings.js';
|
|
9
|
+
export type { LogLine, QueryLine, ErrorGroup } from './rings.js';
|
|
10
|
+
export { servePanel, type PanelOptions } from './panel.js';
|
|
11
|
+
|
|
12
|
+
/** The rpc operation this extension answers under. */
|
|
13
|
+
export const CALLS_OP = 'calls';
|
|
14
|
+
|
|
15
|
+
export interface CallsOptions {
|
|
16
|
+
/** How many calls the ring keeps. Beyond it, the oldest are dropped and counted. */
|
|
17
|
+
max?: number;
|
|
18
|
+
/**
|
|
19
|
+
* Serve the page too, on its own loopback port. `true` takes a free one.
|
|
20
|
+
*
|
|
21
|
+
* Node only — it opens an `http` server, which workerd has not. Without it the extension
|
|
22
|
+
* stays universal and `fougere devtools` is the reader.
|
|
23
|
+
*/
|
|
24
|
+
panel?: boolean | number | PanelOptions;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What this process serves, read from the app itself.
|
|
29
|
+
*
|
|
30
|
+
* Not a second source: `operationsFor` is the same effective model the façade consumes, so
|
|
31
|
+
* the page shows what will actually answer — including the ops nobody has called yet, which
|
|
32
|
+
* is what makes an empty panel useful instead of blank.
|
|
33
|
+
*/
|
|
34
|
+
function servedModel(app: App): unknown {
|
|
35
|
+
return {
|
|
36
|
+
fronds: app.fronds.map((frond) => ({
|
|
37
|
+
name: frond.name,
|
|
38
|
+
// What the config SAYS. What the runtime saw is in the ring, under `route` — and the
|
|
39
|
+
// two disagree exactly when something is misconfigured, which is the whole point of
|
|
40
|
+
// showing them side by side. `rpc.topology` calls a frond remote because it ANSWERED.
|
|
41
|
+
declared: app.remotes[frond.name] ? 'remote' as const : 'local' as const,
|
|
42
|
+
at: app.remotes[frond.name] ? hostOf(app.remotes[frond.name]!) : null,
|
|
43
|
+
entities: frond.entities.map((entity) => entity.name),
|
|
44
|
+
operations: frond.handlers.flatMap((handler) => {
|
|
45
|
+
const ops = app.operationsFor(handler.address);
|
|
46
|
+
return [...(ops?.values() ?? [])].map((op) => ({
|
|
47
|
+
id: op.id,
|
|
48
|
+
operation: op.operation,
|
|
49
|
+
kind: op.kind,
|
|
50
|
+
address: `${op.handler.address}.${op.implementation.method}`,
|
|
51
|
+
handler: op.handler.className,
|
|
52
|
+
description: op.description ?? null,
|
|
53
|
+
input: nameOf(op.input),
|
|
54
|
+
output: nameOf(op.output),
|
|
55
|
+
cardinality: op.cardinality ?? null,
|
|
56
|
+
parameters: op.parameters.map((one) => ({
|
|
57
|
+
name: one.name,
|
|
58
|
+
type: one.type,
|
|
59
|
+
optional: one.optional,
|
|
60
|
+
binding: one.binding.source.kind,
|
|
61
|
+
})),
|
|
62
|
+
surfaces: op.exposure.surfaces,
|
|
63
|
+
adapters: op.exposure.adapters,
|
|
64
|
+
placement: op.placement.runtime,
|
|
65
|
+
file: op.implementation.filePath,
|
|
66
|
+
}));
|
|
67
|
+
}),
|
|
68
|
+
})),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Host and port only — a declared address may carry credentials, and this answer leaves. */
|
|
73
|
+
function hostOf(address: string): string {
|
|
74
|
+
try {
|
|
75
|
+
const url = new URL(address);
|
|
76
|
+
return `${url.protocol}//${url.host}`;
|
|
77
|
+
} catch {
|
|
78
|
+
return address;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function nameOf(schema: unknown): string | null {
|
|
83
|
+
const held = schema as { getName?: () => string; name?: string } | undefined;
|
|
84
|
+
if (!held) return null;
|
|
85
|
+
|
|
86
|
+
return typeof held.getName === 'function' ? held.getName() : held.name ?? null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* `address -> frond`, resolved once at boot: a call's address does not carry its frond.
|
|
91
|
+
*
|
|
92
|
+
* Indexed on HANDLERS and not on entities, because a frond may hold no entity at all —
|
|
93
|
+
* `demos/observability` has two — and then every one of its calls came back unnamed. The
|
|
94
|
+
* entities are indexed after, and only where a handler left the address free: an entity
|
|
95
|
+
* with no handler is served by `Crud`, which answers under the same address.
|
|
96
|
+
*/
|
|
97
|
+
function frondIndex(app: App): (address: string) => string | undefined {
|
|
98
|
+
const byAddress = new Map<string, string>();
|
|
99
|
+
for (const frond of app.fronds) {
|
|
100
|
+
for (const entity of frond.entities) byAddress.set(entity.name, frond.name);
|
|
101
|
+
for (const handler of frond.handlers) byAddress.set(handler.address, frond.name);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return (address) => byAddress.get(address);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Subscribe to SQL when there is SQL. Absence is an answer, not a blank tab.
|
|
109
|
+
*
|
|
110
|
+
* `logQueries` is installed in every Kysely this process builds, so the subscription is all
|
|
111
|
+
* that is missing — and it is taken here, in `up(app)`, like the other two.
|
|
112
|
+
*/
|
|
113
|
+
async function queriesFrom(queries: QueryRing): Promise<() => void> {
|
|
114
|
+
try {
|
|
115
|
+
const { onQuery } = await import('@fougere/adapter-sql');
|
|
116
|
+
return onQuery((event) => queries.record(event));
|
|
117
|
+
} catch {
|
|
118
|
+
return () => {};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function cursorOf(invocation: InvocationContext): number {
|
|
123
|
+
const body = invocation.body as { since?: unknown } | undefined;
|
|
124
|
+
const since = Number(body?.since ?? 0);
|
|
125
|
+
|
|
126
|
+
return Number.isFinite(since) && since > 0 ? since : 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* What this process dispatched, kept in a bounded ring and served as an rpc operation.
|
|
131
|
+
*
|
|
132
|
+
* It watches rather than participates: `app.observe` is passive, `DispatchLifecycle`
|
|
133
|
+
* swallows an observer's own failure, and the ring holds no reference to a body. What it
|
|
134
|
+
* sees that a middleware cannot: a call refused BEFORE any handler — an unknown route, an
|
|
135
|
+
* entity hosted elsewhere, a call arriving while the door drains — and the route kind of
|
|
136
|
+
* every call, so a local execution and a hop to another process read the same way.
|
|
137
|
+
*
|
|
138
|
+
* The reader is `fougere devtools`, over `/_fougere/call` like any other consumer. No port
|
|
139
|
+
* is opened here, and an app that never installed this package answers
|
|
140
|
+
* `Unknown rpc operation 'calls'. It serves discover.`
|
|
141
|
+
*/
|
|
142
|
+
export function calls(options: CallsOptions = {}): Extension {
|
|
143
|
+
/**
|
|
144
|
+
* Per APP, not per extension. A host declares its extensions once, so the same instance
|
|
145
|
+
* goes up on the new app before the old one is released — one shared ring would mix two
|
|
146
|
+
* processes' worth of calls, and the older `down` would erase the newer subscription.
|
|
147
|
+
*/
|
|
148
|
+
const stopping = new WeakMap<App, () => void | Promise<void>>();
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
name: 'calls',
|
|
152
|
+
|
|
153
|
+
async up(app: App) {
|
|
154
|
+
const ring = new CallRing(options.max ?? 500, frondIndex(app));
|
|
155
|
+
const logs = new LogRing();
|
|
156
|
+
const errors = new ErrorRing();
|
|
157
|
+
const queries = new QueryRing();
|
|
158
|
+
|
|
159
|
+
const undo = [
|
|
160
|
+
app.observe((event) => {
|
|
161
|
+
ring.record(event);
|
|
162
|
+
if (event.stage === 'failed') errors.fromDispatch(event);
|
|
163
|
+
}),
|
|
164
|
+
onLog((line) => {
|
|
165
|
+
logs.record(line);
|
|
166
|
+
errors.fromLog(line);
|
|
167
|
+
}),
|
|
168
|
+
// `@fougere/adapter-sql` is optional — an app on another storage, or none, simply
|
|
169
|
+
// never resolves it, and the Queries tab says there is none rather than staying
|
|
170
|
+
// blank. Dynamic on purpose: this package declares no dependency on it.
|
|
171
|
+
await queriesFrom(queries),
|
|
172
|
+
];
|
|
173
|
+
const stop = () => { for (const one of undo) one(); };
|
|
174
|
+
|
|
175
|
+
app.serveRpc(CALLS_OP, (invocation) => ring.since(cursorOf(invocation), app.inFlight()));
|
|
176
|
+
|
|
177
|
+
if (!options.panel) {
|
|
178
|
+
stopping.set(app, stop);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const asked = typeof options.panel === 'object' ? options.panel : {};
|
|
183
|
+
const port = typeof options.panel === 'number' ? options.panel : asked.port;
|
|
184
|
+
const close = await servePanel(ring, {
|
|
185
|
+
...asked,
|
|
186
|
+
...(port !== undefined ? { port } : {}),
|
|
187
|
+
title: asked.title ?? app.fronds[0]?.name ?? 'fougere',
|
|
188
|
+
fronds: app.fronds.map((frond) => frond.name),
|
|
189
|
+
model: servedModel(app),
|
|
190
|
+
logs: (cursor) => logs.since(cursor),
|
|
191
|
+
errors: (cursor) => errors.since(cursor),
|
|
192
|
+
queries: (cursor) => queries.since(cursor),
|
|
193
|
+
inFlight: () => app.inFlight(),
|
|
194
|
+
announce: asked.announce ?? ((url) => console.log(` calls panel ${url}`)),
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
stopping.set(app, async () => { stop(); await close(); });
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
async down(app: App) {
|
|
201
|
+
await stopping.get(app)?.();
|
|
202
|
+
stopping.delete(app);
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|