@anycon/protocol 0.1.0 → 0.2.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/src/index.ts CHANGED
@@ -6,12 +6,14 @@
6
6
  * - 内层 JSON-RPC 编解码与 `RpcPeer`(`rpc.ts`)
7
7
  * - Noise wasm 封装(`crypto.ts`)
8
8
  * - 常量(`constants.ts`)
9
+ * - dev server 预览:启动参数、label、隧道编解码(`preview.ts`,ADR-39)
9
10
  */
10
11
  export * from './constants.js'
11
12
  export * from './crypto.js'
12
13
  export * from './envelope.js'
13
14
  export * from './errors.js'
14
15
  export * from './method-types.js'
16
+ export * from './preview.js'
15
17
  export * from './rpc.js'
16
18
 
17
19
  // 生成的类型。`Control`、`RpcMessage` 等已经由上面的模块导出,这里只补充其余的。
@@ -29,6 +29,7 @@ import type {
29
29
  JobStarted,
30
30
  NodeAuditParams,
31
31
  NodeAuditResult,
32
+ NodeConfigResult,
32
33
  NodeDevicesResult,
33
34
  NodeDoctorResult,
34
35
  NodeEnrollApproveParams,
@@ -39,11 +40,14 @@ import type {
39
40
  NodeEnrollResolved,
40
41
  NodeEnrollStatusResult,
41
42
  NodeInfo,
43
+ NodeLogsParams,
44
+ NodeLogsResult,
42
45
  NodePairBeginParams,
43
46
  NodePairBeginResult,
44
47
  NodeRevokeDeviceParams,
45
48
  NodeSecurityEvent,
46
49
  NodeSetAgentDefaultsParams,
50
+ NodeSetConfigParams,
47
51
  NodeSetEnrollEnabledParams,
48
52
  NodeSetEnrollPasswordParams,
49
53
  NodeStats,
@@ -53,6 +57,12 @@ import type {
53
57
  PermissionRequest,
54
58
  PermissionResolved,
55
59
  PermissionRespondParams,
60
+ PreviewCloseParams,
61
+ PreviewListResult,
62
+ PreviewOpenParams,
63
+ PreviewOpenResult,
64
+ PreviewPortsParams,
65
+ PreviewPortsResult,
56
66
  Project,
57
67
  RenamedProject,
58
68
  SessionArchiveParams,
@@ -109,6 +119,9 @@ export interface RequestMethods {
109
119
  'node.doctor': { params: Empty; result: NodeDoctorResult }
110
120
  'node.audit': { params: NodeAuditParams; result: NodeAuditResult }
111
121
  'node.set_agent_defaults': { params: NodeSetAgentDefaultsParams; result: Empty }
122
+ 'node.get_config': { params: Empty; result: NodeConfigResult }
123
+ 'node.set_config': { params: NodeSetConfigParams; result: NodeConfigResult }
124
+ 'node.logs': { params: NodeLogsParams; result: NodeLogsResult }
112
125
  'node.enroll_list': { params: Empty; result: NodeEnrollListResult }
113
126
  'node.enroll_approve': { params: NodeEnrollApproveParams; result: NodeEnrollApproveResult }
114
127
  'node.enroll_deny': { params: NodeEnrollIdParams; result: Empty }
@@ -161,6 +174,10 @@ export interface RequestMethods {
161
174
  'blob.discard': { params: BlobIdParams; result: Empty }
162
175
  'permission.respond': { params: PermissionRespondParams; result: Empty }
163
176
  'permission.list': { params: PermissionListParams; result: PermissionListResult }
177
+ 'preview.open': { params: PreviewOpenParams; result: PreviewOpenResult }
178
+ 'preview.close': { params: PreviewCloseParams; result: Empty }
179
+ 'preview.list': { params: Empty; result: PreviewListResult }
180
+ 'preview.ports': { params: PreviewPortsParams; result: PreviewPortsResult }
164
181
  }
165
182
 
166
183
  /** D→C 通知方法(没有响应)。 */
package/src/preview.ts ADDED
@@ -0,0 +1,625 @@
1
+ /**
2
+ * dev server 预览(ADR-39,docs/protocol.md § 11)的 TS 侧:启动参数的编解码、label 校验、预览 origin。
3
+ * 隧道消息 / 帧的编解码与 `crates/protocol/src/preview.rs` 的 `tunnel` 模块逐字节一致
4
+ * (`tests/vectors/preview.json`)。
5
+ */
6
+
7
+ import { decode, encode } from '@msgpack/msgpack'
8
+
9
+ import { ProtocolError } from './errors.js'
10
+ import type {
11
+ PreviewGoaway,
12
+ PreviewGoawayCode,
13
+ PreviewLaunch,
14
+ PreviewRejectReason,
15
+ PreviewRequestHead,
16
+ PreviewReset,
17
+ PreviewResetCode,
18
+ PreviewResponseHead,
19
+ } from './generated.js'
20
+
21
+ /** 预览 label:26 个字符的小写 base32(RFC 4648 字母表,无填充)。 */
22
+ export const PREVIEW_LABEL_LEN = 26
23
+ export const PREVIEW_LABEL_ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567'
24
+ /** `PreviewLaunch.v` 的当前版本。 */
25
+ export const PREVIEW_LAUNCH_VERSION = 1
26
+ /** 预览页的入口路径:hub 在这里回引导页,启动参数放在 fragment 里。 */
27
+ export const PREVIEW_ENTER_PATH = '/__anycon/enter'
28
+
29
+ const LABEL_RE = /^[a-z2-7]{26}$/
30
+
31
+ export function isValidPreviewLabel(label: string): boolean {
32
+ return LABEL_RE.test(label)
33
+ }
34
+
35
+ /**
36
+ * 按 hub 的模板(`/api/config.preview_origin_template`,如 `https://{label}.aiplace.app`)算出预览 origin。
37
+ * label 不合法时抛错:它会成为主机名的一段。
38
+ */
39
+ export function previewOrigin(template: string, label: string): string {
40
+ if (!isValidPreviewLabel(label)) throw new ProtocolError(`invalid preview label: ${label}`)
41
+ if (!template.includes('{label}'))
42
+ throw new ProtocolError('preview origin template has no {label}')
43
+ return template.replace('{label}', label)
44
+ }
45
+
46
+ function base64urlEncode(bytes: Uint8Array): string {
47
+ let bin = ''
48
+ for (const b of bytes) bin += String.fromCharCode(b)
49
+ return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
50
+ }
51
+
52
+ function base64urlDecode(text: string): Uint8Array {
53
+ if (!/^[A-Za-z0-9_-]*$/.test(text)) throw new ProtocolError('invalid base64url')
54
+ const std = text.replace(/-/g, '+').replace(/_/g, '/')
55
+ const padded = std + '='.repeat((4 - (std.length % 4)) % 4)
56
+ const bin = atob(padded)
57
+ const out = new Uint8Array(bin.length)
58
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
59
+ return out
60
+ }
61
+
62
+ /** `PreviewLaunch` → URL fragment(不含 `#`):JSON 的 UTF-8 字节做 base64url,无填充。 */
63
+ export function encodePreviewLaunch(launch: PreviewLaunch): string {
64
+ return base64urlEncode(new TextEncoder().encode(JSON.stringify(launch)))
65
+ }
66
+
67
+ /** `path` 里不许出现的字符:反斜杠和 ASCII 控制字符(U+0000–U+001F、U+007F)。 */
68
+ function hasForbiddenPathChar(path: string): boolean {
69
+ for (let i = 0; i < path.length; i++) {
70
+ const c = path.charCodeAt(i)
71
+ if (c === 0x5c || c <= 0x1f || c === 0x7f) return true
72
+ }
73
+ return false
74
+ }
75
+
76
+ const HEX32 = /^[0-9a-f]{32}$/
77
+ const HEX64 = /^[0-9a-f]{64}$/
78
+
79
+ /**
80
+ * URL fragment(可以带或不带开头的 `#`)→ `PreviewLaunch`。格式不对一律抛 `ProtocolError`:
81
+ * 版本、label、id 和密钥的格式、端口范围、`path` 以 `/` 开头且不是 `//`(协议相对 URL)、不含 `\` 和控制字符。
82
+ */
83
+ export function decodePreviewLaunch(fragment: string): PreviewLaunch {
84
+ const text = fragment.startsWith('#') ? fragment.slice(1) : fragment
85
+ let value: unknown
86
+ try {
87
+ value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(base64urlDecode(text)))
88
+ } catch (err) {
89
+ if (err instanceof ProtocolError) throw err
90
+ throw new ProtocolError('invalid preview launch')
91
+ }
92
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
93
+ throw new ProtocolError('invalid preview launch')
94
+ }
95
+ const v = value as Record<string, unknown>
96
+ const str = (k: string): string => {
97
+ const x = v[k]
98
+ if (typeof x !== 'string') throw new ProtocolError(`preview launch: ${k} must be a string`)
99
+ return x
100
+ }
101
+ const int = (k: string, min: number, max: number): number => {
102
+ const x = v[k]
103
+ if (typeof x !== 'number' || !Number.isInteger(x) || x < min || x > max) {
104
+ throw new ProtocolError(`preview launch: ${k} out of range`)
105
+ }
106
+ return x
107
+ }
108
+ const launch: PreviewLaunch = {
109
+ v: int('v', 0, Number.MAX_SAFE_INTEGER),
110
+ node: str('node'),
111
+ node_public: str('node_public'),
112
+ label: str('label'),
113
+ port: int('port', 1, 65535),
114
+ ticket_id: str('ticket_id'),
115
+ psk: str('psk'),
116
+ token: str('token'),
117
+ expires_at_ms: int('expires_at_ms', 0, Number.MAX_SAFE_INTEGER),
118
+ path: str('path'),
119
+ }
120
+ if (launch.v !== PREVIEW_LAUNCH_VERSION)
121
+ throw new ProtocolError(`unsupported preview launch version ${launch.v}`)
122
+ if (!isValidPreviewLabel(launch.label)) throw new ProtocolError('preview launch: invalid label')
123
+ if (!HEX32.test(launch.node) || !HEX32.test(launch.ticket_id))
124
+ throw new ProtocolError('preview launch: invalid id')
125
+ if (!HEX64.test(launch.node_public) || !HEX64.test(launch.psk))
126
+ throw new ProtocolError('preview launch: invalid key')
127
+ if (launch.token.length === 0) throw new ProtocolError('preview launch: empty token')
128
+ if (!launch.path.startsWith('/') || launch.path.startsWith('//')) {
129
+ throw new ProtocolError('preview launch: path must start with a single /')
130
+ }
131
+ // 浏览器把 `\` 当 `/`、并且会去掉 tab / 换行:`/\evil.example` 会变成协议相对的 URL
132
+ if (hasForbiddenPathChar(launch.path)) {
133
+ throw new ProtocolError(
134
+ 'preview launch: path must not contain a backslash or control characters',
135
+ )
136
+ }
137
+ return launch
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // 隧道(docs/protocol.md § 11):二进制格式和常量。与 `crates/protocol/src/preview.rs` 的 `tunnel` 模块
142
+ // 逐字节一致,`tests/vectors/preview.json`(Rust 生成)守着。
143
+ // ---------------------------------------------------------------------------
144
+
145
+ /** 票据 id 的字节数。 */
146
+ export const TUNNEL_TICKET_ID_LEN = 16
147
+ /** 票据密钥(Noise PSK)的字节数。 */
148
+ export const TUNNEL_PSK_LEN = 32
149
+
150
+ /** 握手第一条消息 body 的版本字节。 */
151
+ export const TUNNEL_HANDSHAKE_VERSION = 1
152
+ /** 握手回复 body 的第一个字节:接受(后面是 Noise 第二条消息)。 */
153
+ export const TUNNEL_HANDSHAKE_ACCEPT = 0x00
154
+ /** 握手回复 body 的第一个字节:拒绝(后面是 UTF-8 原因)。 */
155
+ export const TUNNEL_HANDSHAKE_REJECT = 0x01
156
+ /** Noise prologue 的前缀。完整 prologue = 前缀 ‖ 0x00 ‖ label ‖ 0x00 ‖ ticket_id(16 字节)。 */
157
+ export const TUNNEL_PROLOGUE_PREFIX = 'anycon-preview-v1'
158
+
159
+ /** 一条隧道消息的明文上限(加密后仍小于一个 `WHOLE` 数据帧,所以预览隧道从不分片)。 */
160
+ export const TUNNEL_MAX_MESSAGE = 128 * 1024
161
+ /** 单个 `DATA` / `WS_MESSAGE` 帧的数据上限。 */
162
+ export const TUNNEL_MAX_DATA_FRAME = 64 * 1024
163
+ /** `REQUEST` / `RESPONSE` 头部(msgpack 编码后)上限。 */
164
+ export const TUNNEL_MAX_HEAD = 64 * 1024
165
+ /** 一条 WebSocket 消息(拼起来之后)的上限。 */
166
+ export const TUNNEL_MAX_WS_MESSAGE = 16 * 1024 * 1024
167
+ /** 每个 stream 每个方向初始的流量窗口(`DATA` 和 `WS_MESSAGE` 的数据字节)。 */
168
+ export const TUNNEL_STREAM_WINDOW = 256 * 1024
169
+ /** 一条隧道上同时打开的 stream 上限。 */
170
+ export const TUNNEL_MAX_STREAMS = 256
171
+ /** 发送方未被回执的密文上限:daemon → 预览页。 */
172
+ export const TUNNEL_DAEMON_INFLIGHT = 8 * 1024 * 1024
173
+ /** 发送方未被回执的密文上限:预览页 → daemon。 */
174
+ export const TUNNEL_CLIENT_INFLIGHT = 2 * 1024 * 1024
175
+ /** 收到这么多字节(或 `TUNNEL_ACK_EVERY_MESSAGES` 条)还没回执过、手里又没有别的要发时,立即发一条纯回执消息。 */
176
+ export const TUNNEL_ACK_EVERY_BYTES = 256 * 1024
177
+ export const TUNNEL_ACK_EVERY_MESSAGES = 32
178
+ /** 收到消息后最多等这么久就发纯回执。 */
179
+ export const TUNNEL_ACK_DELAY_MS = 50
180
+ /** WebSocket 关闭原因的字节上限(RFC 6455)。 */
181
+ export const TUNNEL_WS_CLOSE_REASON_MAX = 123
182
+
183
+ /** `WS_MESSAGE` 标志位:二进制消息(否则是文本)。 */
184
+ export const TUNNEL_WS_BINARY = 0x01
185
+ /** `WS_MESSAGE` 标志位:这是这条 WebSocket 消息的最后一段。 */
186
+ export const TUNNEL_WS_FIN = 0x02
187
+
188
+ /** 帧头:类型 1 字节 + stream 4 字节 + 长度 4 字节。 */
189
+ export const TUNNEL_FRAME_HEADER = 1 + 4 + 4
190
+ const MESSAGE_HEADER = 8
191
+
192
+ /** 帧类型(§ 11.3)。 */
193
+ export const TunnelFrameType = {
194
+ /** P→D,msgpack `PreviewRequestHead`,打开一个 stream。 */
195
+ Request: 0x01,
196
+ /** D→P,msgpack `PreviewResponseHead`。 */
197
+ Response: 0x02,
198
+ /** 双向,HTTP 消息体的一段。占流量窗口。 */
199
+ Data: 0x03,
200
+ /** 双向,无载荷:这个方向的消息体结束。 */
201
+ End: 0x04,
202
+ /** 双向,msgpack `PreviewReset`:终止 stream。 */
203
+ Reset: 0x05,
204
+ /** 双向,4 字节大端:给对端这个 stream 增加的窗口。 */
205
+ Window: 0x06,
206
+ /** 双向,1 字节标志 + 数据:WebSocket 消息的一段。占流量窗口。 */
207
+ WsMessage: 0x07,
208
+ /** 双向,2 字节大端关闭码 + UTF-8 原因。 */
209
+ WsClose: 0x08,
210
+ /** D→P,stream 0,msgpack `PreviewGoaway`。 */
211
+ Goaway: 0x09,
212
+ } as const
213
+ export type TunnelFrameType = (typeof TunnelFrameType)[keyof typeof TunnelFrameType]
214
+
215
+ const FRAME_TYPES: ReadonlySet<number> = new Set(Object.values(TunnelFrameType))
216
+
217
+ export interface TunnelFrame {
218
+ type: TunnelFrameType
219
+ stream: number
220
+ payload: Uint8Array
221
+ }
222
+
223
+ /** 解密后的一条隧道消息。`ack` 是 u64,用 bigint 保证与 Rust 逐字节一致。 */
224
+ export interface TunnelMessage {
225
+ /** 发送方此刻已经收到对端多少条传输消息(这条传输建立以来,累计,不含握手)。 */
226
+ ack: bigint
227
+ frames: TunnelFrame[]
228
+ }
229
+
230
+ export type TunnelErrorCode = 'truncated' | 'too_large' | 'version' | 'malformed'
231
+
232
+ /** 隧道字节不合法。`code` 与 Rust 的 `TunnelError` 变体对应(snake_case)。 */
233
+ export class TunnelError extends ProtocolError {
234
+ override readonly name = 'TunnelError'
235
+ constructor(
236
+ readonly code: TunnelErrorCode,
237
+ message: string = code,
238
+ ) {
239
+ super(message)
240
+ }
241
+ }
242
+
243
+ export function newTunnelFrame(
244
+ type: TunnelFrameType,
245
+ stream: number,
246
+ payload: Uint8Array = new Uint8Array(0),
247
+ ): TunnelFrame {
248
+ return { type, stream, payload }
249
+ }
250
+
251
+ /** 一帧编码后的字节数。 */
252
+ export function tunnelFrameLen(frame: TunnelFrame): number {
253
+ return TUNNEL_FRAME_HEADER + frame.payload.length
254
+ }
255
+
256
+ /** 消息明文的字节数(编码前就能算,用来决定还能不能再塞一帧)。 */
257
+ export function messageLen(frames: readonly TunnelFrame[]): number {
258
+ let n = MESSAGE_HEADER
259
+ for (const f of frames) n += tunnelFrameLen(f)
260
+ return n
261
+ }
262
+
263
+ /** 编码一条隧道消息。超过 `TUNNEL_MAX_MESSAGE` 抛 `TunnelError('too_large')`(调用方负责把帧分到多条消息里)。 */
264
+ export function encodeMessage(ack: bigint | number, frames: readonly TunnelFrame[]): Uint8Array {
265
+ const len = messageLen(frames)
266
+ if (len > TUNNEL_MAX_MESSAGE) throw new TunnelError('too_large', 'tunnel message too large')
267
+ const big = typeof ack === 'bigint' ? ack : BigInt(ack)
268
+ if (big < 0n || big > 0xffff_ffff_ffff_ffffn)
269
+ throw new TunnelError('malformed', 'ack out of range')
270
+ const out = new Uint8Array(len)
271
+ const view = new DataView(out.buffer)
272
+ view.setBigUint64(0, big, false)
273
+ let at = MESSAGE_HEADER
274
+ for (const f of frames) {
275
+ if (!Number.isInteger(f.stream) || f.stream < 0 || f.stream > 0xffff_ffff) {
276
+ throw new TunnelError('malformed', 'stream id out of range')
277
+ }
278
+ out[at] = f.type
279
+ view.setUint32(at + 1, f.stream, false)
280
+ view.setUint32(at + 5, f.payload.length, false)
281
+ out.set(f.payload, at + TUNNEL_FRAME_HEADER)
282
+ at += TUNNEL_FRAME_HEADER + f.payload.length
283
+ }
284
+ return out
285
+ }
286
+
287
+ /** 解码一条隧道消息。未知类型的帧跳过(前向兼容);长度越界抛 `truncated`,超过上限抛 `too_large`。载荷是拷贝。 */
288
+ export function decodeMessage(buf: Uint8Array): TunnelMessage {
289
+ if (buf.length > TUNNEL_MAX_MESSAGE)
290
+ throw new TunnelError('too_large', 'tunnel message too large')
291
+ if (buf.length < MESSAGE_HEADER) throw new TunnelError('truncated', 'truncated tunnel message')
292
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
293
+ const ack = view.getBigUint64(0, false)
294
+ const frames: TunnelFrame[] = []
295
+ let at = MESSAGE_HEADER
296
+ while (at < buf.length) {
297
+ if (buf.length - at < TUNNEL_FRAME_HEADER)
298
+ throw new TunnelError('truncated', 'truncated frame header')
299
+ const type = buf[at]!
300
+ const stream = view.getUint32(at + 1, false)
301
+ const len = view.getUint32(at + 5, false)
302
+ if (buf.length - at - TUNNEL_FRAME_HEADER < len) {
303
+ throw new TunnelError('truncated', 'truncated frame payload')
304
+ }
305
+ const start = at + TUNNEL_FRAME_HEADER
306
+ if (FRAME_TYPES.has(type)) {
307
+ frames.push({
308
+ type: type as TunnelFrameType,
309
+ stream,
310
+ payload: buf.slice(start, start + len),
311
+ })
312
+ }
313
+ at = start + len
314
+ }
315
+ return { ack, frames }
316
+ }
317
+
318
+ // ---------- 头部:msgpack,字段名为键,键序与 Rust 结构体的声明顺序一致 ----------
319
+
320
+ const REQUEST_HEAD_KEYS = ['method', 'path', 'headers', 'websocket'] as const
321
+ const RESPONSE_HEAD_KEYS = ['status', 'headers', 'encoding'] as const
322
+
323
+ const RESET_CODES: ReadonlySet<string> = new Set<PreviewResetCode>([
324
+ 'refused',
325
+ 'timeout',
326
+ 'too_large',
327
+ 'cancelled',
328
+ 'flow_control',
329
+ 'protocol',
330
+ 'limit',
331
+ 'internal',
332
+ ])
333
+ const GOAWAY_CODES: ReadonlySet<string> = new Set<PreviewGoawayCode>([
334
+ 'expired',
335
+ 'closed',
336
+ 'revoked',
337
+ 'disabled',
338
+ 'port_not_allowed',
339
+ 'shutdown',
340
+ ])
341
+
342
+ function packHead(value: Record<string, unknown>): Uint8Array {
343
+ const bytes = encode(value, { ignoreUndefined: true })
344
+ if (bytes.length > TUNNEL_MAX_HEAD) throw new TunnelError('too_large', 'tunnel head too large')
345
+ return bytes
346
+ }
347
+
348
+ function unpackHead(payload: Uint8Array): Record<string, unknown> {
349
+ if (payload.length > TUNNEL_MAX_HEAD) throw new TunnelError('too_large', 'tunnel head too large')
350
+ let value: unknown
351
+ try {
352
+ value = decode(payload)
353
+ } catch {
354
+ throw new TunnelError('malformed', 'head decode')
355
+ }
356
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
357
+ throw new TunnelError('malformed', 'head decode')
358
+ }
359
+ return value as Record<string, unknown>
360
+ }
361
+
362
+ function headerPairs(value: unknown): [string, string][] {
363
+ if (!Array.isArray(value)) throw new TunnelError('malformed', 'head headers')
364
+ return value.map((pair): [string, string] => {
365
+ if (
366
+ !Array.isArray(pair) ||
367
+ pair.length !== 2 ||
368
+ typeof pair[0] !== 'string' ||
369
+ typeof pair[1] !== 'string'
370
+ ) {
371
+ throw new TunnelError('malformed', 'head headers')
372
+ }
373
+ return [pair[0], pair[1]]
374
+ })
375
+ }
376
+
377
+ function optionalMessage(value: unknown): string | undefined {
378
+ if (value === undefined || value === null) return undefined
379
+ if (typeof value !== 'string') throw new TunnelError('malformed', 'head message')
380
+ return value
381
+ }
382
+
383
+ /** `REQUEST` 载荷。键序 `method, path, headers, websocket`(`websocket` 总是写出)。 */
384
+ export function encodeRequestHead(head: PreviewRequestHead): Uint8Array {
385
+ const src: Record<string, unknown> = { ...head, websocket: head.websocket ?? false }
386
+ return packHead(Object.fromEntries(REQUEST_HEAD_KEYS.map((k) => [k, src[k]])))
387
+ }
388
+
389
+ export function decodeRequestHead(payload: Uint8Array): PreviewRequestHead {
390
+ const v = unpackHead(payload)
391
+ if (typeof v.method !== 'string' || typeof v.path !== 'string') {
392
+ throw new TunnelError('malformed', 'request head')
393
+ }
394
+ if (v.websocket !== undefined && typeof v.websocket !== 'boolean') {
395
+ throw new TunnelError('malformed', 'request head')
396
+ }
397
+ return {
398
+ method: v.method,
399
+ path: v.path,
400
+ headers: headerPairs(v.headers),
401
+ websocket: v.websocket === true,
402
+ }
403
+ }
404
+
405
+ /** `RESPONSE` 载荷。键序 `status, headers, encoding`(`encoding` 总是写出)。 */
406
+ export function encodeResponseHead(head: PreviewResponseHead): Uint8Array {
407
+ const src: Record<string, unknown> = { ...head, encoding: head.encoding ?? 'identity' }
408
+ return packHead(Object.fromEntries(RESPONSE_HEAD_KEYS.map((k) => [k, src[k]])))
409
+ }
410
+
411
+ export function decodeResponseHead(payload: Uint8Array): PreviewResponseHead {
412
+ const v = unpackHead(payload)
413
+ if (
414
+ typeof v.status !== 'number' ||
415
+ !Number.isInteger(v.status) ||
416
+ v.status < 0 ||
417
+ v.status > 0xffff
418
+ ) {
419
+ throw new TunnelError('malformed', 'response head')
420
+ }
421
+ const encoding = v.encoding ?? 'identity'
422
+ if (encoding !== 'identity' && encoding !== 'gzip')
423
+ throw new TunnelError('malformed', 'response head')
424
+ return { status: v.status, headers: headerPairs(v.headers), encoding }
425
+ }
426
+
427
+ /** `RESET` 载荷。键序 `code, message?`(`message` 为空时省略键)。 */
428
+ export function encodeReset(reset: PreviewReset): Uint8Array {
429
+ return packHead(
430
+ reset.message === undefined || reset.message === null
431
+ ? { code: reset.code }
432
+ : { code: reset.code, message: reset.message },
433
+ )
434
+ }
435
+
436
+ export function decodeReset(payload: Uint8Array): PreviewReset {
437
+ const v = unpackHead(payload)
438
+ if (typeof v.code !== 'string' || !RESET_CODES.has(v.code))
439
+ throw new TunnelError('malformed', 'reset')
440
+ const message = optionalMessage(v.message)
441
+ return message === undefined
442
+ ? { code: v.code as PreviewResetCode }
443
+ : { code: v.code as PreviewResetCode, message }
444
+ }
445
+
446
+ /** `GOAWAY` 载荷(只有 daemon 发)。键序 `code, message?`。 */
447
+ export function encodeGoaway(goaway: PreviewGoaway): Uint8Array {
448
+ return packHead(
449
+ goaway.message === undefined || goaway.message === null
450
+ ? { code: goaway.code }
451
+ : { code: goaway.code, message: goaway.message },
452
+ )
453
+ }
454
+
455
+ export function decodeGoaway(payload: Uint8Array): PreviewGoaway {
456
+ const v = unpackHead(payload)
457
+ if (typeof v.code !== 'string' || !GOAWAY_CODES.has(v.code))
458
+ throw new TunnelError('malformed', 'goaway')
459
+ const message = optionalMessage(v.message)
460
+ return message === undefined
461
+ ? { code: v.code as PreviewGoawayCode }
462
+ : { code: v.code as PreviewGoawayCode, message }
463
+ }
464
+
465
+ // ---------- 小载荷 ----------
466
+
467
+ /** `WINDOW` 载荷:u32 大端,给对端这个 stream 增加的窗口字节。 */
468
+ export function windowPayload(increment: number): Uint8Array {
469
+ if (!Number.isInteger(increment) || increment < 0 || increment > 0xffff_ffff) {
470
+ throw new TunnelError('malformed', 'window increment out of range')
471
+ }
472
+ const out = new Uint8Array(4)
473
+ new DataView(out.buffer).setUint32(0, increment, false)
474
+ return out
475
+ }
476
+
477
+ export function parseWindow(payload: Uint8Array): number {
478
+ if (payload.length !== 4) throw new TunnelError('malformed', 'window')
479
+ return new DataView(payload.buffer, payload.byteOffset, 4).getUint32(0, false)
480
+ }
481
+
482
+ /** `WS_MESSAGE` 载荷:标志(`TUNNEL_WS_BINARY` / `TUNNEL_WS_FIN`)‖ 数据。 */
483
+ export function wsMessage(flags: number, data: Uint8Array): Uint8Array {
484
+ const out = new Uint8Array(1 + data.length)
485
+ out[0] = flags
486
+ out.set(data, 1)
487
+ return out
488
+ }
489
+
490
+ /** 返回 `{ flags, data }`;`data` 是 `payload` 的视图。 */
491
+ export function parseWsMessage(payload: Uint8Array): { flags: number; data: Uint8Array } {
492
+ if (payload.length === 0) throw new TunnelError('malformed', 'ws message')
493
+ return { flags: payload[0]!, data: payload.subarray(1) }
494
+ }
495
+
496
+ /** `WS_CLOSE` 载荷:关闭码(u16 大端)‖ 原因。原因超过 `TUNNEL_WS_CLOSE_REASON_MAX` 字节时在 UTF-8 字符边界处截断。 */
497
+ export function wsClose(code: number, reason: string): Uint8Array {
498
+ if (!Number.isInteger(code) || code < 0 || code > 0xffff) {
499
+ throw new TunnelError('malformed', 'ws close code out of range')
500
+ }
501
+ const bytes = new TextEncoder().encode(reason)
502
+ let end = Math.min(bytes.length, TUNNEL_WS_CLOSE_REASON_MAX)
503
+ // 下标 `end` 处是 UTF-8 续字节(10xxxxxx)说明切在了字符中间,往回退到字符起点
504
+ while (end > 0 && end < bytes.length && (bytes[end]! & 0xc0) === 0x80) end--
505
+ const out = new Uint8Array(2 + end)
506
+ new DataView(out.buffer).setUint16(0, code, false)
507
+ out.set(bytes.subarray(0, end), 2)
508
+ return out
509
+ }
510
+
511
+ export function parseWsClose(payload: Uint8Array): { code: number; reason: string } {
512
+ if (payload.length < 2) throw new TunnelError('malformed', 'ws close')
513
+ const code = new DataView(payload.buffer, payload.byteOffset, 2).getUint16(0, false)
514
+ let reason: string
515
+ try {
516
+ reason = new TextDecoder('utf-8', { fatal: true }).decode(payload.subarray(2))
517
+ } catch {
518
+ throw new TunnelError('malformed', 'ws close reason')
519
+ }
520
+ return { code, reason }
521
+ }
522
+
523
+ // ---------- 握手 ----------
524
+
525
+ /** 握手第一条(P → D)的信封 body:版本 ‖ ticket_id ‖ Noise 消息。 */
526
+ export function handshakeInit(ticketId: Uint8Array, noise: Uint8Array): Uint8Array {
527
+ if (ticketId.length !== TUNNEL_TICKET_ID_LEN)
528
+ throw new TunnelError('malformed', 'ticket id length')
529
+ const out = new Uint8Array(1 + TUNNEL_TICKET_ID_LEN + noise.length)
530
+ out[0] = TUNNEL_HANDSHAKE_VERSION
531
+ out.set(ticketId, 1)
532
+ out.set(noise, 1 + TUNNEL_TICKET_ID_LEN)
533
+ return out
534
+ }
535
+
536
+ export function parseHandshakeInit(body: Uint8Array): { ticketId: Uint8Array; noise: Uint8Array } {
537
+ if (body.length === 0) throw new TunnelError('truncated', 'empty handshake')
538
+ if (body[0] !== TUNNEL_HANDSHAKE_VERSION) {
539
+ throw new TunnelError('version', `unsupported handshake version ${body[0]}`)
540
+ }
541
+ if (body.length < 1 + TUNNEL_TICKET_ID_LEN)
542
+ throw new TunnelError('truncated', 'truncated handshake')
543
+ return {
544
+ ticketId: body.slice(1, 1 + TUNNEL_TICKET_ID_LEN),
545
+ noise: body.slice(1 + TUNNEL_TICKET_ID_LEN),
546
+ }
547
+ }
548
+
549
+ /** 握手回复(D → P)接受:`0x00` ‖ Noise 第二条。 */
550
+ export function handshakeAccept(noise: Uint8Array): Uint8Array {
551
+ const out = new Uint8Array(1 + noise.length)
552
+ out[0] = TUNNEL_HANDSHAKE_ACCEPT
553
+ out.set(noise, 1)
554
+ return out
555
+ }
556
+
557
+ /** 握手回复拒绝:`0x01` ‖ 原因(UTF-8)。 */
558
+ export function handshakeReject(reason: PreviewRejectReason | string): Uint8Array {
559
+ const text = new TextEncoder().encode(reason)
560
+ const out = new Uint8Array(1 + text.length)
561
+ out[0] = TUNNEL_HANDSHAKE_REJECT
562
+ out.set(text, 1)
563
+ return out
564
+ }
565
+
566
+ export type TunnelHandshakeReply =
567
+ | { accept: true; noise: Uint8Array }
568
+ /** 原因保持为字符串:新版本 daemon 可能给出这一版不认识的原因。 */
569
+ | { accept: false; reason: string }
570
+
571
+ export function parseHandshakeReply(body: Uint8Array): TunnelHandshakeReply {
572
+ if (body.length === 0) throw new TunnelError('truncated', 'empty handshake reply')
573
+ if (body[0] === TUNNEL_HANDSHAKE_ACCEPT) return { accept: true, noise: body.slice(1) }
574
+ if (body[0] === TUNNEL_HANDSHAKE_REJECT) {
575
+ try {
576
+ return {
577
+ accept: false,
578
+ reason: new TextDecoder('utf-8', { fatal: true }).decode(body.subarray(1)),
579
+ }
580
+ } catch {
581
+ throw new TunnelError('malformed', 'reject reason')
582
+ }
583
+ }
584
+ throw new TunnelError('malformed', 'handshake reply')
585
+ }
586
+
587
+ /**
588
+ * Noise prologue:`"anycon-preview-v1" ‖ 0x00 ‖ label ‖ 0x00 ‖ ticket_id`。两端各自算:预览页取自己的主机名,
589
+ * daemon 取 `preview_peer.label`,hub 说谎只会让握手失败。
590
+ */
591
+ export function previewPrologue(label: string, ticketId: Uint8Array): Uint8Array {
592
+ if (ticketId.length !== TUNNEL_TICKET_ID_LEN)
593
+ throw new TunnelError('malformed', 'ticket id length')
594
+ const enc = new TextEncoder()
595
+ const prefix = enc.encode(TUNNEL_PROLOGUE_PREFIX)
596
+ const text = enc.encode(label)
597
+ const out = new Uint8Array(prefix.length + 1 + text.length + 1 + TUNNEL_TICKET_ID_LEN)
598
+ out.set(prefix, 0)
599
+ let at = prefix.length
600
+ out[at++] = 0
601
+ out.set(text, at)
602
+ at += text.length
603
+ out[at++] = 0
604
+ out.set(ticketId, at)
605
+ return out
606
+ }
607
+
608
+ /** 把 16 字节随机数编码成 label(base32 小写,无填充:128 bit → 26 个字符)。 */
609
+ export function labelFromBytes(bytes: Uint8Array): string {
610
+ if (bytes.length !== 16) throw new TunnelError('malformed', 'label needs 16 bytes')
611
+ let out = ''
612
+ let acc = 0
613
+ let bits = 0
614
+ for (const b of bytes) {
615
+ acc = ((acc << 8) | b) >>> 0
616
+ bits += 8
617
+ while (bits >= 5) {
618
+ bits -= 5
619
+ out += PREVIEW_LABEL_ALPHABET[(acc >>> bits) & 0x1f]
620
+ }
621
+ acc &= (1 << bits) - 1
622
+ }
623
+ if (bits > 0) out += PREVIEW_LABEL_ALPHABET[(acc << (5 - bits)) & 0x1f]
624
+ return out
625
+ }