dsh-plugin-usage 0.2.0 → 0.3.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/README.md +41 -110
- package/icon.svg +11 -0
- package/image-1.png +0 -0
- package/image.png +0 -0
- package/lib/client/index.js +671 -44
- package/lib/host/fold.js +142 -35
- package/lib/host/index.js +44 -13
- package/lib/host/query.js +24 -4
- package/lib/host/rpc.js +136 -14
- package/lib/host/scan.js +108 -15
- package/lib/host/settings.js +76 -99
- package/lib/host/state.js +14 -3
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +21 -8
package/lib/host/fold.js
CHANGED
|
@@ -1,60 +1,167 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Fold session events into usage records.
|
|
2
|
+
* Fold durable session events into usage records.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* This mirrors the official `tokenUsage` session projection
|
|
5
|
+
* (@deepseek-ai/dsh-token-meter) so a third-party dashboard and the built-in
|
|
6
|
+
* stats agree on what one session consumed:
|
|
5
7
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* - A **billed settlement** is `assistant/message` or `assistant/attempt`.
|
|
9
|
+
* `assistant/message` carries `data.usage`; a retried/failed attempt has no
|
|
10
|
+
* usage field but can still have reported usage on its exact stream, so the
|
|
11
|
+
* last raw `usage` chunk of `data.stream` is used when the field is absent.
|
|
12
|
+
* - `llm/retry-started` closes the replacement slot for that (turn, step):
|
|
13
|
+
* the retried attempt is a NEW billable attempt and must add, not replace.
|
|
14
|
+
* - Otherwise a later sample for the same (turn, step) replaces the previous
|
|
15
|
+
* one in place (index remove + add), because a settlement re-reports the
|
|
16
|
+
* cumulative usage of its step.
|
|
17
|
+
* - `request/context` and `request/header` provide the route (provider/model)
|
|
18
|
+
* for settlements that carry no `message.source`.
|
|
19
|
+
*
|
|
20
|
+
* Records without turn/step (legacy logs) are never deduped — they always
|
|
21
|
+
* append.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Last raw `usage` chunk of one durable assistant stream.
|
|
25
|
+
*
|
|
26
|
+
* @param {unknown} stream - `AssistantStreamRecord[]` (packed records included).
|
|
27
|
+
* @returns {object | undefined} the reported usage, when the stream has one.
|
|
28
|
+
*/
|
|
29
|
+
export function streamUsage(stream) {
|
|
30
|
+
if (!Array.isArray(stream))
|
|
31
|
+
return undefined;
|
|
32
|
+
for (let index = stream.length - 1; index >= 0; index -= 1) {
|
|
33
|
+
const record = stream[index];
|
|
34
|
+
if (record && record.type === 'chunk' && record.chunk && record.chunk.type === 'usage') {
|
|
35
|
+
return record.chunk.usage;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Usage reported by one durable assistant settlement.
|
|
42
|
+
*
|
|
43
|
+
* @param {{ type?: string, data?: any }} event - session event.
|
|
44
|
+
* @returns {object | undefined} reported usage, when the settlement billed one.
|
|
45
|
+
*/
|
|
46
|
+
export function usageOf(event) {
|
|
47
|
+
const data = event && event.data;
|
|
48
|
+
if (event.type === 'assistant/message' && data && data.usage !== undefined)
|
|
49
|
+
return data.usage;
|
|
50
|
+
if (event.type !== 'assistant/message' && event.type !== 'assistant/attempt')
|
|
51
|
+
return undefined;
|
|
52
|
+
return streamUsage(data && data.stream);
|
|
53
|
+
}
|
|
54
|
+
/** Route carried by a request event, when present. */
|
|
55
|
+
function routeOf(event) {
|
|
56
|
+
const data = event.data || {};
|
|
57
|
+
if (event.type === 'request/context') {
|
|
58
|
+
if (typeof data.provider === 'string' && typeof data.model === 'string') {
|
|
59
|
+
return { provider: data.provider, model: data.model };
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
if (event.type === 'request/header') {
|
|
64
|
+
const config = data.header && data.header.config;
|
|
65
|
+
if (config && typeof config.provider === 'string' && typeof config.model === 'string') {
|
|
66
|
+
return { provider: config.provider, model: config.model };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Fold one durable event into the usage records.
|
|
73
|
+
*
|
|
74
|
+
* @param {object} state - runtime state capsule (see state.js).
|
|
75
|
+
* @param {string} sessionId - owning session.
|
|
76
|
+
* @param {{ type?: string, seq?: number, time?: number, data?: any }} e - session event.
|
|
77
|
+
* @returns {object | null} the affected record, or null when the event billed nothing.
|
|
12
78
|
*/
|
|
13
79
|
export function foldEvent(state, sessionId, e) {
|
|
14
|
-
if (!e || e
|
|
80
|
+
if (!e || typeof e !== 'object')
|
|
81
|
+
return null;
|
|
82
|
+
// Route tracking: every settlement of a turn is priced/attributed under the
|
|
83
|
+
// route its request ran on.
|
|
84
|
+
const route = routeOf(e);
|
|
85
|
+
if (route !== undefined) {
|
|
86
|
+
state.route.set(sessionId, route);
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
// Retry boundary: the retried attempt is a new billable attempt.
|
|
90
|
+
if (e.type === 'llm/retry-started') {
|
|
91
|
+
const data = e.data || {};
|
|
92
|
+
const last = state.lastSample.get(sessionId);
|
|
93
|
+
if (last && last.turn === data.turn && last.step === data.step) {
|
|
94
|
+
state.lastSample.delete(sessionId);
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
if (e.type !== 'assistant/message' && e.type !== 'assistant/attempt')
|
|
15
99
|
return null;
|
|
16
|
-
const
|
|
17
|
-
|
|
100
|
+
const usage = usageOf(e);
|
|
101
|
+
if (usage === undefined || usage === null || typeof usage !== 'object')
|
|
102
|
+
return null;
|
|
103
|
+
const data = e.data || {};
|
|
104
|
+
const source = (data.message && data.message.source) || state.route.get(sessionId) || {};
|
|
105
|
+
const u = usage;
|
|
106
|
+
const inputTokens = u.inputTokens || 0;
|
|
107
|
+
const outputTokens = u.outputTokens || 0;
|
|
108
|
+
const cacheReadTokens = u.cacheReadTokens || 0;
|
|
109
|
+
const cacheWriteTokens = u.cacheWriteTokens || 0;
|
|
18
110
|
const rec = {
|
|
19
111
|
time: e.time,
|
|
20
112
|
session: sessionId,
|
|
21
113
|
provider: source.provider || '',
|
|
22
114
|
model: source.model || '',
|
|
23
|
-
turn:
|
|
24
|
-
step:
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
115
|
+
turn: data.turn,
|
|
116
|
+
step: data.step,
|
|
117
|
+
attempt: e.type === 'assistant/attempt',
|
|
118
|
+
inputTokens,
|
|
119
|
+
outputTokens,
|
|
120
|
+
cacheReadTokens,
|
|
121
|
+
cacheWriteTokens,
|
|
29
122
|
reasoningTokens: u.reasoningTokens || 0,
|
|
30
|
-
|
|
123
|
+
// Prefer the provider's authoritative full-call total when reported.
|
|
124
|
+
totalTokens: typeof u.totalTokens === 'number'
|
|
125
|
+
? u.totalTokens
|
|
126
|
+
: inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens,
|
|
31
127
|
};
|
|
32
|
-
const
|
|
33
|
-
if (
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (state.index)
|
|
38
|
-
state.index.remove(prev);
|
|
39
|
-
Object.assign(prev, rec);
|
|
40
|
-
if (state.index)
|
|
41
|
-
state.index.add(prev);
|
|
42
|
-
return prev;
|
|
43
|
-
}
|
|
44
|
-
state.records.push(rec);
|
|
45
|
-
state.dedup.set(key, state.records.length - 1);
|
|
128
|
+
const previous = state.lastSample.get(sessionId);
|
|
129
|
+
if (previous !== undefined
|
|
130
|
+
&& data.turn !== undefined && data.step !== undefined
|
|
131
|
+
&& previous.turn === data.turn && previous.step === data.step) {
|
|
132
|
+
const prev = previous.record;
|
|
46
133
|
if (state.index)
|
|
47
|
-
state.index.
|
|
48
|
-
|
|
134
|
+
state.index.remove(prev);
|
|
135
|
+
Object.assign(prev, rec);
|
|
136
|
+
if (state.index)
|
|
137
|
+
state.index.add(prev);
|
|
138
|
+
state.lastSample.set(sessionId, { turn: data.turn, step: data.step, record: prev });
|
|
139
|
+
return prev;
|
|
49
140
|
}
|
|
50
141
|
state.records.push(rec);
|
|
51
142
|
if (state.index)
|
|
52
143
|
state.index.add(rec);
|
|
144
|
+
if (data.turn !== undefined && data.step !== undefined) {
|
|
145
|
+
state.lastSample.set(sessionId, { turn: data.turn, step: data.step, record: rec });
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
state.lastSample.delete(sessionId);
|
|
149
|
+
}
|
|
53
150
|
return rec;
|
|
54
151
|
}
|
|
55
|
-
/**
|
|
152
|
+
/**
|
|
153
|
+
* Fold events with seq > watermark for one session; returns the new watermark.
|
|
154
|
+
*
|
|
155
|
+
* @param {object} state - runtime state capsule.
|
|
156
|
+
* @param {string} sessionId - owning session.
|
|
157
|
+
* @param {ReadonlyArray<object>} events - events in log order.
|
|
158
|
+
* @param {number} wm - current watermark (last folded seq).
|
|
159
|
+
* @returns {number} the new watermark.
|
|
160
|
+
*/
|
|
56
161
|
export function foldNew(state, sessionId, events, wm) {
|
|
57
162
|
let max = wm;
|
|
163
|
+
if (!Array.isArray(events))
|
|
164
|
+
return max;
|
|
58
165
|
for (const e of events) {
|
|
59
166
|
if (e && typeof e.seq === 'number' && e.seq > wm) {
|
|
60
167
|
foldEvent(state, sessionId, e);
|
package/lib/host/index.js
CHANGED
|
@@ -3,33 +3,51 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Wires the submodules (fold / scan / query / settings / aggregate / rpc /
|
|
5
5
|
* client-source) into the cordis plugin lifecycle. Strategy notes:
|
|
6
|
-
* - DSH
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* - DSH persists every loop model call's token usage in each session log as
|
|
7
|
+
* an `assistant/message` (or `assistant/attempt`) settlement carrying
|
|
8
|
+
* `usage`; this plugin folds those events with the same replacement /
|
|
9
|
+
* retry semantics as the official `tokenUsage` projection instead of
|
|
9
10
|
* tapping `llm/stream` (unreachable from an agent-scoped dynamic plugin).
|
|
10
11
|
* - Live sessions are scanned from the in-memory store every poll tick;
|
|
11
12
|
* persisted sessions are backfilled at startup and re-scanned incrementally
|
|
12
|
-
*
|
|
13
|
+
* (watermark + revision per session).
|
|
13
14
|
* - Aggregation, cost (user-configurable price table) and model list are
|
|
14
|
-
* served
|
|
15
|
+
* served through src/host/rpc.js.
|
|
16
|
+
*
|
|
17
|
+
* The price table is the plugin's own config: the exported `Config` schema is
|
|
18
|
+
* the official, durable form surface (`Plugins` settings tab -> profile
|
|
19
|
+
* patch), replacing the 0.1.x `settings.register`/`settings.yaml` path that
|
|
20
|
+
* does not exist in DSH 0.2.x.
|
|
15
21
|
*
|
|
16
22
|
* The same entry doubles as the cordis.yml host plugin module (exports name /
|
|
17
|
-
* inject / apply) and as the `code.host` function body for a dynamic
|
|
23
|
+
* inject / Config / apply) and as the `code.host` function body for a dynamic
|
|
24
|
+
* plugin.
|
|
18
25
|
*/
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
import { DEFAULT_PRICES, DEFAULT_CURRENCY, createPriceStore, initSettings } from './settings.js';
|
|
26
|
+
import Schema from '@deepseek-ai/schemastery';
|
|
27
|
+
import { DEFAULT_PRICES, DEFAULT_CURRENCY, createPriceStore, mergePrices } from './settings.js';
|
|
22
28
|
import { createState } from './state.js';
|
|
23
29
|
import { createUsageIndex } from './aggregate.js';
|
|
24
30
|
import { scanLive, scanPersisted, runBackfill } from './scan.js';
|
|
25
31
|
import { query } from './query.js';
|
|
26
32
|
import { registerRPC } from './rpc.js';
|
|
27
33
|
import { getClientSource } from './client-source.js';
|
|
34
|
+
export const name = 'usage-host';
|
|
35
|
+
export const inject = ['timer'];
|
|
36
|
+
/** Durable price table + currency, materialized as the plugin's config form. */
|
|
37
|
+
export const Config = Schema.object({
|
|
38
|
+
currency: Schema.string().default(DEFAULT_CURRENCY),
|
|
39
|
+
prices: Schema.dict(Schema.object({
|
|
40
|
+
inputPerM: Schema.number().default(0),
|
|
41
|
+
outputPerM: Schema.number().default(0),
|
|
42
|
+
cacheReadPerM: Schema.number().default(0),
|
|
43
|
+
cacheWritePerM: Schema.number().default(0),
|
|
44
|
+
})).default(DEFAULT_PRICES),
|
|
45
|
+
});
|
|
28
46
|
const LIVE_POLL_MS = 2000;
|
|
29
47
|
const PERSISTED_POLL_MS = 30000;
|
|
30
|
-
export function apply(ctx) {
|
|
31
|
-
const
|
|
32
|
-
|
|
48
|
+
export function apply(ctx, config) {
|
|
49
|
+
const resolved = config && typeof config === 'object' ? config : {};
|
|
50
|
+
const priceStore = createPriceStore(mergePrices(resolved.prices), resolved.currency || DEFAULT_CURRENCY);
|
|
33
51
|
const state = createState(priceStore);
|
|
34
52
|
state.index = createUsageIndex(state);
|
|
35
53
|
const handlers = new Map();
|
|
@@ -39,6 +57,19 @@ export function apply(ctx) {
|
|
|
39
57
|
store.setPrices(args && args.prices);
|
|
40
58
|
if (args && typeof args.currency === 'string')
|
|
41
59
|
store.setCurrency(args.currency);
|
|
60
|
+
// Secondary durability for hosts where the client cannot reach its config
|
|
61
|
+
// form: write the same values through the official SettingsForms API.
|
|
62
|
+
// The entry id is the bundle patch row id (`usage`).
|
|
63
|
+
try {
|
|
64
|
+
const settings = typeof ctx.get === 'function' ? ctx.get('settings') : undefined;
|
|
65
|
+
if (settings && typeof settings.update === 'function') {
|
|
66
|
+
settings.update('usage', { prices: Object.assign({}, store.prices), currency: store.currency })
|
|
67
|
+
.catch(() => { });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// settings is optional; the in-memory store still answers this session.
|
|
72
|
+
}
|
|
42
73
|
return store.snapshot();
|
|
43
74
|
});
|
|
44
75
|
handlers.set('client-source', () => getClientSource(ctx));
|
|
@@ -46,7 +77,7 @@ export function apply(ctx) {
|
|
|
46
77
|
// Full history scan once at startup.
|
|
47
78
|
runBackfill(ctx, state);
|
|
48
79
|
// Live sessions every tick; persisted sessions on a slow cadence to keep
|
|
49
|
-
// per-session
|
|
80
|
+
// per-session log reads cheap (unchanged revisions are skipped).
|
|
50
81
|
ctx.interval(() => {
|
|
51
82
|
scanLive(ctx, state);
|
|
52
83
|
}, LIVE_POLL_MS);
|
package/lib/host/query.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* by-model table and model candidate list) are owned here.
|
|
6
6
|
*/
|
|
7
7
|
import { dayKey, DAY_MS, bucketKeyFor, stepMs, alignFloor, stepDayKey, MAX_BUCKETS, } from './time.js';
|
|
8
|
+
import { currencyOf } from './settings.js';
|
|
8
9
|
function emptyBucket(key) {
|
|
9
10
|
return {
|
|
10
11
|
key,
|
|
@@ -52,7 +53,7 @@ export function query(state, args) {
|
|
|
52
53
|
const dailyMap = new Map();
|
|
53
54
|
const modelMap = new Map();
|
|
54
55
|
const total = emptyBucket('total');
|
|
55
|
-
const seenModels = new Map(); // composite key -> {provider, model} (first composite wins)
|
|
56
|
+
const seenModels = new Map(); // composite key -> {key, provider, model} (first composite wins)
|
|
56
57
|
let unpricedCalls = 0;
|
|
57
58
|
// Range-anchored day-step grid: blocks start at the range's first day.
|
|
58
59
|
const dayAnchor = Number.isFinite(from) ? alignFloor(from, 'day') : null;
|
|
@@ -60,14 +61,17 @@ export function query(state, args) {
|
|
|
60
61
|
for (const r of state.index ? state.index.recordsInRange(from, to) : state.records) {
|
|
61
62
|
if (r.time < from || r.time > to)
|
|
62
63
|
continue;
|
|
64
|
+
// Candidate list first, selection filter second: the dropdown must offer
|
|
65
|
+
// every model that has data in the range, not only the ones already ticked
|
|
66
|
+
// (ticking one used to collapse the list to that single entry).
|
|
67
|
+
const modelKey = compositeModelKey(r);
|
|
68
|
+
if (!seenModels.has(modelKey))
|
|
69
|
+
seenModels.set(modelKey, { key: modelKey, provider: r.provider, model: r.model });
|
|
63
70
|
if (!modelMatches(r))
|
|
64
71
|
continue;
|
|
65
72
|
const cost = state.prices.costOf(r);
|
|
66
73
|
if (r.totalTokens > 0 && !state.prices.hasPrice(r))
|
|
67
74
|
unpricedCalls += 1;
|
|
68
|
-
const modelKey = compositeModelKey(r);
|
|
69
|
-
if (!seenModels.has(modelKey))
|
|
70
|
-
seenModels.set(modelKey, { provider: r.provider, model: r.model });
|
|
71
75
|
const dk = useStepGrid ? stepDayKey(r, dayAnchor, stepDays) : bucketKeyFor(r.time, granularity, stepDays);
|
|
72
76
|
let db = dailyMap.get(dk);
|
|
73
77
|
if (db === undefined) {
|
|
@@ -145,12 +149,28 @@ export function query(state, args) {
|
|
|
145
149
|
heatmap.push(hmMap.get(k) || emptyBucket(k));
|
|
146
150
|
}
|
|
147
151
|
}
|
|
152
|
+
// Display currency: the price table is denominated in USD and stays as it is
|
|
153
|
+
// (it is shared, polled, and never mutated per request). Conversion happens
|
|
154
|
+
// here, once, at the output boundary — including the heatmap and the totals,
|
|
155
|
+
// so no two views of the same data disagree. Six decimals keep float tails
|
|
156
|
+
// out of the sums.
|
|
157
|
+
const money = currencyOf(args.currency, state.prices.currency);
|
|
158
|
+
const toDisplay = (v) => (money.perUsd === 1 ? v : Math.round(v * money.perUsd * 1e6) / 1e6);
|
|
159
|
+
for (const b of daily)
|
|
160
|
+
b.cost = toDisplay(b.cost);
|
|
161
|
+
for (const b of byModel)
|
|
162
|
+
b.cost = toDisplay(b.cost);
|
|
163
|
+
total.cost = toDisplay(total.cost);
|
|
164
|
+
if (heatmap !== null)
|
|
165
|
+
for (const b of heatmap)
|
|
166
|
+
b.cost = toDisplay(b.cost);
|
|
148
167
|
return {
|
|
149
168
|
daily,
|
|
150
169
|
byModel,
|
|
151
170
|
total,
|
|
152
171
|
models,
|
|
153
172
|
unpricedCalls,
|
|
173
|
+
currency: { code: money.code, symbol: money.symbol },
|
|
154
174
|
...heatmap === null ? {} : { heatmap },
|
|
155
175
|
backfill: { status: state.backfill.status, total: state.backfill.total, done: state.backfill.done },
|
|
156
176
|
};
|
package/lib/host/rpc.js
CHANGED
|
@@ -2,12 +2,109 @@
|
|
|
2
2
|
* RPC registration.
|
|
3
3
|
*
|
|
4
4
|
* Dynamic-plugin runs expose `harness` directly; static (bundle) installs
|
|
5
|
-
* expose the
|
|
6
|
-
*
|
|
5
|
+
* expose the Connection service. Support both from one file, plus the
|
|
6
|
+
* official Typert gateway path (P0-3).
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* Host side, preference order:
|
|
9
|
+
* 1. dynamic-plugin run -> `harness.handle(endpoint, fn)`
|
|
10
|
+
* 2. Typert gateway present -> `typert.register(channel, endpoints)`
|
|
11
|
+
* 3. static (bundle) install -> Connection
|
|
12
|
+
*
|
|
13
|
+
* Connection has two registries and they are NOT interchangeable:
|
|
14
|
+
*
|
|
15
|
+
* - `connection.fetch.register(route)` adds one exact route to the shared
|
|
16
|
+
* `/api` dispatch table. It only touches the service's own map, so it
|
|
17
|
+
* works from any fiber.
|
|
18
|
+
* - `connection.rpc.handle(channel, handler)` mounts a physical prefix route
|
|
19
|
+
* through `owner.webServer.register(route)`, where `owner` is the fiber
|
|
20
|
+
* that READ the service. On 0.2.x every plugin fiber is a strict proxy: a
|
|
21
|
+
* plugin whose `inject` is `['timer']` throws `cannot get property
|
|
22
|
+
* "webServer" without inject` inside that registration, `effect()`
|
|
23
|
+
* rethrows it, and the channel silently never mounts — observed as HTTP
|
|
24
|
+
* 405 on `/usage/*` with no host-side message. Adding `webServer` to the
|
|
25
|
+
* plugin's static `inject` would "fix" it only for compositions that own a
|
|
26
|
+
* web server, and would gate dynamic-plugin loads on a service they never
|
|
27
|
+
* get, so the fetch registry is the correct seam for a bundle install.
|
|
28
|
+
*
|
|
29
|
+
* Therefore: register exact Fetch routes on the shared `/api` channel when
|
|
30
|
+
* that registry exists, and keep the legacy `/usage` prefix channel as a
|
|
31
|
+
* best-effort fallback for older hosts. The browser half calls the Fetch route
|
|
32
|
+
* first and falls back to the legacy channel on "route missing".
|
|
33
|
+
*
|
|
34
|
+
* Wire format of one Fetch route (POST `/api/usage/<endpoint>`):
|
|
35
|
+
* request body = JSON of the endpoint args (or empty for no args)
|
|
36
|
+
* response body = `{ ok: true, value }` | `{ ok: false, error: { code, message } }`
|
|
37
|
+
* i.e. the same result shape the legacy RPC channel wraps, minus its envelope.
|
|
38
|
+
*/
|
|
39
|
+
/** Absolute logical channel owned by this plugin on pre-0.2 hosts. */
|
|
40
|
+
export const USAGE_RPC_CHANNEL = '/usage';
|
|
41
|
+
/** Shared-channel path prefix owned by this plugin (`/api` is Connection's mount). */
|
|
42
|
+
export const USAGE_API_PREFIX = '/api/usage';
|
|
43
|
+
/**
|
|
44
|
+
* Exact Fetch route path for one endpoint.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} endpoint - endpoint name such as `query`.
|
|
47
|
+
* @returns {string} absolute path below `/api`, as `connection.fetch.register` requires.
|
|
48
|
+
*/
|
|
49
|
+
export function fetchRoutePath(endpoint) {
|
|
50
|
+
return USAGE_API_PREFIX + '/' + endpoint;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Failure result in the Connection RPC result shape.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} message - human-readable failure.
|
|
56
|
+
* @param {string} [code] - stable error code.
|
|
57
|
+
* @returns {{ ok: false, error: { code: string, message: string, details: object } }} failure result.
|
|
58
|
+
*/
|
|
59
|
+
export function errorResult(message, code = 'internal') {
|
|
60
|
+
return { ok: false, error: { code, message, details: {} } };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Run one endpoint through the handler map, converting throws into results.
|
|
64
|
+
*
|
|
65
|
+
* @param {Map<string, (args: any) => any>} handlers - endpoint handlers.
|
|
66
|
+
* @param {string} endpoint - endpoint name.
|
|
67
|
+
* @param {unknown} args - decoded endpoint arguments.
|
|
68
|
+
* @returns {Promise<{ ok: true, value: any } | { ok: false, error: object }>} RPC result.
|
|
69
|
+
*/
|
|
70
|
+
export async function dispatch(handlers, endpoint, args) {
|
|
71
|
+
const fn = handlers.get(endpoint);
|
|
72
|
+
if (fn === undefined)
|
|
73
|
+
return errorResult(`unknown endpoint: ${endpoint}`, 'bad-request');
|
|
74
|
+
try {
|
|
75
|
+
return { ok: true, value: await fn(args) };
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
return errorResult(String((error && error.message) || error));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Build the Fetch-shaped handler for one exact route.
|
|
83
|
+
*
|
|
84
|
+
* @param {Map<string, (args: any) => any>} handlers - endpoint handlers.
|
|
85
|
+
* @param {string} endpoint - endpoint name.
|
|
86
|
+
* @returns {(request: Request) => Promise<Response>} route handler.
|
|
87
|
+
*/
|
|
88
|
+
export function createFetchRoute(handlers, endpoint) {
|
|
89
|
+
return async function fetchRoute(request) {
|
|
90
|
+
let args = null;
|
|
91
|
+
const text = await request.text();
|
|
92
|
+
if (text.length > 0) {
|
|
93
|
+
try {
|
|
94
|
+
args = JSON.parse(text);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return Response.json(errorResult('request body is not JSON', 'bad-request'), { status: 400 });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return Response.json(await dispatch(handlers, endpoint, args));
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Register every endpoint on the best transport this host offers.
|
|
105
|
+
*
|
|
106
|
+
* @param {any} ctx - host plugin context.
|
|
107
|
+
* @param {Map<string, (args: any) => any>} handlers - endpoint handlers.
|
|
11
108
|
*/
|
|
12
109
|
export function registerRPC(ctx, handlers) {
|
|
13
110
|
const dynamicHarness = (typeof harness !== 'undefined' && harness && typeof harness.handle === 'function') ? harness : null;
|
|
@@ -48,22 +145,47 @@ export function registerRPC(ctx, handlers) {
|
|
|
48
145
|
}
|
|
49
146
|
// Static (bundle) installs: the `connection` service is provided
|
|
50
147
|
// asynchronously by the client-connection plugin, so wait for it before
|
|
51
|
-
// registering
|
|
148
|
+
// registering (the official api-gateway pattern).
|
|
52
149
|
ctx.inject(['connection'], (connectionCtx) => {
|
|
53
150
|
const connection = connectionCtx.connection;
|
|
54
|
-
if (connection === undefined || connection
|
|
151
|
+
if (connection === undefined || connection === null)
|
|
55
152
|
return;
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
153
|
+
// 0.2+: exact Fetch routes on the shared /api channel. This is the path
|
|
154
|
+
// that actually mounts a route: it never reads `webServer`, so the strict
|
|
155
|
+
// fiber proxy cannot reject it.
|
|
156
|
+
const fetchRegistry = connection.fetch;
|
|
157
|
+
let mountedFetchRoutes = 0;
|
|
158
|
+
if (fetchRegistry !== undefined && typeof fetchRegistry.register === 'function') {
|
|
159
|
+
for (const endpoint of handlers.keys()) {
|
|
160
|
+
const path = fetchRoutePath(endpoint);
|
|
161
|
+
try {
|
|
162
|
+
fetchRegistry.register({
|
|
163
|
+
path,
|
|
164
|
+
methods: ['POST'],
|
|
165
|
+
requestBody: 'buffered',
|
|
166
|
+
fetch: createFetchRoute(handlers, endpoint),
|
|
167
|
+
});
|
|
168
|
+
mountedFetchRoutes += 1;
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
console.warn(`[usage] failed to mount shared Fetch route ${path}: ${String((error && error.message) || error)}`);
|
|
172
|
+
}
|
|
60
173
|
}
|
|
174
|
+
}
|
|
175
|
+
// Pre-0.2 hosts (and any composition that still exposes the prefix
|
|
176
|
+
// channel): the legacy /usage RPC bridge. On 0.2.x this throws inside
|
|
177
|
+
// effect() because the reading fiber owns no `webServer`; that is expected
|
|
178
|
+
// and already covered by the Fetch routes, so the failure stays local.
|
|
179
|
+
const rpcRegistry = connection.rpc;
|
|
180
|
+
if (rpcRegistry !== undefined && typeof rpcRegistry.handle === 'function') {
|
|
61
181
|
try {
|
|
62
|
-
|
|
182
|
+
rpcRegistry.handle(USAGE_RPC_CHANNEL, async (endpoint, args) => dispatch(handlers, endpoint, args), { authority: 'loopback' });
|
|
63
183
|
}
|
|
64
184
|
catch (error) {
|
|
65
|
-
|
|
185
|
+
if (mountedFetchRoutes === 0) {
|
|
186
|
+
console.warn(`[usage] failed to mount ${USAGE_RPC_CHANNEL} RPC channel: ${String((error && error.message) || error)}`);
|
|
187
|
+
}
|
|
66
188
|
}
|
|
67
|
-
}
|
|
189
|
+
}
|
|
68
190
|
});
|
|
69
191
|
}
|