@linxin666/dsh-usage 0.3.7
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 +202 -0
- package/README.i18n.yaml +5 -0
- package/README.md +50 -0
- package/README.zh.md +50 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +755 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +1443 -0
- package/lib/types/client/UsageSectionCard.d.ts +36 -0
- package/lib/types/client/index.d.ts +34 -0
- package/lib/types/client/locales.d.ts +62 -0
- package/lib/types/client/usage-store.d.ts +27 -0
- package/lib/types/core/adapters.d.ts +58 -0
- package/lib/types/core/ledger.d.ts +31 -0
- package/lib/types/core/types.d.ts +112 -0
- package/lib/types/dsh-home.d.ts +16 -0
- package/lib/types/host/http.d.ts +42 -0
- package/lib/types/host/loopback.d.ts +24 -0
- package/lib/types/host/routes.d.ts +14 -0
- package/lib/types/host/usage-service.d.ts +82 -0
- package/lib/types/index.d.ts +21 -0
- package/lib/types/mount-once.d.ts +24 -0
- package/package.json +92 -0
- package/src/client/UsageSectionCard.tsx +368 -0
- package/src/client/css-modules.d.ts +4 -0
- package/src/client/index.ts +131 -0
- package/src/client/locales.ts +116 -0
- package/src/client/usage-store.ts +52 -0
- package/src/client/usage.module.css +311 -0
- package/src/core/adapters.ts +370 -0
- package/src/core/ledger.ts +108 -0
- package/src/core/types.ts +127 -0
- package/src/dsh-home.ts +42 -0
- package/src/host/http.ts +105 -0
- package/src/host/loopback.ts +63 -0
- package/src/host/routes.ts +54 -0
- package/src/host/usage-service.ts +530 -0
- package/src/index.ts +90 -0
- package/src/mount-once.ts +48 -0
package/src/host/http.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/host/http.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* Shared JSON body/response helpers for the host route families: one strict
|
|
4
|
+
* bounded body reader, one lenient bounded body reader, one JSON object
|
|
5
|
+
* narrow, and one JSON writer. Previously these were copy-pasted across the
|
|
6
|
+
* package route files (routes.ts, update-routes.ts, mobile-api.ts, and each
|
|
7
|
+
* family's route module) with drifting contracts: body caps ranging 4 KiB to
|
|
8
|
+
* 1 MiB and four distinct overflow behaviors (reject, undefined, null, throw).
|
|
9
|
+
*
|
|
10
|
+
* Packages receive this file as a generated copy via scripts/sync-shared.mjs;
|
|
11
|
+
* edit this shared source and re-run the sync instead of editing a copy.
|
|
12
|
+
* Consumer code is migrated onto it in follow-up waves; no call site changes
|
|
13
|
+
* belong in the same change as its introduction.
|
|
14
|
+
* @module dsh-web-shared/host/http
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'node:http'
|
|
18
|
+
|
|
19
|
+
/** Default body cap for readJsonBody: 64 KiB. */
|
|
20
|
+
const DEFAULT_JSON_BODY_MAX_BYTES = 64 * 1024
|
|
21
|
+
|
|
22
|
+
/** Family-default JSON response headers; callers may append or override. */
|
|
23
|
+
const JSON_HEADERS = {
|
|
24
|
+
'content-type': 'application/json; charset=utf-8',
|
|
25
|
+
'referrer-policy': 'no-referrer',
|
|
26
|
+
} satisfies OutgoingHttpHeaders
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Strict bounded body reader: parse a request body of at most maxBytes as
|
|
30
|
+
* JSON.
|
|
31
|
+
* @throws 'body too large' past the cap, or the JSON.parse error for an
|
|
32
|
+
* invalid or empty payload.
|
|
33
|
+
*/
|
|
34
|
+
export async function readBoundedJson(req: IncomingMessage, maxBytes: number): Promise<unknown> {
|
|
35
|
+
const chunks: Buffer[] = []
|
|
36
|
+
let size = 0
|
|
37
|
+
for await (const chunk of req) {
|
|
38
|
+
const buffer = chunk as Buffer
|
|
39
|
+
size += buffer.length
|
|
40
|
+
if (size > maxBytes) throw new Error('body too large')
|
|
41
|
+
chunks.push(buffer)
|
|
42
|
+
}
|
|
43
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Lenient bounded body reader: parse a request body as JSON, or null on an
|
|
48
|
+
* empty body, invalid JSON, or a body past maxBytes (default 64 KiB).
|
|
49
|
+
* Overflow destroys the request instead of draining the remainder (no drain
|
|
50
|
+
* call, matching the current repo-wide behavior); callers must not keep
|
|
51
|
+
* reading the request afterwards. With objectOnly, non-JSON-object payloads
|
|
52
|
+
* also yield null.
|
|
53
|
+
*/
|
|
54
|
+
export async function readJsonBody(
|
|
55
|
+
req: IncomingMessage,
|
|
56
|
+
opts: { maxBytes?: number; objectOnly?: boolean } = {},
|
|
57
|
+
): Promise<unknown | null> {
|
|
58
|
+
const maxBytes = opts.maxBytes ?? DEFAULT_JSON_BODY_MAX_BYTES
|
|
59
|
+
const chunks: Buffer[] = []
|
|
60
|
+
let size = 0
|
|
61
|
+
for await (const chunk of req) {
|
|
62
|
+
const buffer = chunk as Buffer
|
|
63
|
+
size += buffer.length
|
|
64
|
+
if (size > maxBytes) {
|
|
65
|
+
req.destroy()
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
chunks.push(buffer)
|
|
69
|
+
}
|
|
70
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
71
|
+
if (text === '') return null
|
|
72
|
+
try {
|
|
73
|
+
const parsed: unknown = JSON.parse(text)
|
|
74
|
+
if (opts.objectOnly && !isJsonObject(parsed)) return null
|
|
75
|
+
return parsed
|
|
76
|
+
} catch {
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Whether a value is a JSON object: typeof object, not null, not an array. */
|
|
82
|
+
function isJsonObject(value: unknown): value is Record<string, unknown> {
|
|
83
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Narrow a value to a JSON object, or undefined when it is not one. */
|
|
87
|
+
export function asJsonObject(value: unknown): Record<string, unknown> | undefined {
|
|
88
|
+
return isJsonObject(value) ? value : undefined
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Write one JSON response. Default headers are the family defaults
|
|
93
|
+
* (content-type and referrer-policy); caller headers are appended or
|
|
94
|
+
* override them.
|
|
95
|
+
*/
|
|
96
|
+
export function writeJson(
|
|
97
|
+
res: ServerResponse,
|
|
98
|
+
status: number,
|
|
99
|
+
body: unknown,
|
|
100
|
+
headers: OutgoingHttpHeaders = {},
|
|
101
|
+
): void {
|
|
102
|
+
const payload = JSON.stringify(body)
|
|
103
|
+
res.writeHead(status, { ...JSON_HEADERS, ...headers })
|
|
104
|
+
res.end(payload)
|
|
105
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/host/loopback.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* Loopback trust fence shared by the host route families: socket address,
|
|
4
|
+
* Host header, and browser same-origin markers. Packages receive this file as
|
|
5
|
+
* a generated copy via scripts/sync-shared.mjs; edit the shared source and
|
|
6
|
+
* re-run the sync instead of editing a copy.
|
|
7
|
+
*
|
|
8
|
+
* Semantics: RFC 5735 IPv4 127/8, ::1, IPv4-mapped ::ffff:127/8 (matching the
|
|
9
|
+
* remote-web-ui gate), localhost hostnames, plus the browser same-origin
|
|
10
|
+
* markers (sec-fetch-site and Origin) for the request-level fence.
|
|
11
|
+
* @module dsh-web-shared/host/loopback
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { IncomingMessage } from 'node:http'
|
|
15
|
+
|
|
16
|
+
/** IPv4 127/8 predicate (four decimal octets, first == 127). */
|
|
17
|
+
export function isIPv4Loopback(v4: string): boolean {
|
|
18
|
+
const parts = v4.split('.')
|
|
19
|
+
return parts.length === 4
|
|
20
|
+
&& parts[0] === '127'
|
|
21
|
+
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
|
|
25
|
+
export function isLoopbackAddress(address: string | undefined): boolean {
|
|
26
|
+
if (address === undefined) return false
|
|
27
|
+
const normalized = address.toLowerCase()
|
|
28
|
+
if (normalized === '::1') return true
|
|
29
|
+
if (normalized.startsWith('::ffff:')) return isIPv4Loopback(normalized.slice('::ffff:'.length))
|
|
30
|
+
return isIPv4Loopback(normalized)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Whether a normalized URL hostname names the loopback authority (localhost, [::1], 127/8). */
|
|
34
|
+
export function isLoopbackHostname(hostname: string): boolean {
|
|
35
|
+
if (hostname === 'localhost' || hostname === '[::1]') return true
|
|
36
|
+
return isIPv4Loopback(hostname)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Request-level trust fence: a loopback socket address AND a loopback Host
|
|
41
|
+
* header, plus browser same-origin markers. The socket address is
|
|
42
|
+
* authoritative; X-Forwarded-For is never trusted.
|
|
43
|
+
*/
|
|
44
|
+
export function isLoopbackRequest(request: IncomingMessage): boolean {
|
|
45
|
+
if (!isLoopbackAddress(request.socket.remoteAddress)) return false
|
|
46
|
+
const host = request.headers.host
|
|
47
|
+
if (typeof host !== 'string') return false
|
|
48
|
+
let hostUrl: URL
|
|
49
|
+
try {
|
|
50
|
+
hostUrl = new URL('http://' + host)
|
|
51
|
+
} catch {
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
if (!isLoopbackHostname(hostUrl.hostname)) return false
|
|
55
|
+
if (request.headers['sec-fetch-site'] === 'cross-site') return false
|
|
56
|
+
const origin = request.headers.origin
|
|
57
|
+
if (origin === undefined) return true
|
|
58
|
+
try {
|
|
59
|
+
return new URL(origin).host === hostUrl.host
|
|
60
|
+
} catch {
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
2
|
+
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
3
|
+
import { isLoopbackRequest } from './loopback.ts'
|
|
4
|
+
import { writeJson } from './http.ts'
|
|
5
|
+
import type { UsageService } from './usage-service.ts'
|
|
6
|
+
|
|
7
|
+
export const USAGE_API_PREFIX = '/api/dsh-usage'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Loopback-fenced overview route: provider balances, plan quotas, and token
|
|
11
|
+
* usage totals. Personal account data, so the loopback fence mirrors
|
|
12
|
+
* dsh-perf's stats surface; the browser runs on the same machine.
|
|
13
|
+
*/
|
|
14
|
+
export function makeUsageOverviewRoute(service: UsageService): WebRoute {
|
|
15
|
+
return {
|
|
16
|
+
kind: 'exact',
|
|
17
|
+
path: USAGE_API_PREFIX + '/overview',
|
|
18
|
+
handler: async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
19
|
+
if (!isLoopbackRequest(req)) {
|
|
20
|
+
writeJson(res, 403, { ok: false, error: 'forbidden: loopback-only' })
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
writeJson(res, 200, service.overview(), { 'cache-control': 'no-store' })
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Loopback-fenced manual refresh: forces one probe cycle now and answers
|
|
30
|
+
* with the fresh overview.
|
|
31
|
+
*/
|
|
32
|
+
export function makeUsageRefreshRoute(service: UsageService): WebRoute {
|
|
33
|
+
return {
|
|
34
|
+
kind: 'exact',
|
|
35
|
+
path: USAGE_API_PREFIX + '/refresh',
|
|
36
|
+
handler: async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
37
|
+
if (!isLoopbackRequest(req)) {
|
|
38
|
+
writeJson(res, 403, { ok: false, error: 'forbidden: loopback-only' })
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
if (req.method !== 'POST') {
|
|
42
|
+
writeJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
await service.refresh()
|
|
47
|
+
} catch (error) {
|
|
48
|
+
writeJson(res, 500, { ok: false, error: error instanceof Error ? error.message : 'refresh failed' })
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
writeJson(res, 200, service.overview(), { 'cache-control': 'no-store' })
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
}
|