@vincentt-xr/cli 0.16.0 → 0.18.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 +24 -0
- package/README.md +94 -1
- package/dist/cli/index.js +196 -87
- package/dist/cli/usage-text.js +32 -10
- package/dist/domain/api.d.ts +10 -8
- package/dist/domain/api.js +14 -9
- package/dist/domain/command.js +25 -16
- package/dist/domain/copy.d.ts +6 -3
- package/dist/domain/copy.js +10 -6
- package/dist/dx/command.d.ts +24 -4
- package/dist/dx/command.js +80 -25
- package/dist/dx/consent.js +6 -5
- package/dist/dx/journal.d.ts +17 -2
- package/dist/dx/journal.js +19 -3
- package/dist/dx/report.js +4 -3
- package/dist/dx/reportIo.js +6 -5
- package/dist/dx/send.js +6 -5
- package/dist/lib/backend.d.ts +0 -16
- package/dist/lib/backend.js +2 -26
- package/dist/lib/detect.d.ts +87 -0
- package/dist/lib/detect.js +867 -0
- package/dist/lib/diagnostics.js +2 -2
- package/dist/lib/feedback.js +1 -1
- package/dist/lib/feedbackCopy.js +1 -1
- package/dist/lib/initFrom.js +4 -4
- package/dist/lib/lifecycle.d.ts +2 -0
- package/dist/lib/lifecycle.js +9 -3
- package/dist/lib/listProjects.js +2 -1
- package/dist/lib/projects.d.ts +37 -2
- package/dist/lib/projects.js +86 -21
- package/dist/lib/settings.d.ts +113 -0
- package/dist/lib/settings.js +284 -0
- package/dist/lib/settingsCopy.d.ts +6 -0
- package/dist/lib/settingsCopy.js +99 -0
- package/dist/lib/settle.d.ts +61 -0
- package/dist/lib/settle.js +338 -0
- package/dist/lib/template.d.ts +19 -0
- package/dist/lib/template.js +73 -0
- package/dist/lib/terminalText.d.ts +3 -0
- package/dist/lib/terminalText.js +10 -0
- package/dist/lib/version.js +5 -11
- package/dist/lib/whoami.js +10 -3
- package/dist/library/assetsCommand.js +1 -1
- package/dist/library/client.js +8 -3
- package/dist/library/render.js +8 -6
- package/dist/login/authBackend.js +3 -2
- package/dist/login/ensure.d.ts +8 -2
- package/dist/login/ensure.js +21 -10
- package/dist/login/guard.js +5 -4
- package/dist/login/loginCommand.d.ts +2 -2
- package/dist/login/loginCommand.js +29 -5
- package/dist/login/pkce.js +1 -1
- package/dist/login/pkceLogin.js +1 -1
- package/dist/platform/outdated.js +2 -2
- package/dist/platform/upgradeReport.js +16 -12
- package/dist/preview/api.d.ts +24 -11
- package/dist/preview/api.js +42 -15
- package/dist/preview/command.d.ts +29 -0
- package/dist/preview/command.js +61 -1
- package/dist/preview/copy.d.ts +35 -3
- package/dist/preview/copy.js +102 -10
- package/dist/preview/proxy.d.ts +38 -1
- package/dist/preview/proxy.js +151 -2
- package/dist/preview/qrPage.js +1 -1
- package/dist/preview/runner.d.ts +25 -13
- package/dist/preview/runner.js +127 -26
- package/dist/preview/viewers.js +1 -1
- package/dist/publish/api.d.ts +39 -6
- package/dist/publish/api.js +80 -9
- package/dist/publish/command.d.ts +25 -0
- package/dist/publish/command.js +230 -32
- package/dist/publish/copy.d.ts +78 -5
- package/dist/publish/copy.js +271 -37
- package/dist/publish/fit.d.ts +15 -0
- package/dist/publish/fit.js +103 -0
- package/dist/publish/manifest.d.ts +44 -3
- package/dist/publish/manifest.js +172 -3
- package/dist/publish/prompt.d.ts +4 -0
- package/dist/publish/prompt.js +15 -1
- package/dist/publish/upload.d.ts +6 -0
- package/dist/publish/upload.js +11 -1
- package/dist/publish/visibility.d.ts +21 -0
- package/dist/publish/visibility.js +68 -0
- package/dist/relay/annotations.js +9 -4
- package/dist/relay/server.d.ts +21 -0
- package/dist/relay/server.js +35 -1
- package/dist/shared/authError.d.ts +27 -0
- package/dist/shared/authError.js +48 -4
- package/dist/shared/config.d.ts +21 -1
- package/dist/shared/config.js +39 -8
- package/dist/shared/countLimit.d.ts +114 -0
- package/dist/shared/countLimit.js +176 -0
- package/dist/shared/http.d.ts +23 -0
- package/dist/shared/http.js +58 -0
- package/package.json +7 -3
package/dist/preview/copy.d.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
/** Flow L13 — the start block. */
|
|
2
1
|
export interface StartBlockInput {
|
|
3
2
|
projectName: string;
|
|
4
3
|
orgName: string;
|
|
@@ -28,7 +27,32 @@ export declare function startBlock(i: StartBlockInput): string[];
|
|
|
28
27
|
* without an account, which is false. It is offered as a CAPABILITY, not as
|
|
29
28
|
* consolation — no "unfortunately", no "you can still".
|
|
30
29
|
*/
|
|
31
|
-
export declare function notSignedIn(): string[];
|
|
30
|
+
export declare function notSignedIn(devCommand?: string, localUrl?: string): string[];
|
|
31
|
+
/**
|
|
32
|
+
* Flow P1 — the two settings lines, printed right after resolution and before
|
|
33
|
+
* the sign-in check, the mint and the spawn. They are the ONLY disclosure of the
|
|
34
|
+
* dev command before it runs; no second line repeats it.
|
|
35
|
+
*/
|
|
36
|
+
export declare function settingsBanner(i: {
|
|
37
|
+
devCommand: string;
|
|
38
|
+
devCommandSource: string;
|
|
39
|
+
devPort: number;
|
|
40
|
+
devPortSource: string;
|
|
41
|
+
/** Something already listens on the port, so the command will not be started. */
|
|
42
|
+
reused: boolean;
|
|
43
|
+
}): string[];
|
|
44
|
+
/** Flow P3, static: `package.json` does not list the harness. Precedes the start block. */
|
|
45
|
+
export declare function noDiagnosticsStatic(): string[];
|
|
46
|
+
/** Flow P3, arrival: once per device class, only when the static line was shown. */
|
|
47
|
+
export declare function deviceArrived(descriptor: string): string;
|
|
48
|
+
/**
|
|
49
|
+
* Flow P3, runtime: the harness is listed but nothing said hello. Names the
|
|
50
|
+
* device class (a tester exists only after a hello) and asserts neither cause.
|
|
51
|
+
* The window's length is deliberately not printed.
|
|
52
|
+
*/
|
|
53
|
+
export declare function noDiagnosticsRuntime(descriptor: string): string[];
|
|
54
|
+
/** Flow P1 — the proxy refused a hidden path. Once per preview, naming the first. */
|
|
55
|
+
export declare function hiddenPathRefused(requestPath: string): string;
|
|
32
56
|
/** Flow L16 — `--viewer`, a second terminal, not a second preview. */
|
|
33
57
|
export declare function followingBlock(label: string, descriptor: string): string[];
|
|
34
58
|
export declare function followingFooter(): string[];
|
|
@@ -43,7 +67,10 @@ export declare function followingFooter(): string[];
|
|
|
43
67
|
* The middle line splits the wire in two, because the creator's first instinct
|
|
44
68
|
* on a 502 is to suspect us.
|
|
45
69
|
*/
|
|
46
|
-
export declare function originDown(targetUrl: string
|
|
70
|
+
export declare function originDown(targetUrl: string, settings?: {
|
|
71
|
+
devCommand: string;
|
|
72
|
+
portSource: "flag" | "vincentt.json" | "default";
|
|
73
|
+
}): string[];
|
|
47
74
|
/** Flow L19 — the creator's own network drops. */
|
|
48
75
|
export declare function reconnecting(): string[];
|
|
49
76
|
export declare function reconnected(): string[];
|
|
@@ -179,6 +206,11 @@ export declare function supersededWinner(): string[];
|
|
|
179
206
|
* cannot act on that and the retry is the whole of the advice.
|
|
180
207
|
*/
|
|
181
208
|
export declare function endedUnreachable(orgName: string): string[];
|
|
209
|
+
/**
|
|
210
|
+
* The heartbeat's token was refused. Distinct from both causes above: the
|
|
211
|
+
* project and the access are fine, and the one thing to do is sign in again.
|
|
212
|
+
*/
|
|
213
|
+
export declare function endedAuthExpired(): string[];
|
|
182
214
|
export declare function endedUnconfirmed(): string[];
|
|
183
215
|
/**
|
|
184
216
|
* e. Abandoned — this machine stopped checking in (the 30-minute heartbeat-
|
package/dist/preview/copy.js
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
// session id, the edge hostname, or the workingTreeKey. The grant is the
|
|
17
17
|
// sharpest — it transits an agent's tool-output transcript by default in this
|
|
18
18
|
// product, so f1's "a token is never printed" extends to it verbatim.
|
|
19
|
+
/** Flow L13 — the start block. */
|
|
20
|
+
import { escapeControl } from "../lib/terminalText.js";
|
|
19
21
|
export function startBlock(i) {
|
|
20
22
|
const out = [
|
|
21
23
|
"",
|
|
@@ -59,7 +61,7 @@ export function startBlock(i) {
|
|
|
59
61
|
* without an account, which is false. It is offered as a CAPABILITY, not as
|
|
60
62
|
* consolation — no "unfortunately", no "you can still".
|
|
61
63
|
*/
|
|
62
|
-
export function notSignedIn() {
|
|
64
|
+
export function notSignedIn(devCommand = "npm run dev", localUrl = "http://localhost:5173") {
|
|
63
65
|
return [
|
|
64
66
|
"",
|
|
65
67
|
"✗ You're not signed in.",
|
|
@@ -68,12 +70,68 @@ export function notSignedIn() {
|
|
|
68
70
|
"",
|
|
69
71
|
" Sign in: vincentt login",
|
|
70
72
|
"",
|
|
71
|
-
|
|
72
|
-
|
|
73
|
+
` Your local dev loop does not need one. Run \`${devCommand}\` and open`,
|
|
74
|
+
` ${localUrl} on this machine. The camera works there.`,
|
|
73
75
|
" What signing in adds is the phone.",
|
|
74
76
|
"",
|
|
75
77
|
];
|
|
76
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Flow P1 — the two settings lines, printed right after resolution and before
|
|
81
|
+
* the sign-in check, the mint and the spawn. They are the ONLY disclosure of the
|
|
82
|
+
* dev command before it runs; no second line repeats it.
|
|
83
|
+
*/
|
|
84
|
+
export function settingsBanner(i) {
|
|
85
|
+
// The command is schema-checked to hold no control character; escaping here
|
|
86
|
+
// as well keeps this line honest even for a caller that skipped the check.
|
|
87
|
+
const row = (label, raw, source) => {
|
|
88
|
+
const value = escapeControl(raw);
|
|
89
|
+
return `→ ${label.padEnd(14)}${value.padEnd(Math.max(27, value.length + 2))}${source}`;
|
|
90
|
+
};
|
|
91
|
+
const out = [row("Dev command", i.devCommand, i.devCommandSource)];
|
|
92
|
+
if (i.reused) {
|
|
93
|
+
out.push(`${" ".repeat(16)}not started: something is already listening on port ${i.devPort}`);
|
|
94
|
+
}
|
|
95
|
+
out.push(row("Dev port", String(i.devPort), i.devPortSource));
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
/** Flow P3, static: `package.json` does not list the harness. Precedes the start block. */
|
|
99
|
+
export function noDiagnosticsStatic() {
|
|
100
|
+
return [
|
|
101
|
+
"",
|
|
102
|
+
"! This app has no Vincentt diagnostics, so `vincentt logs`, `network` and `trace`",
|
|
103
|
+
" will stay empty. The preview itself works.",
|
|
104
|
+
"",
|
|
105
|
+
" To add them, install the harness and put one line at the top of your entry file:",
|
|
106
|
+
" npm i @vincentt-xr/harness",
|
|
107
|
+
' import "@vincentt-xr/harness/auto";',
|
|
108
|
+
];
|
|
109
|
+
}
|
|
110
|
+
/** Flow P3, arrival: once per device class, only when the static line was shown. */
|
|
111
|
+
export function deviceArrived(descriptor) {
|
|
112
|
+
return descriptor
|
|
113
|
+
? `A device opened the preview · ${descriptor}`
|
|
114
|
+
: "A device opened the preview";
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Flow P3, runtime: the harness is listed but nothing said hello. Names the
|
|
118
|
+
* device class (a tester exists only after a hello) and asserts neither cause.
|
|
119
|
+
* The window's length is deliberately not printed.
|
|
120
|
+
*/
|
|
121
|
+
export function noDiagnosticsRuntime(descriptor) {
|
|
122
|
+
const opened = descriptor
|
|
123
|
+
? `A device opened the preview (${descriptor}), but no diagnostics have arrived`
|
|
124
|
+
: "A device opened the preview, but no diagnostics have arrived";
|
|
125
|
+
return [
|
|
126
|
+
`! ${opened}`,
|
|
127
|
+
" from the app. It may not load @vincentt-xr/harness/auto, or it may have",
|
|
128
|
+
" stopped before that line ran.",
|
|
129
|
+
];
|
|
130
|
+
}
|
|
131
|
+
/** Flow P1 — the proxy refused a hidden path. Once per preview, naming the first. */
|
|
132
|
+
export function hiddenPathRefused(requestPath) {
|
|
133
|
+
return `! Answered 404 for ${requestPath}. A preview never serves hidden files from your project.`;
|
|
134
|
+
}
|
|
77
135
|
/** Flow L16 — `--viewer`, a second terminal, not a second preview. */
|
|
78
136
|
export function followingBlock(label, descriptor) {
|
|
79
137
|
return [
|
|
@@ -96,12 +154,32 @@ export function followingFooter() {
|
|
|
96
154
|
* The middle line splits the wire in two, because the creator's first instinct
|
|
97
155
|
* on a 502 is to suspect us.
|
|
98
156
|
*/
|
|
99
|
-
export function originDown(targetUrl) {
|
|
157
|
+
export function originDown(targetUrl, settings) {
|
|
158
|
+
if (settings === undefined) {
|
|
159
|
+
return [
|
|
160
|
+
`! Your dev server isn't responding on ${targetUrl}.`,
|
|
161
|
+
"",
|
|
162
|
+
" The preview is still live. The phone is reaching us; we can't reach your app.",
|
|
163
|
+
" Start it and reload the phone: npm run dev",
|
|
164
|
+
"",
|
|
165
|
+
];
|
|
166
|
+
}
|
|
167
|
+
const source = settings.portSource === "vincentt.json"
|
|
168
|
+
? " (port from vincentt.json)"
|
|
169
|
+
: settings.portSource === "flag"
|
|
170
|
+
? " (port from --port)"
|
|
171
|
+
: "";
|
|
172
|
+
const fix = settings.portSource === "vincentt.json"
|
|
173
|
+
? " If it runs on another port, set dev.port in vincentt.json."
|
|
174
|
+
: settings.portSource === "flag"
|
|
175
|
+
? " If it runs on another port, pass that port with --port."
|
|
176
|
+
: " If it runs on another port, pass --port or add vincentt.json.";
|
|
100
177
|
return [
|
|
101
|
-
`! Your dev server isn't responding on ${targetUrl}.`,
|
|
178
|
+
`! Your dev server isn't responding on ${targetUrl}${source}.`,
|
|
102
179
|
"",
|
|
103
|
-
" The preview is still live
|
|
104
|
-
|
|
180
|
+
" The preview is still live. The phone is reaching us; we can't reach your app.",
|
|
181
|
+
` Start it and reload the phone: ${settings.devCommand}`,
|
|
182
|
+
fix,
|
|
105
183
|
"",
|
|
106
184
|
];
|
|
107
185
|
}
|
|
@@ -283,7 +361,7 @@ export function hardMaxWarning(clockTime, minutesLeft) {
|
|
|
283
361
|
`! This preview reaches its 12-hour maximum at ${clockTime} (in ${minutesLeft}m) and will stop.`,
|
|
284
362
|
"",
|
|
285
363
|
" A preview link is deliberately short-lived. To keep going, start a new one",
|
|
286
|
-
" then
|
|
364
|
+
" then. You'll get a new link, so any QR code on a desk will need re-scanning.",
|
|
287
365
|
"",
|
|
288
366
|
];
|
|
289
367
|
}
|
|
@@ -310,7 +388,7 @@ export function supersededLoser() {
|
|
|
310
388
|
"",
|
|
311
389
|
"✗ This preview was replaced by a newer one for this folder.",
|
|
312
390
|
"",
|
|
313
|
-
" Its link no longer works. The new preview has a different link
|
|
391
|
+
" Its link no longer works. The new preview has a different link, so whoever",
|
|
314
392
|
" is holding a phone will need it again.",
|
|
315
393
|
"",
|
|
316
394
|
];
|
|
@@ -363,6 +441,20 @@ export function endedUnreachable(orgName) {
|
|
|
363
441
|
"",
|
|
364
442
|
];
|
|
365
443
|
}
|
|
444
|
+
/**
|
|
445
|
+
* The heartbeat's token was refused. Distinct from both causes above: the
|
|
446
|
+
* project and the access are fine, and the one thing to do is sign in again.
|
|
447
|
+
*/
|
|
448
|
+
export function endedAuthExpired() {
|
|
449
|
+
return [
|
|
450
|
+
"",
|
|
451
|
+
"✗ Preview ended. Your Vincentt access token is no longer valid.",
|
|
452
|
+
"",
|
|
453
|
+
" Sign in again: vincentt login",
|
|
454
|
+
" Then start a new preview: vincentt preview",
|
|
455
|
+
"",
|
|
456
|
+
];
|
|
457
|
+
}
|
|
366
458
|
export function endedUnconfirmed() {
|
|
367
459
|
return [
|
|
368
460
|
"",
|
|
@@ -451,7 +543,7 @@ export function mintRateLimited(retryAfterSec) {
|
|
|
451
543
|
"",
|
|
452
544
|
`✗ Too many previews started recently. Try again in ${approxWait(retryAfterSec)}.`,
|
|
453
545
|
"",
|
|
454
|
-
" A running preview does not count against this
|
|
546
|
+
" A running preview does not count against this. If one is already running",
|
|
455
547
|
" for this folder, you don't need a new one.",
|
|
456
548
|
"",
|
|
457
549
|
];
|
package/dist/preview/proxy.d.ts
CHANGED
|
@@ -12,4 +12,41 @@ import type { Duplex } from "node:stream";
|
|
|
12
12
|
* under a live preview, not by reading the code.
|
|
13
13
|
*/
|
|
14
14
|
export declare function proxyWeb(req: IncomingMessage, res: ServerResponse, targetPort: number, onOriginError?: () => void): void;
|
|
15
|
-
export
|
|
15
|
+
export interface ProxyWsOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Rewrite `Origin` to the dev server's own loopback origin iff the request's
|
|
18
|
+
* Origin is BYTE-EQUAL to this value. It is the minted preview origin, fixed at
|
|
19
|
+
* mint and never read from a request. A dev server refuses a foreign Origin on
|
|
20
|
+
* its live-reload socket; equality keeps that refusal for every other page.
|
|
21
|
+
*/
|
|
22
|
+
rewriteOriginFrom?: string;
|
|
23
|
+
}
|
|
24
|
+
export declare function proxyWs(req: IncomingMessage, socket: Duplex, head: Buffer, targetPort: number, opts?: ProxyWsOptions): void;
|
|
25
|
+
/**
|
|
26
|
+
* Where the front proxy sends a request: to the relay only on the exact paths
|
|
27
|
+
* the phone-side harness uses, and nowhere at all for anything else under the
|
|
28
|
+
* harness prefix. The harness opens its WebSocket at `harnessPath` and POSTs
|
|
29
|
+
* annotations to `harnessPath/annotation`; nothing else of the relay is
|
|
30
|
+
* tunnel-facing (`/query` and `feedback/next` belong to the local CLI).
|
|
31
|
+
*
|
|
32
|
+
* Exact comparison of the raw path is what makes `/__harness/../query` and
|
|
33
|
+
* `/__harness/%2e%2e/query` land on `refuse` rather than on the relay, where
|
|
34
|
+
* URL normalisation would have turned them into `/query`.
|
|
35
|
+
*/
|
|
36
|
+
export declare function relayRoute(url: string, harnessPath: string, kind: "http" | "upgrade"): "relay" | "refuse" | "app";
|
|
37
|
+
/**
|
|
38
|
+
* Whether the front proxy must answer 404 itself for this app-route URL, so a
|
|
39
|
+
* file-server dev tool serving the repo root cannot hand `/.env`, `/.git/config`
|
|
40
|
+
* or the binding to any holder of a preview link.
|
|
41
|
+
*
|
|
42
|
+
* The path is decoded once, and an encoded `/` or `\` is refused outright (a
|
|
43
|
+
* separator the dev server would split on and this check would not). A segment
|
|
44
|
+
* still carrying an encoded dot after that one decode is judged as the dev
|
|
45
|
+
* server would read it after a second decode, so `%252eenv` cannot slip past.
|
|
46
|
+
* `.` and `..` segments are always refused, which is what makes the `/@fs/`
|
|
47
|
+
* text containment below real containment.
|
|
48
|
+
*
|
|
49
|
+
* `projectRoot` is the realpath of the binding directory, taken once at preview
|
|
50
|
+
* start; inside-ness is a segment-aligned text prefix, with no disk call.
|
|
51
|
+
*/
|
|
52
|
+
export declare function isHiddenAppPath(url: string, projectRoot: string): boolean;
|
package/dist/preview/proxy.js
CHANGED
|
@@ -3,12 +3,48 @@
|
|
|
3
3
|
// relay behind one origin); not a general proxy. No deps.
|
|
4
4
|
import { connect } from "node:net";
|
|
5
5
|
import { request, } from "node:http";
|
|
6
|
+
import { createGzip } from "node:zlib";
|
|
6
7
|
// Rewrite Host to the loopback target. cloudflared forwards the public Host, and
|
|
7
8
|
// esbuild's `serve` 403s any Host it doesn't recognize — so the phone would get a
|
|
8
9
|
// 403. The origin only needs a Host it accepts; the browser never sees this value.
|
|
9
10
|
function localHeaders(headers, targetPort) {
|
|
10
11
|
return { ...headers, host: `localhost:${targetPort}` };
|
|
11
12
|
}
|
|
13
|
+
const COMPRESSIBLE_TYPE = /^(text\/(?!event-stream)|application\/(javascript|json|xml)|image\/svg\+xml)|\+json\b|\+xml\b/i;
|
|
14
|
+
function acceptsGzip(header) {
|
|
15
|
+
if (header === undefined)
|
|
16
|
+
return false;
|
|
17
|
+
const value = Array.isArray(header) ? header.join(",") : header;
|
|
18
|
+
for (const part of value.split(",")) {
|
|
19
|
+
const [coding, ...params] = part.trim().split(";");
|
|
20
|
+
const name = coding?.trim().toLowerCase();
|
|
21
|
+
if (name !== "gzip" && name !== "*")
|
|
22
|
+
continue;
|
|
23
|
+
const q = params.map((p) => p.trim().toLowerCase()).find((p) => p.startsWith("q="));
|
|
24
|
+
if (q !== undefined && !(Number(q.slice(2)) > 0))
|
|
25
|
+
continue;
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
function shouldGzip(req, proxyRes, status) {
|
|
31
|
+
if (req.method === "HEAD" || status < 200 || status === 204 || status === 206 || status === 304)
|
|
32
|
+
return false;
|
|
33
|
+
if (proxyRes.headers["content-encoding"] !== undefined)
|
|
34
|
+
return false;
|
|
35
|
+
const cacheControl = String(proxyRes.headers["cache-control"] ?? "");
|
|
36
|
+
if (/\bno-transform\b/i.test(cacheControl))
|
|
37
|
+
return false;
|
|
38
|
+
if (!COMPRESSIBLE_TYPE.test(String(proxyRes.headers["content-type"] ?? "")))
|
|
39
|
+
return false;
|
|
40
|
+
return acceptsGzip(req.headers["accept-encoding"]);
|
|
41
|
+
}
|
|
42
|
+
function appendVary(existing) {
|
|
43
|
+
const current = Array.isArray(existing) ? existing.join(", ") : (existing ?? "");
|
|
44
|
+
if (/(^|,)\s*(accept-encoding|\*)\s*(,|$)/i.test(current))
|
|
45
|
+
return current;
|
|
46
|
+
return current === "" ? "Accept-Encoding" : `${current}, Accept-Encoding`;
|
|
47
|
+
}
|
|
12
48
|
/**
|
|
13
49
|
* onOriginError is where the creator's dev server being down is ACTUALLY
|
|
14
50
|
* OBSERVED.
|
|
@@ -35,7 +71,19 @@ export function proxyWeb(req, res, targetPort, onOriginError) {
|
|
|
35
71
|
const headers = { ...proxyRes.headers, "cache-control": "no-store" };
|
|
36
72
|
delete headers.etag;
|
|
37
73
|
delete headers["last-modified"];
|
|
38
|
-
|
|
74
|
+
const status = proxyRes.statusCode ?? 502;
|
|
75
|
+
if (shouldGzip(req, proxyRes, status)) {
|
|
76
|
+
delete headers["content-length"];
|
|
77
|
+
headers["content-encoding"] = "gzip";
|
|
78
|
+
headers.vary = appendVary(headers.vary);
|
|
79
|
+
res.writeHead(status, headers);
|
|
80
|
+
const gzip = createGzip();
|
|
81
|
+
gzip.on("error", () => res.destroy());
|
|
82
|
+
proxyRes.on("error", () => gzip.destroy());
|
|
83
|
+
proxyRes.pipe(gzip).pipe(res);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
res.writeHead(status, headers);
|
|
39
87
|
proxyRes.pipe(res);
|
|
40
88
|
});
|
|
41
89
|
proxyReq.on("error", () => {
|
|
@@ -46,9 +94,12 @@ export function proxyWeb(req, res, targetPort, onOriginError) {
|
|
|
46
94
|
});
|
|
47
95
|
req.pipe(proxyReq);
|
|
48
96
|
}
|
|
49
|
-
export function proxyWs(req, socket, head, targetPort) {
|
|
97
|
+
export function proxyWs(req, socket, head, targetPort, opts = {}) {
|
|
50
98
|
// Re-issue the upgrade handshake against the target and splice the sockets.
|
|
51
99
|
const headers = localHeaders(req.headers, targetPort);
|
|
100
|
+
if (opts.rewriteOriginFrom !== undefined && headers.origin === opts.rewriteOriginFrom) {
|
|
101
|
+
headers.origin = `http://localhost:${targetPort}`;
|
|
102
|
+
}
|
|
52
103
|
const upstream = connect(targetPort, "127.0.0.1", () => {
|
|
53
104
|
const headerLines = [
|
|
54
105
|
`${req.method} ${req.url} HTTP/1.1`,
|
|
@@ -66,3 +117,101 @@ export function proxyWs(req, socket, head, targetPort) {
|
|
|
66
117
|
upstream.on("error", bail);
|
|
67
118
|
socket.on("error", bail);
|
|
68
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* Where the front proxy sends a request: to the relay only on the exact paths
|
|
122
|
+
* the phone-side harness uses, and nowhere at all for anything else under the
|
|
123
|
+
* harness prefix. The harness opens its WebSocket at `harnessPath` and POSTs
|
|
124
|
+
* annotations to `harnessPath/annotation`; nothing else of the relay is
|
|
125
|
+
* tunnel-facing (`/query` and `feedback/next` belong to the local CLI).
|
|
126
|
+
*
|
|
127
|
+
* Exact comparison of the raw path is what makes `/__harness/../query` and
|
|
128
|
+
* `/__harness/%2e%2e/query` land on `refuse` rather than on the relay, where
|
|
129
|
+
* URL normalisation would have turned them into `/query`.
|
|
130
|
+
*/
|
|
131
|
+
export function relayRoute(url, harnessPath, kind) {
|
|
132
|
+
const raw = url.split("?")[0] ?? "";
|
|
133
|
+
if (kind === "upgrade" && raw === harnessPath)
|
|
134
|
+
return "relay";
|
|
135
|
+
if (kind === "http" && raw === `${harnessPath}/annotation`)
|
|
136
|
+
return "relay";
|
|
137
|
+
return raw.startsWith(harnessPath) ? "refuse" : "app";
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Whether the front proxy must answer 404 itself for this app-route URL, so a
|
|
141
|
+
* file-server dev tool serving the repo root cannot hand `/.env`, `/.git/config`
|
|
142
|
+
* or the binding to any holder of a preview link.
|
|
143
|
+
*
|
|
144
|
+
* The path is decoded once, and an encoded `/` or `\` is refused outright (a
|
|
145
|
+
* separator the dev server would split on and this check would not). A segment
|
|
146
|
+
* still carrying an encoded dot after that one decode is judged as the dev
|
|
147
|
+
* server would read it after a second decode, so `%252eenv` cannot slip past.
|
|
148
|
+
* `.` and `..` segments are always refused, which is what makes the `/@fs/`
|
|
149
|
+
* text containment below real containment.
|
|
150
|
+
*
|
|
151
|
+
* `projectRoot` is the realpath of the binding directory, taken once at preview
|
|
152
|
+
* start; inside-ness is a segment-aligned text prefix, with no disk call.
|
|
153
|
+
*/
|
|
154
|
+
export function isHiddenAppPath(url, projectRoot) {
|
|
155
|
+
const raw = (url.split("#")[0] ?? "").split("?")[0] ?? "";
|
|
156
|
+
if (/%(2f|5c)/i.test(raw) || raw.includes("\\"))
|
|
157
|
+
return true;
|
|
158
|
+
let decoded;
|
|
159
|
+
try {
|
|
160
|
+
decoded = decodeURIComponent(raw);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
const segments = decoded.split("/").slice(1);
|
|
166
|
+
if (segments.some((s) => s === "." || s === ".."))
|
|
167
|
+
return true;
|
|
168
|
+
const checked = checkedSegmentIndexes(segments, projectRoot);
|
|
169
|
+
for (const i of checked) {
|
|
170
|
+
const seg = segments[i];
|
|
171
|
+
if (!startsWithDot(seg))
|
|
172
|
+
continue;
|
|
173
|
+
if (isAllowedDotSegment(segments, i))
|
|
174
|
+
continue;
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
function startsWithDot(segment) {
|
|
180
|
+
if (segment.startsWith("."))
|
|
181
|
+
return true;
|
|
182
|
+
try {
|
|
183
|
+
return decodeURIComponent(segment).startsWith(".");
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function checkedSegmentIndexes(segments, projectRoot) {
|
|
190
|
+
const all = segments.map((_, i) => i);
|
|
191
|
+
if (segments[0] !== "@fs")
|
|
192
|
+
return all;
|
|
193
|
+
const disk = segments.slice(1);
|
|
194
|
+
const rootSegments = projectRoot.split("/").filter((s) => s !== "");
|
|
195
|
+
const inside = disk.length >= rootSegments.length && rootSegments.every((s, i) => disk[i] === s);
|
|
196
|
+
if (inside)
|
|
197
|
+
return all.slice(1 + rootSegments.length);
|
|
198
|
+
const lastNodeModules = disk.lastIndexOf("node_modules");
|
|
199
|
+
if (lastNodeModules !== -1)
|
|
200
|
+
return all.slice(1 + lastNodeModules + 1);
|
|
201
|
+
return all.slice(1);
|
|
202
|
+
}
|
|
203
|
+
/** The four anchored exceptions (D-Preview-hides-dot-paths). Case-sensitive. */
|
|
204
|
+
function isAllowedDotSegment(segments, i) {
|
|
205
|
+
const seg = segments[i];
|
|
206
|
+
const prev = segments[i - 1];
|
|
207
|
+
const next = segments[i + 1];
|
|
208
|
+
if (seg === ".well-known")
|
|
209
|
+
return i === 0;
|
|
210
|
+
if (seg === ".vite" || seg === ".pnpm")
|
|
211
|
+
return prev === "node_modules";
|
|
212
|
+
if (seg === ".svelte-kit")
|
|
213
|
+
return next === "generated" && segments[i + 2] !== "server";
|
|
214
|
+
if (seg === ".yarn")
|
|
215
|
+
return next === "cache" || next === "__virtual__" || next === "unplugged";
|
|
216
|
+
return false;
|
|
217
|
+
}
|
package/dist/preview/qrPage.js
CHANGED
|
@@ -23,7 +23,7 @@ export async function buildQrPage(url) {
|
|
|
23
23
|
<head>
|
|
24
24
|
<meta charset="utf-8" />
|
|
25
25
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
26
|
-
<title>Scan to preview
|
|
26
|
+
<title>Scan to preview: ${safe}</title>
|
|
27
27
|
<style>
|
|
28
28
|
:root { color-scheme: light dark; }
|
|
29
29
|
body { margin:0; min-height:100vh; display:grid; place-items:center;
|
package/dist/preview/runner.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { type ControlKind, type TunnelState, type UAClass } from "@vincentt-xr/harness/tunnel";
|
|
2
2
|
import type { DiagEvent } from "@vincentt-xr/harness/events";
|
|
3
3
|
import { type EventTags } from "../relay/server.js";
|
|
4
|
-
import type
|
|
4
|
+
import { type ResolvedConfig } from "../shared/config.js";
|
|
5
|
+
/** The path a 404 advisory names: the request's own path, without its query. */
|
|
6
|
+
export declare function requestPathOf(url: string | undefined): string;
|
|
5
7
|
/** Why a preview ended. Each maps to exactly one screen (Flow L20/L21). */
|
|
6
8
|
export type PreviewEndReason = "stopped"
|
|
7
9
|
/**
|
|
@@ -29,7 +31,13 @@ export type PreviewEndReason = "stopped"
|
|
|
29
31
|
* session ended at minute 30 has not reached its 12-hour ceiling, and saying
|
|
30
32
|
* so is a false statement about the product's own behaviour.
|
|
31
33
|
*/
|
|
32
|
-
| "abandoned" | "egress_cap"
|
|
34
|
+
| "abandoned" | "egress_cap"
|
|
35
|
+
/**
|
|
36
|
+
* The heartbeat's token was refused (401). Not a network blip: every later
|
|
37
|
+
* beat is refused the same way, so the preview ends with the sign-in remedy
|
|
38
|
+
* instead of heartbeating until the edge drains it.
|
|
39
|
+
*/
|
|
40
|
+
| "auth_expired";
|
|
33
41
|
export interface StartPreviewOptions {
|
|
34
42
|
/** Project directory — its .vincentt binding + dev script drive the preview. */
|
|
35
43
|
projectCwd: string;
|
|
@@ -55,7 +63,13 @@ export interface StartPreviewOptions {
|
|
|
55
63
|
frontPort?: number;
|
|
56
64
|
/** Path routed to the relay instead of the app (default /__harness). */
|
|
57
65
|
harnessPath?: string;
|
|
58
|
-
|
|
66
|
+
/**
|
|
67
|
+
* The dev command. A string (the resolved `dev.command`) runs through the
|
|
68
|
+
* platform shell, because creator-authored commands carry arguments and `&&`.
|
|
69
|
+
*/
|
|
70
|
+
devCommand?: string[] | string;
|
|
71
|
+
/** A hidden path was refused by the front proxy (the 404 advisory). */
|
|
72
|
+
onHiddenPath?: (requestPath: string) => void;
|
|
59
73
|
appStdio?: "ignore" | "inherit";
|
|
60
74
|
appReadyTimeoutMs?: number;
|
|
61
75
|
/** Progress sink. Never write app stdout to an MCP server's stdout. */
|
|
@@ -158,7 +172,8 @@ export declare function refusalToReason(status: number): PreviewEndReason | null
|
|
|
158
172
|
/**
|
|
159
173
|
* One heartbeat. A 410 is the session ending on the server's terms; a 404/403
|
|
160
174
|
* is authorization lost. They are DELIBERATELY NOT MERGED, because the remedies
|
|
161
|
-
* differ: one is "find out who you are", the other is "try again".
|
|
175
|
+
* differ: one is "find out who you are", the other is "try again". A 401 is the
|
|
176
|
+
* token itself refused, and its remedy is signing in again.
|
|
162
177
|
*
|
|
163
178
|
* A transport failure is neither — the API being briefly unreachable must not
|
|
164
179
|
* end a healthy preview, because the edge already fails closed on its own
|
|
@@ -170,20 +185,17 @@ export declare function refusalToReason(status: number): PreviewEndReason | null
|
|
|
170
185
|
* superseded, or stopped elsewhere, all answer 410, and the hard-max screen is a
|
|
171
186
|
* `✓` telling the creator the preview did exactly what it promised over 12 hours.
|
|
172
187
|
*
|
|
173
|
-
* The API's 410
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
* and must not guess. `session_ended` is the honest, cause-free rendering until
|
|
177
|
-
* the wire carries the discriminator. See the note on PreviewEndedError.
|
|
188
|
+
* The API's 410 carries the terminal reason under `detail.endReason`, and each
|
|
189
|
+
* reason maps to its own screen. A 410 with no reason gets the cause-free
|
|
190
|
+
* ending. See the note on PreviewEndedError.
|
|
178
191
|
*/
|
|
179
192
|
export declare function runHeartbeat(config: ResolvedConfig, sessionId: string, fetchImpl: typeof fetch, onEnded?: (reason: PreviewEndReason) => void): Promise<void>;
|
|
180
193
|
/**
|
|
181
194
|
* The API's `endReason` → the CLI's ending screen.
|
|
182
195
|
*
|
|
183
|
-
* `hard_max` is returned ONLY for an explicit `expired`. An UNLABELLED 410
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
* and the link no longer works. It asserts no cause.
|
|
196
|
+
* `hard_max` is returned ONLY for an explicit `expired`. An UNLABELLED 410 maps
|
|
197
|
+
* to `grace_exhausted`, the one ending whose copy states only what is actually
|
|
198
|
+
* known: the preview has ended and the link no longer works. It asserts no cause.
|
|
187
199
|
*
|
|
188
200
|
* That default is the conservative choice and it is deliberate. `hard_max` is a
|
|
189
201
|
* `✓` claiming the preview ran its full 12 hours; rendering it for a session the
|