@jeffusion/bungee-core 5.15.2 → 5.17.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.
@@ -242,6 +242,8 @@
242
242
  "ui.previousInterval": "vs previous {interval}",
243
243
  "ui.minutes": "min",
244
244
  "ui.hours": "h",
245
+ "ui.days": "d",
246
+ "page.trendHint": "Compares the two latest complete time buckets in the selected range; incomplete buckets are excluded.",
245
247
  "ui.newActivity": "New",
246
248
  "page.range": "Time range",
247
249
  "page.range.1h": "1 hour",
@@ -355,6 +357,8 @@
355
357
  "ui.previousInterval": "较上个{interval}",
356
358
  "ui.minutes": "分钟",
357
359
  "ui.hours": "小时",
360
+ "ui.days": "天",
361
+ "page.trendHint": "对比所选范围内最近两个完整统计时段,未完成时段不参与。",
358
362
  "ui.newActivity": "新增",
359
363
  "page.range": "时间范围",
360
364
  "page.range.1h": "1 小时",
@@ -11,6 +11,8 @@
11
11
  import { Input } from '$components/ui/input';
12
12
  import UsageTimeChart from './UsageTimeChart.svelte';
13
13
  import { getStatsResource } from './stats';
14
+ import KpiTrendValue from '$components/industrial/KpiTrendValue.svelte';
15
+ import { overviewTrends, PAGE_METRICS } from './overview-trends';
14
16
  import { formatCacheInputPercentage, formatEstimatedUsd, formatTokenCount, modelTokenTotal, tokenComposition, usagePresentation, type TokenPageRange } from './labels';
15
17
  import type { StatsState, StatsResource } from './stats-resource';
16
18
 
@@ -21,11 +23,12 @@
21
23
  let keysFailed = $state(false);
22
24
  let sort = $state('tokens');
23
25
  let search = $state('');
24
- let models: StatsState = $state({ data: null, busy: false, error: '', refreshedAt: 0 });
25
- let time: StatsState = $state({ data: null, busy: false, error: '', refreshedAt: 0 });
26
+ let models: StatsState = $state({ data: null, busy: true, error: '', refreshedAt: 0 });
27
+ let time: StatsState = $state({ data: null, busy: true, error: '', refreshedAt: 0 });
26
28
  let modelResource: StatsResource | undefined = $state();
27
29
  let timeResource: StatsResource | undefined = $state();
28
- const t = (key: string) => $isLoading ? '' : getPluginText(key, pluginName, (id, options) => $_(id, options));
30
+ const t = (key: string, values?: Record<string, string>) => $isLoading ? '' : getPluginText(key, pluginName,
31
+ (id, options) => $_(id, { ...options, values }));
29
32
  const display = formatCompactNumber;
30
33
  const fullCount = (value: unknown) => formatTokenCount(value, $locale ?? undefined);
31
34
  const displayCost = (value: unknown) => typeof value === 'number' && value >= 1000
@@ -47,6 +50,16 @@
47
50
  .filter(row => modelLabel(row.dimension).toLowerCase().includes(search.trim().toLowerCase()))
48
51
  .sort((a, b) => rankAmount(b) - rankAmount(a) || a.dimension.localeCompare(b.dimension)));
49
52
  const maxTokens = $derived(Math.max(0, ...rows.map(row => modelTokenTotal(row) ?? 0)));
53
+ const modelsLoading = $derived(models.busy && !models.data);
54
+ const trends = $derived(overviewTrends(time.error || time.data?.reportingIncomplete ? null : time.data, PAGE_METRICS));
55
+ const comparisonCaption = $derived(t('ui.previousInterval', { interval: ['day', '1d'].includes(range)
56
+ ? `1${t('ui.hours')}` : `1${t('ui.days')}` }));
57
+ const summaryMetrics = $derived([
58
+ { metric: 'input', label: t('ui.input'), value: display(usage?.input), title: fullCount(usage?.input) },
59
+ { metric: 'output', label: t('ui.output'), value: display(usage?.output), title: fullCount(usage?.output) },
60
+ { metric: 'tokens', label: t('page.tokens'), value: display(models.data ? modelTokenTotal(models.data) : null), title: fullCount(models.data ? modelTokenTotal(models.data) : null) },
61
+ { metric: 'cost', label: t('ui.estimatedCostUsd'), value: displayCost(models.data?.estimatedCostUsd), title: formatEstimatedUsd(models.data?.estimatedCostUsd) },
62
+ ] as const);
50
63
  const busy = $derived(models.busy || time.busy);
51
64
  const refreshedAt = $derived(Math.min(models.refreshedAt, time.refreshedAt));
52
65
  const ranges = $derived(['day', 'week', 'month', '1d', '7d', '30d'].map(value => ({ value, label: t(`page.range.${value}`) })));
@@ -76,6 +89,10 @@
76
89
  function refresh() { void modelResource?.refresh(); void timeResource?.refresh(); }
77
90
  </script>
78
91
 
92
+ {#snippet loadingKpi()}
93
+ <LoadingIndicator label={t('ui.loading')} size="sm" height="none" class="h-9 w-full" />
94
+ {/snippet}
95
+
79
96
  <div class="min-w-0 space-y-4" data-testid="token-stats-page">
80
97
  <div class="flex flex-wrap items-center justify-between gap-3">
81
98
  <div class="flex flex-wrap items-center gap-3">
@@ -97,29 +114,40 @@
97
114
  <span>{t('ui.loadFailed')}</span><Button variant="ghost" size="sm" onclick={refresh} disabled={busy}>{t('page.retry')}</Button>
98
115
  </div>
99
116
  {/if}
100
- {#if models.busy && !models.data}
101
- <LoadingIndicator label={t('ui.loading')} height="sm" />
102
- {:else if models.data}
103
- <div class="grid grid-cols-2 gap-3 xl:grid-cols-4" data-testid="token-stats-page-summary">
104
- <KpiCard class="min-w-0 [container-type:inline-size]" label={t('ui.input')} value={display(usage?.input)} valueTitle={fullCount(usage?.input)} />
105
- <KpiCard class="min-w-0 [container-type:inline-size]" label={t('ui.output')} value={display(usage?.output)} valueTitle={fullCount(usage?.output)} />
106
- <KpiCard class="min-w-0 [container-type:inline-size]" label={t('page.tokens')} value={display(modelTokenTotal(models.data))} valueTitle={fullCount(modelTokenTotal(models.data))} />
107
- <KpiCard class="min-w-0 [container-type:inline-size]" label={t('ui.estimatedCostUsd')} value={displayCost(models.data.estimatedCostUsd)} valueTitle={formatEstimatedUsd(models.data.estimatedCostUsd)} />
108
- </div>
109
- {#if usage?.state === 'empty'}<p class="py-8 text-center text-sm text-zinc-400" data-testid="token-stats-page-empty">{t('ui.noData')}</p>{/if}
110
- {/if}
117
+ <div class="grid grid-cols-2 gap-3 xl:grid-cols-4" data-testid="token-stats-page-summary" aria-busy={modelsLoading}>
118
+ {#each summaryMetrics as item, index (item.metric)}
119
+ {@const trend = trends[index]!}
120
+ <KpiCard fillHeight reserveFooter class="h-[144px] min-w-0 [container-type:inline-size] xl:h-[168px]"
121
+ label={item.label} value={item.value} valueTitle={item.title} children={modelsLoading ? loadingKpi : undefined}>
122
+ {#snippet foot()}
123
+ <div class="flex h-full min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5" data-testid={`token-stats-page-trend-${item.metric}`} title={t('page.trendHint')}>
124
+ {#if time.busy && !time.data}
125
+ <LoadingIndicator size="xs" centered={false} height="none" label={t('ui.loading')} />
126
+ {:else}
127
+ <KpiTrendValue value={trend.isNew ? t('ui.newActivity') : trend.percent == null ? '—' : `${trend.percent >= 0 ? '+' : ''}${trend.percent.toFixed(1)}%`}
128
+ change={trend.change} direction={item.metric === 'cost' ? 'down' : 'up'} clamp />
129
+ <span class="nx-label-sm">{comparisonCaption}</span>
130
+ {/if}
131
+ </div>
132
+ {/snippet}
133
+ </KpiCard>
134
+ {/each}
135
+ </div>
136
+ {#if usage?.state === 'empty'}<p class="py-8 text-center text-sm text-zinc-400" data-testid="token-stats-page-empty">{t('ui.noData')}</p>{/if}
111
137
 
112
138
  <PanelCard title={t('page.activity')} tag="TOKEN / TIME">
113
139
  <div class="h-[240px] min-w-0 sm:h-[300px]">
114
- {#if time.busy && !time.data}<LoadingIndicator label={t('ui.loading')} />
140
+ {#if time.busy && !time.data}<LoadingIndicator label={t('ui.loading')} height="none" class="h-full" />
115
141
  {:else if time.data && usagePresentation(time.data).positive}
116
142
  <UsageTimeChart stats={time.data} {range} refreshedAt={time.refreshedAt} {pluginName} presentation="page" />
117
143
  {:else}<p class="flex h-full items-center justify-center text-sm text-zinc-400">{t(time.data && usagePresentation(time.data).state === 'empty' ? 'ui.noData' : 'ui.noBreakdown')}</p>{/if}
118
144
  </div>
119
145
  </PanelCard>
120
146
 
121
- {#if models.data}
122
- <PanelCard title={t('page.models')} tag={`${rows.length} / ${models.data.data.length}`}>
147
+ <PanelCard title={t('page.models')} tag={models.data ? `${rows.length} / ${models.data.data.length}` : ''}>
148
+ {#if modelsLoading}
149
+ <LoadingIndicator label={t('ui.loading')} />
150
+ {:else if models.data}
123
151
  <div class="mb-3 flex flex-wrap items-center gap-3">
124
152
  <BSegmentedControl options={sorts} bind:value={sort} ariaLabel={t('page.sort')} class="shrink-0" />
125
153
  <Input bind:value={search} aria-label={t('page.searchModels')} placeholder={t('page.searchModels')} class="min-w-[140px] flex-1 sm:max-w-64" />
@@ -143,6 +171,8 @@
143
171
  </li>
144
172
  {:else}<li class="py-6 text-center text-xs text-zinc-400">{t(search ? 'page.noModels' : 'ui.noData')}</li>{/each}
145
173
  </ol>
146
- </PanelCard>
147
- {/if}
174
+ {:else}
175
+ <p class="py-6 text-center text-xs text-zinc-400">{t(models.error ? 'ui.loadFailed' : 'ui.noData')}</p>
176
+ {/if}
177
+ </PanelCard>
148
178
  </div>
@@ -1,29 +1,42 @@
1
1
  import { bucketTrend } from '../../../packages/ui/src/components/dashboard/trends';
2
- import { usagePresentation } from './labels';
2
+ import { modelTokenTotal, usagePresentation } from './labels';
3
3
  import type { StatsResponse } from './stats-resource';
4
4
 
5
5
  export const OVERVIEW_METRICS = ['input', 'output', 'cost'] as const;
6
6
  export type OverviewMetric = typeof OVERVIEW_METRICS[number];
7
- export type OverviewTrend = { metric: OverviewMetric; percent: number | null; change: 'up' | 'down' | 'flat' | null; isNew: boolean };
7
+ export const PAGE_METRICS = ['input', 'output', 'tokens', 'cost'] as const;
8
+ export type SummaryMetric = typeof PAGE_METRICS[number];
9
+ export type OverviewTrend = { metric: SummaryMetric; percent: number | null; change: 'up' | 'down' | 'flat' | null; isNew: boolean };
8
10
 
9
11
  /** Compare adjacent completed server buckets. Missing traffic is zero; missing metering stays unknown. */
10
- export function overviewTrends(snapshot: StatsResponse | null): OverviewTrend[] {
12
+ export function overviewTrends(snapshot: StatsResponse | null, metrics: readonly SummaryMetric[] = OVERVIEW_METRICS): OverviewTrend[] {
11
13
  const { asOfMs, bucketMs } = snapshot ?? {};
12
- const valid = snapshot?.groupBy === 'time' && Number.isSafeInteger(asOfMs) && Number.isSafeInteger(bucketMs) && bucketMs! > 0;
13
- const currentStart = valid ? Math.floor(asOfMs! / bucketMs!) * bucketMs! - bucketMs! : NaN;
14
- return OVERVIEW_METRICS.map(metric => {
14
+ let valid = snapshot?.groupBy === 'time' && Number.isSafeInteger(asOfMs) && Number.isSafeInteger(bucketMs) && bucketMs! > 0;
15
+ let currentStart = valid ? Math.floor(asOfMs! / bucketMs!) * bucketMs! - bucketMs! : NaN;
16
+ let previousStart = currentStart - bucketMs!;
17
+ if (valid && snapshot?.bucketStarts) {
18
+ const starts = snapshot.bucketStarts;
19
+ valid = starts.every((start, index) => Number.isSafeInteger(start) && (index === 0 || start > starts[index - 1]!));
20
+ // Calendar days can span 23/25 hours; rolling page windows need not align with the UTC clock.
21
+ const current = valid ? starts.findLastIndex((start, index) =>
22
+ (starts[index + 1] ?? snapshot.bucketEndMs ?? start + bucketMs!) <= asOfMs!) : -1;
23
+ valid = current >= 1;
24
+ currentStart = valid ? starts[current]! : NaN;
25
+ previousStart = valid ? starts[current - 1]! : NaN;
26
+ }
27
+ return metrics.map(metric => {
15
28
  const amount = (start: number): number | null => {
16
29
  if (!valid) return null;
17
30
  const rows = snapshot!.data.filter(row => row.bucketStartMs === start);
18
31
  let total = 0;
19
32
  for (const row of rows) {
20
- const value = metric === 'cost' ? row.estimatedCostUsd : usagePresentation(row)[metric];
33
+ const value = metric === 'cost' ? row.estimatedCostUsd : metric === 'tokens' ? modelTokenTotal(row) : usagePresentation(row)[metric];
21
34
  if (value == null || !Number.isFinite(value) || value < 0) return null;
22
35
  total += value;
23
36
  }
24
37
  return total;
25
38
  };
26
- const previous = amount(currentStart - bucketMs!), current = amount(currentStart);
39
+ const previous = amount(previousStart), current = amount(currentStart);
27
40
  const known = previous !== null && current !== null;
28
41
  const percent = known ? bucketTrend([previous, current]) : null;
29
42
  return { metric, percent: Number.isFinite(percent) ? percent : null,
@@ -0,0 +1,4 @@
1
+ import type { ForwardPublicRequestOptions } from './forwarding';
2
+ import { type WebSocketBridge } from '../websocket';
3
+ /** Pin the admitted worker for this connection; publication never migrates an open socket. */
4
+ export declare function forwardPublicWebSocket(request: Request, server: Bun.Server<any>, bridge: WebSocketBridge, options: ForwardPublicRequestOptions, peer?: string): Promise<Response | undefined>;
@@ -231,6 +231,8 @@ export declare class ScopedPluginRegistry {
231
231
  private combinedHooksCache;
232
232
  /** Phase-aware Hooks 缓存:`phase-aware:${routeId}#${upstreamId}#${serviceName}` → PhaseAwareHooks */
233
233
  private phaseAwareCache;
234
+ private websocketHandshakeHooks;
235
+ private websocketObservationHooks;
234
236
  private attemptObservationHooks;
235
237
  /** 已加载的插件类:pluginName → PluginClass */
236
238
  private pluginClasses;
@@ -390,6 +392,16 @@ export declare class ScopedPluginRegistry {
390
392
  * ```
391
393
  */
392
394
  getPrecompiledHooks(routeId: string, upstreamId?: string, serviceName?: string): PhaseAwareHooks;
395
+ /** Retain every instance actually executed by handshake phases; observer precedence is different. */
396
+ getWebSocketHandshakeOwners(routeId: string, upstreamId: string, serviceName?: string): Array<{
397
+ pluginName: string;
398
+ scopeKey: string;
399
+ }>;
400
+ getWebSocketOwners(routeId: string, upstreamId?: string, serviceName?: string): Array<{
401
+ pluginName: string;
402
+ scopeKey: string;
403
+ hooks: PluginHooks['onWebSocketObservation'];
404
+ }>;
393
405
  getAttemptObservationOwners(routeId: string, upstreamId?: string, serviceName?: string, context?: PluginBodyRequirementContext): AttemptObservationOwner[];
394
406
  /**
395
407
  * 获取路由级预编译 Hooks(不含上游插件)
@@ -0,0 +1,9 @@
1
+ import type { IncomingMessage, OutgoingHttpHeaders } from 'node:http';
2
+ /** Ordinary HTTP and other upgrade protocols remain owned by the HTTP router. */
3
+ export declare function isWebSocketUpgradeRequest(request: Request): boolean;
4
+ export declare function validateWebSocketRequest(request: Request): Response | undefined;
5
+ export declare function offeredProtocols(request: Request): string[];
6
+ /** ws generates a fresh key, Host, Connection, Upgrade and version for this hop. */
7
+ export declare function upstreamHeaders(headers: Headers, protocols: readonly string[]): OutgoingHttpHeaders;
8
+ /** rawHeaders retains repeated Set-Cookie; framing and internal headers do not cross hops. */
9
+ export declare function downstreamHeaders(response: IncomingMessage, rejection?: boolean): Headers;
@@ -0,0 +1,3 @@
1
+ export { createWebSocketMessageView } from './message';
2
+ export { isWebSocketUpgradeRequest, validateWebSocketRequest } from './headers';
3
+ export { createWebSocketBridge, WebSocketBridge, WEBSOCKET_BRIDGE_DEFAULTS, type WebSocketBridgeOptions, type WebSocketBridgeData, type WebSocketUpgradeOptions, type WebSocketBridgeStats, } from './transport';
@@ -0,0 +1,3 @@
1
+ import type { WebSocketMessageView } from '../gateway/websocket-contracts';
2
+ /** One immutable view per complete message; parsing is shared and demand driven. */
3
+ export declare function createWebSocketMessageView(message: string | Uint8Array): WebSocketMessageView;
@@ -0,0 +1,17 @@
1
+ import type { PluginHooks } from '../hooks';
2
+ import type { WebSocketObservationEvent, WebSocketSessionContext } from '../gateway/websocket-contracts';
3
+ type Detail = WebSocketObservationEvent extends infer E ? E extends WebSocketObservationEvent ? Omit<E, keyof WebSocketSessionContext | 'isActive'> : never : never;
4
+ type Owner = {
5
+ pluginName: string;
6
+ hooks: PluginHooks['onWebSocketObservation'];
7
+ };
8
+ /** Independent, ordered queues: an optional observer cannot block forwarding or another owner. */
9
+ export declare class WebSocketObservers {
10
+ private branches;
11
+ constructor(context: WebSocketSessionContext, owners: readonly Owner[]);
12
+ emit(detail: Detail): void;
13
+ close(detail: Extract<Detail, {
14
+ phase: 'close';
15
+ }>): Promise<void>;
16
+ }
17
+ export {};
@@ -0,0 +1,77 @@
1
+ import type { WebSocketMessageView, WebSocketSessionMetrics } from '../gateway/websocket-contracts';
2
+ export declare const WEBSOCKET_BRIDGE_DEFAULTS: Readonly<{
3
+ maxMessageBytes: number;
4
+ maxConnections: 64;
5
+ maxHandshakes: 16;
6
+ handshakeTimeoutMs: 10000;
7
+ maxBufferedBytes: number;
8
+ maxTotalBufferedBytes: number;
9
+ maxEarlyMessages: 16;
10
+ maxFragments: 1024;
11
+ maxRejectedBodyBytes: number;
12
+ closeTimeoutMs: 1000;
13
+ }>;
14
+ type Limits = {
15
+ [K in keyof typeof WEBSOCKET_BRIDGE_DEFAULTS]: number;
16
+ };
17
+ export type WebSocketBridgeOptions = Partial<Limits>;
18
+ /** Opaque token prevents callbacks from taking ownership of another bridge's session. */
19
+ export interface WebSocketBridgeData {
20
+ readonly connection: object;
21
+ }
22
+ export interface WebSocketUpgradeOptions {
23
+ readonly url: URL | string;
24
+ readonly headers: Headers;
25
+ /** Handshake cancellation only. Removed before committing the downstream upgrade. */
26
+ readonly signal?: AbortSignal;
27
+ readonly onOpen?: () => unknown;
28
+ readonly onMessage?: (direction: 'client' | 'upstream', message: WebSocketMessageView) => unknown;
29
+ /** Runs once after both native close callbacks, for committed sessions only. */
30
+ readonly onClose?: (code: number, reason: string, metrics: WebSocketSessionMetrics) => unknown;
31
+ }
32
+ export interface WebSocketBridgeStats {
33
+ readonly accepting: boolean;
34
+ readonly connections: number;
35
+ readonly handshakes: number;
36
+ readonly bufferedBytes: number;
37
+ readonly processBufferedBytes: number;
38
+ readonly downstreamBackpressureEvents: number;
39
+ readonly downstreamDrainEvents: number;
40
+ }
41
+ export declare class WebSocketBridge {
42
+ #private;
43
+ readonly limits: Readonly<Limits>;
44
+ readonly websocket: Bun.WebSocketHandler<WebSocketBridgeData>;
45
+ constructor(options?: WebSocketBridgeOptions);
46
+ get stats(): WebSocketBridgeStats;
47
+ /** Call only for upgrade candidates; ordinary HTTP returns undefined without taking ownership. */
48
+ upgrade(request: Request, server: Bun.Server<any>, options: WebSocketUpgradeOptions): Promise<Response | undefined>;
49
+ stopAccepting(): void;
50
+ /** 1012 close, bounded grace, then terminate, and require actual native close confirmation. */
51
+ stop(): Promise<void>;
52
+ forceStop(): Promise<void>;
53
+ get accepting(): boolean;
54
+ /** @internal */ endHandshake(): void;
55
+ /** @internal */ backpressure(): void;
56
+ /** @internal */ confirmGone(session: BridgeSession): void;
57
+ }
58
+ type Message = string | Buffer;
59
+ declare class BridgeSession {
60
+ #private;
61
+ readonly token: Readonly<{}>;
62
+ readonly response: Promise<Response | undefined>;
63
+ constructor(bridge: WebSocketBridge, request: Request, server: Bun.Server<any>, options: WebSocketUpgradeOptions, url: URL);
64
+ get bufferedBytes(): number;
65
+ get processLimit(): number;
66
+ start(): void;
67
+ cancelHandshake(status: number, reason: string): void;
68
+ downstreamOpen(socket: Bun.ServerWebSocket<WebSocketBridgeData>): void;
69
+ fromClient(message: Message): void;
70
+ downstreamDrain(): void;
71
+ refreshBuffered(): void;
72
+ close(code: number, reason: string): void;
73
+ terminate(): void;
74
+ downstreamClose(code: number, reason: string): void;
75
+ }
76
+ export declare function createWebSocketBridge(options?: WebSocketBridgeOptions): WebSocketBridge;
77
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jeffusion/bungee-core",
3
- "version": "5.15.2",
3
+ "version": "5.17.0",
4
4
  "description": "Bungee reverse proxy core engine",
5
5
  "main": "./dist/main.js",
6
6
  "types": "./dist/main.d.ts",
@@ -12,7 +12,9 @@
12
12
  "test": "bun test"
13
13
  },
14
14
  "dependencies": {
15
- "@jeffusion/bungee-types": "^5.15.2",
15
+ "@bungee/ws-client": "npm:ws@8.22.0",
16
+ "@types/ws": "^8.18.1",
17
+ "@jeffusion/bungee-types": "^5.17.0",
16
18
  "@types/lodash-es": "^4.17.12",
17
19
  "acorn": "^8.15.0",
18
20
  "canonicalize": "3.0.0",