@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.
@@ -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
+ }