@vincentt-xr/cli 0.18.3 → 0.19.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/MIGRATION.md +16 -2
- package/dist/cli/index.js +11 -5
- package/dist/cli/usage-text.js +1 -1
- package/dist/lib/terminalText.d.ts +2 -0
- package/dist/lib/terminalText.js +7 -0
- package/dist/login/pkceLogin.d.ts +7 -1
- package/dist/login/pkceLogin.js +25 -4
- package/dist/preview/api.d.ts +15 -0
- package/dist/preview/api.js +27 -1
- package/dist/preview/command.d.ts +1 -1
- package/dist/preview/copy.d.ts +14 -1
- package/dist/preview/copy.js +37 -3
- package/dist/preview/devPort.d.ts +17 -0
- package/dist/preview/devPort.js +14 -0
- package/dist/preview/proxy.d.ts +16 -9
- package/dist/preview/proxy.js +102 -20
- package/dist/preview/runner.d.ts +10 -2
- package/dist/preview/runner.js +21 -6
- package/dist/preview/stream.d.ts +1 -1
- package/dist/preview/viewers.d.ts +1 -1
- package/dist/relay/server.js +1 -1
- package/dist/tunnel/cbor.d.ts +71 -0
- package/dist/tunnel/cbor.js +403 -0
- package/dist/tunnel/client.d.ts +225 -0
- package/dist/tunnel/client.js +764 -0
- package/dist/tunnel/forwardTarget.d.ts +69 -0
- package/dist/tunnel/forwardTarget.js +179 -0
- package/dist/tunnel/frame.d.ts +126 -0
- package/dist/tunnel/frame.js +244 -0
- package/dist/tunnel/index.d.ts +9 -0
- package/dist/tunnel/index.js +9 -0
- package/package.json +2 -2
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tunnel client: the creator's side of the wire.
|
|
3
|
+
*
|
|
4
|
+
* It opens ONE outbound WSS connection to the edge and multiplexes every
|
|
5
|
+
* viewer's request onto it. Nothing dials INTO the creator's machine; the
|
|
6
|
+
* client never opens a stream, and the edge rejects an even streamId from it as
|
|
7
|
+
* a protocol violation.
|
|
8
|
+
*
|
|
9
|
+
* What this file does NOT do, deliberately:
|
|
10
|
+
* - it does not parse a DATA payload (they are opaque here too);
|
|
11
|
+
* - it does not follow a redirect on the creator's behalf;
|
|
12
|
+
* - it does not trust the edge's viewerLabel or ua-class without
|
|
13
|
+
* re-validating both against the same regex and the same closed enum.
|
|
14
|
+
*/
|
|
15
|
+
import { WebSocket } from "ws";
|
|
16
|
+
import { encodeCredit } from "./frame.js";
|
|
17
|
+
import { type ControlKind, type UAClass } from "./cbor.js";
|
|
18
|
+
import { type ForwardTarget } from "./forwardTarget.js";
|
|
19
|
+
/**
|
|
20
|
+
* VIEWER_STAMP_HEADER carries the VALIDATED label to the creator's own local
|
|
21
|
+
* server, so the relay can key its ring buffer by viewer.
|
|
22
|
+
*
|
|
23
|
+
* The stamp happens HERE, on the creator's side of the wire, which is the whole
|
|
24
|
+
* point of 2.8: the device never chooses its own label, and the value written
|
|
25
|
+
* has already passed parseViewerLabel. It is added AFTER sanitizeHeaders, so a
|
|
26
|
+
* viewer-supplied header of the same name cannot survive to shadow it.
|
|
27
|
+
*/
|
|
28
|
+
export declare const VIEWER_STAMP_HEADER = "x-vincentt-viewer";
|
|
29
|
+
export declare const UA_CLASS_STAMP_HEADER = "x-vincentt-ua-class";
|
|
30
|
+
/**
|
|
31
|
+
* RESUME_HEADER is ONE spelling for both halves of the wire: the edge writes it
|
|
32
|
+
* on the 101 (`ResumeHeader` in `internal/edge/websocket.go`), and this client
|
|
33
|
+
* sends it back on the next dial.
|
|
34
|
+
*
|
|
35
|
+
* It is lower-case here because Node lower-cases every response header name,
|
|
36
|
+
* and `res.headers["X-Vincentt-Resume"]` is silently `undefined`. That mistake
|
|
37
|
+
* would reproduce the exact defect this constant exists to fix — a client that
|
|
38
|
+
* never learns its next credential — with a fully green suite, so the casing is
|
|
39
|
+
* asserted by a test rather than trusted.
|
|
40
|
+
*/
|
|
41
|
+
export declare const RESUME_HEADER = "x-vincentt-resume";
|
|
42
|
+
/**
|
|
43
|
+
* SESSION_HEADER carries the sessionId hex on the RECONNECT dial only.
|
|
44
|
+
*
|
|
45
|
+
* It exists because the reconnect path carries NO GRANT
|
|
46
|
+
* (`D-The-reconnect-path-carries-no-grant`), and the grant is what used to carry
|
|
47
|
+
* the session id in its signed `sid` claim. With the grant gone the edge has no
|
|
48
|
+
* other way to name the row, so the id travels as its own header.
|
|
49
|
+
*
|
|
50
|
+
* It is NOT a secret — it is an ObjectID the edge immediately parses with
|
|
51
|
+
* `primitive.ObjectIDFromHex` before it touches anything (`ingress.go:288`) —
|
|
52
|
+
* but exactly one must be sent: the edge refuses duplicates on all three
|
|
53
|
+
* credential-bearing headers with `Header.Values()` rather than `Get`, because
|
|
54
|
+
* `Get` silently returns the first value and that is the bypass being closed
|
|
55
|
+
* (`ingress.go:120-126`).
|
|
56
|
+
*
|
|
57
|
+
* Lower-case for the same reason as RESUME_HEADER: Node normalizes header names.
|
|
58
|
+
*/
|
|
59
|
+
export declare const SESSION_HEADER = "x-vincentt-session";
|
|
60
|
+
/** The Authorization header, named once so the never-send-both rule cites it. */
|
|
61
|
+
export declare const AUTHORIZATION_HEADER = "authorization";
|
|
62
|
+
/**
|
|
63
|
+
* RESUME_TOKEN_LEN is base64url(32 random bytes), unpadded. It mirrors
|
|
64
|
+
* `resumeTokenLen` in the Go edge so the two ends of this wire state the same
|
|
65
|
+
* fact rather than each carrying a magic number.
|
|
66
|
+
*/
|
|
67
|
+
export declare const RESUME_TOKEN_LEN = 43;
|
|
68
|
+
/**
|
|
69
|
+
* parseResumeToken accepts only the grammar the API's issuer produces.
|
|
70
|
+
*
|
|
71
|
+
* ABSENCE IS LEGITIMATE, not an error: the edge OMITS the header rather than
|
|
72
|
+
* emitting it empty when it has no token to hand over, and the handshake still
|
|
73
|
+
* succeeds. The cost is one reconnect, which is recoverable — treating it as
|
|
74
|
+
* fatal would turn a token problem into a dead preview.
|
|
75
|
+
*
|
|
76
|
+
* A malformed value is discarded for the same reason it is validated at the
|
|
77
|
+
* edge before being written: this value is concatenated into an outbound header
|
|
78
|
+
* on the next dial, and a CR or LF in it would not be a bad credential, it
|
|
79
|
+
* would be a second request.
|
|
80
|
+
*/
|
|
81
|
+
export declare function parseResumeToken(raw: unknown): string | null;
|
|
82
|
+
export interface TunnelClientOptions {
|
|
83
|
+
/** The edge's tunnel ingress: wss://edge-<env>.vincentt.studio/tunnel. */
|
|
84
|
+
readonly edgeUrl: string;
|
|
85
|
+
/**
|
|
86
|
+
* The opaque JWS the API minted. THE CLIENT NEVER PARSES IT.
|
|
87
|
+
*
|
|
88
|
+
* READONLY AND SINGLE-ASSIGNMENT, and that is a design property rather than
|
|
89
|
+
* style (`D-Client-holds-one-credential-per-state`). It is a FIRST-ATTACH
|
|
90
|
+
* credential and nothing else: 10-minute TTL, spent by the attach CAS, and
|
|
91
|
+
* never presented again. Two withdrawn designs needed a mutable grant field so
|
|
92
|
+
* the client could hold a second, longer-lived grant for the reconnect path;
|
|
93
|
+
* both were falsified, and the field staying readonly is what makes the
|
|
94
|
+
* "which credential for which door" question unaskable here.
|
|
95
|
+
*/
|
|
96
|
+
readonly grant: string;
|
|
97
|
+
/**
|
|
98
|
+
* The session id hex from the MINT response, sent as X-Vincentt-Session on
|
|
99
|
+
* every redial. Not a credential — the reconnect path's two conditions are the
|
|
100
|
+
* resume token's single-use consume and the tunnel-slot CAS, both enforced by
|
|
101
|
+
* state at the API.
|
|
102
|
+
*/
|
|
103
|
+
readonly sessionId: string;
|
|
104
|
+
readonly target: ForwardTarget;
|
|
105
|
+
/** onViewer is the CLI's connect line. It receives only VALIDATED values. */
|
|
106
|
+
readonly onViewer?: (label: string, uaClass: UAClass) => void;
|
|
107
|
+
/**
|
|
108
|
+
* onViewerGone is the CLI's disconnect line — the other half of onViewer.
|
|
109
|
+
*
|
|
110
|
+
* onViewer fires per OPEN frame (so the roster dedupes by label); this fires
|
|
111
|
+
* ONCE, when the viewer's connection closes. Without it the roster only ever
|
|
112
|
+
* grows: for a long time the edge logged the detach server-side and told the
|
|
113
|
+
* client nothing, so a phone refreshing read as an arriving crowd.
|
|
114
|
+
*/
|
|
115
|
+
readonly onViewerGone?: (label: string) => void;
|
|
116
|
+
/** onControl carries revoke/expiring/superseded so the CLI prints the right ending. */
|
|
117
|
+
readonly onControl?: (kind: ControlKind) => void;
|
|
118
|
+
/** onOriginError is the creator-facing detail the 502 page must never carry. */
|
|
119
|
+
readonly onOriginError?: (label: string, code: string) => void;
|
|
120
|
+
readonly onStateChange?: (state: TunnelState) => void;
|
|
121
|
+
/**
|
|
122
|
+
* onTunnelError carries the error CODE of a failed dial (`ECONNREFUSED`,
|
|
123
|
+
* `CERT_HAS_EXPIRED`, …). Never the message: an error string from a TLS or
|
|
124
|
+
* HTTP layer can carry the URL, and the URL is the capability.
|
|
125
|
+
*/
|
|
126
|
+
readonly onTunnelError?: (code: string) => void;
|
|
127
|
+
/**
|
|
128
|
+
* onReconnectRefused fires when the EDGE ANSWERED and refused a reconnect —
|
|
129
|
+
* a real HTTP status on the upgrade, not a transport failure.
|
|
130
|
+
*
|
|
131
|
+
* It exists because a refused reconnect stopped being routine noise: the edge
|
|
132
|
+
* emits `preview.resume_token_rejected` on it, which QA and security made an
|
|
133
|
+
* alertable theft signal, and the honest client is not supposed to trigger it.
|
|
134
|
+
* Without this callback the client retried silently forever and the creator
|
|
135
|
+
* watched "Reconnecting…" for a session that was already unrecoverable.
|
|
136
|
+
*
|
|
137
|
+
* The three statuses are deliberately distinguishable rather than collapsed
|
|
138
|
+
* (`ingress.go:299-310`): 401 the token was refused, 409 another tunnel holds
|
|
139
|
+
* this preview, 410 the session is gone. They map to different creator actions.
|
|
140
|
+
*/
|
|
141
|
+
readonly onReconnectRefused?: (status: number) => void;
|
|
142
|
+
/** Injectable for tests; never a real socket in a unit test. */
|
|
143
|
+
readonly connect?: (url: string, headers: Record<string, string>) => WebSocket;
|
|
144
|
+
}
|
|
145
|
+
export type TunnelState = "connecting" | "live" | "reconnecting" | "closed";
|
|
146
|
+
export declare class TunnelClient {
|
|
147
|
+
private readonly opts;
|
|
148
|
+
private ws;
|
|
149
|
+
private decoder;
|
|
150
|
+
private readonly streams;
|
|
151
|
+
/**
|
|
152
|
+
* resumeToken is 32 random bytes, single-use, rotated on every attach, bound
|
|
153
|
+
* to the session, 5-minute TTL. It is held in memory only and NEVER logged:
|
|
154
|
+
* "single-use" is enforced by the API's atomic consume, and a token in a log
|
|
155
|
+
* is a credential in a log.
|
|
156
|
+
*/
|
|
157
|
+
private resumeToken;
|
|
158
|
+
private attempt;
|
|
159
|
+
private closed;
|
|
160
|
+
private state;
|
|
161
|
+
private pingTimer;
|
|
162
|
+
private lastPong;
|
|
163
|
+
constructor(opts: TunnelClientOptions);
|
|
164
|
+
start(): void;
|
|
165
|
+
close(): void;
|
|
166
|
+
private setState;
|
|
167
|
+
/**
|
|
168
|
+
* THE DIAL RULE. One boolean, two disjoint states, one credential each.
|
|
169
|
+
*
|
|
170
|
+
* first dial (resumeToken === null): Authorization: Bearer <mint grant>
|
|
171
|
+
* every redial (resumeToken !== null): X-Vincentt-Resume + X-Vincentt-Session
|
|
172
|
+
*
|
|
173
|
+
* This function is the WIRING of `D-Client-holds-one-credential-per-state`:
|
|
174
|
+
* every dial's header set is built here and nowhere else, so the never-send-both
|
|
175
|
+
* rule is a property of one `if/else` rather than of a convention.
|
|
176
|
+
*
|
|
177
|
+
* NEVER BOTH, and this is the rule most likely to be "helpfully" broken later.
|
|
178
|
+
* The edge REFUSES a request carrying a grant alongside a valid resume token
|
|
179
|
+
* (`ingress.go:144-148`) — it does not ignore the grant — so adding
|
|
180
|
+
* `Authorization` here "for robustness" would break every reconnect while
|
|
181
|
+
* passing every functional test in the suite except the negative that exists
|
|
182
|
+
* for exactly this. The reason it is a refusal rather than a preference: a
|
|
183
|
+
* header that is sometimes meaningful and sometimes ignored is a branch waiting
|
|
184
|
+
* to be written.
|
|
185
|
+
*
|
|
186
|
+
* There is no per-door judgement to get wrong because the states are disjoint:
|
|
187
|
+
* the grant is spent by the attach CAS the moment the first dial succeeds, and
|
|
188
|
+
* a resume token only ever exists after a 101 has delivered one.
|
|
189
|
+
*/
|
|
190
|
+
private dialHeaders;
|
|
191
|
+
private open;
|
|
192
|
+
private onClosed;
|
|
193
|
+
private startPing;
|
|
194
|
+
private stopPing;
|
|
195
|
+
private send;
|
|
196
|
+
private onBytes;
|
|
197
|
+
private dispatch;
|
|
198
|
+
/**
|
|
199
|
+
* End a stream on purpose: return any credit owed, RESET it, and tear the
|
|
200
|
+
* local request down WITHOUT the origin-error advisory, which is reserved for
|
|
201
|
+
* the creator's dev server actually failing.
|
|
202
|
+
*/
|
|
203
|
+
private quietReset;
|
|
204
|
+
/**
|
|
205
|
+
* serveOpen forwards ONE viewer request to the single configured target.
|
|
206
|
+
*
|
|
207
|
+
* Every 2.15.4 rule executes here, in order: the target is the configured one
|
|
208
|
+
* (never from the request), the request target is normalized-then-checked,
|
|
209
|
+
* the viewer's routing headers are dropped, loopback is RE-CHECKED at connect,
|
|
210
|
+
* and no redirect is followed.
|
|
211
|
+
*/
|
|
212
|
+
private serveOpen;
|
|
213
|
+
/**
|
|
214
|
+
* serveUpgrade splices ONE viewer's WebSocket to the creator's local server.
|
|
215
|
+
*
|
|
216
|
+
* It re-issues the handshake over a raw socket rather than using an HTTP
|
|
217
|
+
* client, because Node's `http` module owns the connection after a 101 and
|
|
218
|
+
* will not hand back a usable duplex without the same hijack this does
|
|
219
|
+
* explicitly. The response's status line and headers are parsed only far
|
|
220
|
+
* enough to build the HEAD frame; every byte after the blank line is OPAQUE
|
|
221
|
+
* and is forwarded without being read.
|
|
222
|
+
*/
|
|
223
|
+
private serveUpgrade;
|
|
224
|
+
}
|
|
225
|
+
export { encodeCredit };
|