letmeknow-cli 0.4.2 → 0.4.3
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/README.md +37 -128
- package/SKILL.md +49 -115
- package/bin/letmeknow.js +9 -286
- package/bin/relay.js +440 -0
- package/bin/remote.js +299 -0
- package/package.json +6 -3
package/bin/relay.js
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import { createServer } from "vite";
|
|
2
|
+
import { existsSync, statSync } from "node:fs";
|
|
3
|
+
import { extname, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { Readable, Writable } from "node:stream";
|
|
5
|
+
|
|
6
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
7
|
+
const GRACE_SECONDS = 10 * 60;
|
|
8
|
+
const CONNECTION_TIMEOUT = 10_000;
|
|
9
|
+
const MAX_RETRY_DELAY = 5_000;
|
|
10
|
+
const credentialPattern = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
11
|
+
const clientPath = "/__letmeknow_client.js";
|
|
12
|
+
const clientId = "\0letmeknow-client";
|
|
13
|
+
const client = String.raw`
|
|
14
|
+
const key="letmeknow-client:"+location.host+location.pathname;
|
|
15
|
+
const draftKey="letmeknow-draft:"+location.host+location.pathname;
|
|
16
|
+
let credential=sessionStorage.getItem(key);
|
|
17
|
+
let socket;
|
|
18
|
+
let retryTimer;
|
|
19
|
+
let terminal=false;
|
|
20
|
+
const stateKey=(control,index)=>control.id?"#"+control.id:(control.form?.id??"")+":"+control.name+":"+control.type+":"+index;
|
|
21
|
+
const controls=root=>[...root.querySelectorAll("input,select,textarea")];
|
|
22
|
+
const snapshot=()=>{
|
|
23
|
+
const state=new Map();
|
|
24
|
+
let activeKey;
|
|
25
|
+
for(const [index,control] of controls(document).entries()){
|
|
26
|
+
const key=stateKey(control,index);
|
|
27
|
+
state.set(key,{value:control.value,checked:control.checked,selected:control instanceof HTMLSelectElement?[...control.options].filter(option=>option.selected).map(option=>option.value):undefined,start:typeof control.selectionStart==="number"?control.selectionStart:undefined,end:typeof control.selectionEnd==="number"?control.selectionEnd:undefined});
|
|
28
|
+
if(control===document.activeElement)activeKey=key;
|
|
29
|
+
}
|
|
30
|
+
return {state,activeKey,x:scrollX,y:scrollY};
|
|
31
|
+
};
|
|
32
|
+
const restore=saved=>{
|
|
33
|
+
let active;
|
|
34
|
+
for(const [index,control] of controls(document).entries()){
|
|
35
|
+
const state=saved.state.get(stateKey(control,index));
|
|
36
|
+
if(!state)continue;
|
|
37
|
+
if(control instanceof HTMLSelectElement&&state.selected)for(const option of control.options)option.selected=state.selected.includes(option.value);
|
|
38
|
+
else if(control.type==="checkbox"||control.type==="radio")control.checked=state.checked;
|
|
39
|
+
else{control.value=state.value;if(typeof state.start==="number"&&typeof control.setSelectionRange==="function")control.setSelectionRange(state.start,state.end)}
|
|
40
|
+
if(stateKey(control,index)===saved.activeKey)active=control;
|
|
41
|
+
}
|
|
42
|
+
active?.focus();
|
|
43
|
+
scrollTo(saved.x,saved.y);
|
|
44
|
+
};
|
|
45
|
+
const status=message=>{const element=document.querySelector("[data-letmeknow-status]");if(element)element.textContent=message};
|
|
46
|
+
const sessionPrefix=location.pathname.match(/^\/s\/[a-f0-9]{20}\//)?.[0];
|
|
47
|
+
const currentPath=()=>{const path=sessionPrefix?location.pathname.slice(sessionPrefix.length-1)||"/":location.pathname;return path.endsWith("/")?path+"index.html":path};
|
|
48
|
+
const refresh=async()=>{
|
|
49
|
+
const saved=snapshot();
|
|
50
|
+
const response=await fetch(location.href,{cache:"no-store",headers:{Accept:"text/html"}});
|
|
51
|
+
if(!response.ok)throw new Error("page refresh failed");
|
|
52
|
+
const next=new DOMParser().parseFromString(await response.text(),"text/html");
|
|
53
|
+
document.title=next.title;
|
|
54
|
+
document.body.replaceChildren(...[...next.body.childNodes].filter(node=>!(node instanceof HTMLScriptElement&&node.hasAttribute("data-letmeknow-client"))));
|
|
55
|
+
const links=[...document.head.querySelectorAll("link[rel=stylesheet]")];
|
|
56
|
+
for(const link of links){const url=new URL(link.href);url.searchParams.set("_letmeknow",crypto.randomUUID());link.href=url}
|
|
57
|
+
restore(saved);
|
|
58
|
+
};
|
|
59
|
+
const update=path=>{if(path===currentPath()||path?.endsWith(".css"))refresh().catch(()=>status("The page could not be refreshed"))};
|
|
60
|
+
const connect=()=>{
|
|
61
|
+
clearTimeout(retryTimer);retryTimer=undefined;
|
|
62
|
+
const url=new URL("_letmeknow/client",location.href);url.protocol=url.protocol==="https:"?"wss:":"ws:";
|
|
63
|
+
socket=credential?new WebSocket(url,credential):new WebSocket(url);
|
|
64
|
+
socket.onmessage=event=>{
|
|
65
|
+
const message=JSON.parse(event.data);
|
|
66
|
+
if(message.type==="credential"){credential=message.credential;sessionStorage.setItem(key,credential);return}
|
|
67
|
+
if(message.type==="challenge"){socket.send(JSON.stringify({type:"alive",nonce:message.nonce}));return}
|
|
68
|
+
if(message.type==="busy"){status("This session is open elsewhere");retryTimer=setTimeout(connect,message.retry_after*1000);return}
|
|
69
|
+
if(message.type==="file_update"){update(message.path);return}
|
|
70
|
+
if(message.type==="closed"){terminal=true;sessionStorage.removeItem(key);sessionStorage.removeItem(draftKey);status(message.message)}
|
|
71
|
+
};
|
|
72
|
+
socket.onclose=()=>{if(!terminal&&!retryTimer){status("Reconnecting…");retryTimer=setTimeout(connect,1000)}};
|
|
73
|
+
socket.onerror=()=>{};
|
|
74
|
+
};
|
|
75
|
+
document.addEventListener("submit",async event=>{
|
|
76
|
+
const form=event.target;
|
|
77
|
+
if(!(form instanceof HTMLFormElement))return;
|
|
78
|
+
event.preventDefault();
|
|
79
|
+
const submitter=event.submitter;
|
|
80
|
+
const method=(submitter?.getAttribute("formmethod")??form.getAttribute("method")??"get").toLowerCase();
|
|
81
|
+
if(method!=="get"&&method!=="post"){status("Only GET and POST forms are supported");return}
|
|
82
|
+
if(!form.checkValidity()){form.reportValidity();return}
|
|
83
|
+
let target;
|
|
84
|
+
try{target=new URL(submitter?.getAttribute("formaction")??form.getAttribute("action")??location.href,location.href)}catch{status("Invalid form action");return}
|
|
85
|
+
if(target.origin!==location.origin){status("Form actions must stay on this site");return}
|
|
86
|
+
if(sessionPrefix&&!target.pathname.startsWith(sessionPrefix))target.pathname=sessionPrefix+target.pathname;
|
|
87
|
+
const values=new URLSearchParams();
|
|
88
|
+
for(const [name,value] of new FormData(form,submitter)){if(typeof value!=="string"){status("File inputs are not supported");return}values.append(name,value)}
|
|
89
|
+
const metadata={id:crypto.randomUUID(),form_id:form.id||null,action:target.pathname+target.search,trigger:{id:submitter?.id||null,name:submitter?.getAttribute("name"),value:submitter?.getAttribute("value")}};
|
|
90
|
+
const headers={"X-LetMeKnow-Submission":"1","X-LetMeKnow-ID":encodeURIComponent(metadata.id),"X-LetMeKnow-Form-ID":encodeURIComponent(metadata.form_id??""),"X-LetMeKnow-Action":encodeURIComponent(metadata.action),"X-LetMeKnow-Trigger-ID":encodeURIComponent(metadata.trigger.id??""),"X-LetMeKnow-Trigger-Name":encodeURIComponent(metadata.trigger.name??""),"X-LetMeKnow-Trigger-Value":encodeURIComponent(metadata.trigger.value??"")};
|
|
91
|
+
if(method==="get")for(const [name,value] of values)target.searchParams.append(name,value);
|
|
92
|
+
try{const response=await fetch(target,{method:method.toUpperCase(),headers,...(method==="post"?{body:values}:{})});if(!response.ok)throw new Error();status("Submitted")}catch{status("The submission failed")}
|
|
93
|
+
});
|
|
94
|
+
connect();
|
|
95
|
+
`;
|
|
96
|
+
|
|
97
|
+
function encodedHeader(request, name) {
|
|
98
|
+
const value = request.headers[name];
|
|
99
|
+
if (typeof value !== "string" || value === "") return null;
|
|
100
|
+
try {
|
|
101
|
+
return decodeURIComponent(value);
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function addValue(values, name, value) {
|
|
108
|
+
if (Object.prototype.hasOwnProperty.call(values, name)) {
|
|
109
|
+
values[name] = Array.isArray(values[name]) ? [...values[name], value] : [values[name], value];
|
|
110
|
+
} else {
|
|
111
|
+
values[name] = value;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function readBody(request) {
|
|
116
|
+
return new Promise((resolveBody, reject) => {
|
|
117
|
+
const chunks = [];
|
|
118
|
+
let size = 0;
|
|
119
|
+
let tooLarge = false;
|
|
120
|
+
request.on("data", chunk => {
|
|
121
|
+
if (tooLarge) return;
|
|
122
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
123
|
+
size += buffer.byteLength;
|
|
124
|
+
if (size > MAX_BODY_BYTES) {
|
|
125
|
+
tooLarge = true;
|
|
126
|
+
request.resume();
|
|
127
|
+
reject(new Error("submission is too large"));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
chunks.push(buffer);
|
|
131
|
+
});
|
|
132
|
+
request.on("end", () => resolveBody(Buffer.concat(chunks)));
|
|
133
|
+
request.on("error", reject);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function submission(request, response) {
|
|
138
|
+
const url = new URL(request.url || "/", "http://localhost");
|
|
139
|
+
const method = (request.method || "GET").toUpperCase();
|
|
140
|
+
const values = Object.create(null);
|
|
141
|
+
if (method === "GET") {
|
|
142
|
+
for (const [name, value] of url.searchParams) addValue(values, name, value);
|
|
143
|
+
} else if (method === "POST") {
|
|
144
|
+
const body = await readBody(request);
|
|
145
|
+
const contentType = request.headers["content-type"]?.split(";", 1)[0].trim();
|
|
146
|
+
if (contentType !== "application/x-www-form-urlencoded") throw new Error("unsupported submission encoding");
|
|
147
|
+
for (const [name, value] of new URLSearchParams(body.toString("utf8"))) addValue(values, name, value);
|
|
148
|
+
} else {
|
|
149
|
+
throw new Error("unsupported submission method");
|
|
150
|
+
}
|
|
151
|
+
const event = {
|
|
152
|
+
type: "submit",
|
|
153
|
+
id: encodedHeader(request, "x-letmeknow-id"),
|
|
154
|
+
method,
|
|
155
|
+
action: encodedHeader(request, "x-letmeknow-action") || url.pathname,
|
|
156
|
+
form_id: encodedHeader(request, "x-letmeknow-form-id"),
|
|
157
|
+
trigger: {
|
|
158
|
+
id: encodedHeader(request, "x-letmeknow-trigger-id"),
|
|
159
|
+
name: encodedHeader(request, "x-letmeknow-trigger-name"),
|
|
160
|
+
value: encodedHeader(request, "x-letmeknow-trigger-value")
|
|
161
|
+
},
|
|
162
|
+
values
|
|
163
|
+
};
|
|
164
|
+
process.stdout.write(`${JSON.stringify(event)}\n`);
|
|
165
|
+
response.statusCode = 204;
|
|
166
|
+
response.setHeader("Cache-Control", "no-store");
|
|
167
|
+
response.end();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
class RelayRequest extends Readable {
|
|
171
|
+
constructor(packet) {
|
|
172
|
+
super();
|
|
173
|
+
this.method = packet.method;
|
|
174
|
+
this.url = packet.path;
|
|
175
|
+
this.originalUrl = packet.path;
|
|
176
|
+
this.headers = packet.headers || {};
|
|
177
|
+
this.httpVersion = "1.1";
|
|
178
|
+
this.httpVersionMajor = 1;
|
|
179
|
+
this.httpVersionMinor = 1;
|
|
180
|
+
this.socket = { encrypted: false, remoteAddress: "127.0.0.1" };
|
|
181
|
+
this.body = Buffer.from(packet.body || "", "base64");
|
|
182
|
+
this.sent = false;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
_read() {
|
|
186
|
+
if (this.sent) return;
|
|
187
|
+
this.sent = true;
|
|
188
|
+
this.push(this.body);
|
|
189
|
+
this.push(null);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
class RelayResponse extends Writable {
|
|
194
|
+
constructor() {
|
|
195
|
+
super();
|
|
196
|
+
this.statusCode = 200;
|
|
197
|
+
this.headers = new Map();
|
|
198
|
+
this.chunks = [];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
setHeader(name, value) {
|
|
202
|
+
this.headers.set(name.toLowerCase(), Array.isArray(value) ? value.join(", ") : String(value));
|
|
203
|
+
return this;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
getHeader(name) {
|
|
207
|
+
return this.headers.get(name.toLowerCase());
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
getHeaders() {
|
|
211
|
+
return Object.fromEntries(this.headers);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
hasHeader(name) {
|
|
215
|
+
return this.headers.has(name.toLowerCase());
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
removeHeader(name) {
|
|
219
|
+
this.headers.delete(name.toLowerCase());
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
writeHead(status, headers) {
|
|
223
|
+
this.statusCode = status;
|
|
224
|
+
if (headers) for (const [name, value] of Object.entries(headers)) this.setHeader(name, value);
|
|
225
|
+
return this;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
flushHeaders() {}
|
|
229
|
+
|
|
230
|
+
_write(chunk, _encoding, callback) {
|
|
231
|
+
this.chunks.push(Buffer.from(chunk));
|
|
232
|
+
callback();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
body() {
|
|
236
|
+
return Buffer.concat(this.chunks);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function middlewareResponse(response) {
|
|
241
|
+
const body = response.body();
|
|
242
|
+
if (body.byteLength > MAX_BODY_BYTES) throw new Error("response body is too large");
|
|
243
|
+
return {
|
|
244
|
+
type: "http_response",
|
|
245
|
+
request_id: response.requestId,
|
|
246
|
+
status: response.statusCode,
|
|
247
|
+
headers: response.getHeaders(),
|
|
248
|
+
body: body.toString("base64")
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function handleRequest(server, packet, send) {
|
|
253
|
+
const request = new RelayRequest(packet);
|
|
254
|
+
const response = new RelayResponse();
|
|
255
|
+
response.requestId = packet.request_id;
|
|
256
|
+
await new Promise((resolveRequest, rejectRequest) => {
|
|
257
|
+
response.once("finish", resolveRequest);
|
|
258
|
+
response.once("error", rejectRequest);
|
|
259
|
+
try {
|
|
260
|
+
server.middlewares(request, response, () => {
|
|
261
|
+
if (!response.writableEnded) {
|
|
262
|
+
response.statusCode = 404;
|
|
263
|
+
response.end("Not found");
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
} catch (cause) {
|
|
267
|
+
rejectRequest(cause);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
send(middlewareResponse(response));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function options(args) {
|
|
274
|
+
let root;
|
|
275
|
+
for (const argument of args) {
|
|
276
|
+
if (argument.startsWith("-")) throw new Error(`unknown option: ${argument}`);
|
|
277
|
+
if (root !== undefined) throw new Error("only one directory may be provided");
|
|
278
|
+
root = resolve(argument);
|
|
279
|
+
}
|
|
280
|
+
root = root || process.cwd();
|
|
281
|
+
if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`directory does not exist: ${root}`);
|
|
282
|
+
return { root };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function endpoint(control, credential, sessionUrl) {
|
|
286
|
+
const url = new URL(control);
|
|
287
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
288
|
+
url.pathname = "/v1/connect";
|
|
289
|
+
url.search = "";
|
|
290
|
+
url.hash = "";
|
|
291
|
+
if (credential && sessionUrl) {
|
|
292
|
+
const publicUrl = new URL(sessionUrl);
|
|
293
|
+
const hostCode = publicUrl.hostname.match(/^([a-f0-9]{20})\.letmeknow\.dev$/);
|
|
294
|
+
const pathCode = publicUrl.pathname.match(/^\/s\/([a-f0-9]{20})(?:\/|$)/);
|
|
295
|
+
const code = hostCode?.[1] || pathCode?.[1];
|
|
296
|
+
if (code) url.searchParams.set("code", code);
|
|
297
|
+
}
|
|
298
|
+
return url;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function validSessionUrl(value) {
|
|
302
|
+
if (typeof value !== "string") return false;
|
|
303
|
+
let url;
|
|
304
|
+
try {
|
|
305
|
+
url = new URL(value);
|
|
306
|
+
} catch {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
|
310
|
+
if (/^[a-f0-9]{20}\.letmeknow\.dev$/.test(url.hostname)) return true;
|
|
311
|
+
return /^\/s\/[a-f0-9]{20}(?:\/|$)/.test(url.pathname);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function start(args) {
|
|
315
|
+
const { root } = options(args);
|
|
316
|
+
const control = process.env.LETMEKNOW_URL || "https://letmeknow.dev";
|
|
317
|
+
let send = () => false;
|
|
318
|
+
const vite = await createServer({
|
|
319
|
+
root,
|
|
320
|
+
configFile: false,
|
|
321
|
+
appType: "spa",
|
|
322
|
+
css: { postcss: false },
|
|
323
|
+
logLevel: "silent",
|
|
324
|
+
server: { middlewareMode: true, hmr: false, ws: false, fs: { strict: true, allow: [root], deny: ["**/.env", "**/.env.*", "**/.git/**", "**/*.key", "**/*.pem", "**/*.p12", "**/*.sqlite", "**/*.db"] } },
|
|
325
|
+
plugins: [{
|
|
326
|
+
name: "letmeknow-relay",
|
|
327
|
+
resolveId(id) { return id === clientPath ? clientId : undefined; },
|
|
328
|
+
load(id) { return id === clientId ? client : undefined; },
|
|
329
|
+
configureServer(server) {
|
|
330
|
+
server.middlewares.use((request, response, next) => {
|
|
331
|
+
if (request.headers["x-letmeknow-submission"] !== "1") {
|
|
332
|
+
next();
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
submission(request, response).catch(cause => {
|
|
336
|
+
response.statusCode = cause instanceof Error && cause.message === "submission is too large" ? 413 : 400;
|
|
337
|
+
response.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
338
|
+
response.end(cause instanceof Error ? cause.message : "invalid submission");
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
const update = file => send({ type: "file_update", path: "/" + relative(root, file).split(sep).join("/") });
|
|
342
|
+
server.watcher.on("change", update);
|
|
343
|
+
server.watcher.on("add", update);
|
|
344
|
+
server.watcher.on("unlink", update);
|
|
345
|
+
},
|
|
346
|
+
transformIndexHtml(html) {
|
|
347
|
+
const script = `<script type="module" src="${clientPath}" data-letmeknow-client></script>`;
|
|
348
|
+
return html.includes("</body>") ? html.replace("</body>", `${script}</body>`) : `${html}${script}`;
|
|
349
|
+
}
|
|
350
|
+
}]
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
let socket;
|
|
354
|
+
let credential;
|
|
355
|
+
let sessionUrl;
|
|
356
|
+
let retryTimer;
|
|
357
|
+
let connectionTimer;
|
|
358
|
+
let retryDelay = 100;
|
|
359
|
+
let retryUntil = 0;
|
|
360
|
+
let stopped = false;
|
|
361
|
+
let ready = false;
|
|
362
|
+
const stop = async code => {
|
|
363
|
+
if (stopped) return;
|
|
364
|
+
stopped = true;
|
|
365
|
+
clearTimeout(retryTimer);
|
|
366
|
+
clearTimeout(connectionTimer);
|
|
367
|
+
try { socket?.close(); } catch {}
|
|
368
|
+
await vite.close();
|
|
369
|
+
process.exit(code);
|
|
370
|
+
};
|
|
371
|
+
process.once("SIGINT", () => void stop(0));
|
|
372
|
+
process.once("SIGTERM", () => void stop(0));
|
|
373
|
+
|
|
374
|
+
const retry = () => {
|
|
375
|
+
if (stopped || Date.now() >= retryUntil) return void stop(1);
|
|
376
|
+
retryTimer = setTimeout(() => {
|
|
377
|
+
retryTimer = undefined;
|
|
378
|
+
connect();
|
|
379
|
+
}, retryDelay);
|
|
380
|
+
retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY);
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
const connect = () => {
|
|
384
|
+
if (stopped) return;
|
|
385
|
+
const reconnecting = Boolean(credential && sessionUrl);
|
|
386
|
+
const current = socket = reconnecting ? new WebSocket(endpoint(control, credential, sessionUrl), credential) : new WebSocket(endpoint(control));
|
|
387
|
+
connectionTimer = setTimeout(() => {
|
|
388
|
+
if (socket !== current || current.readyState === WebSocket.OPEN || stopped) return;
|
|
389
|
+
try { current.close(); } catch {}
|
|
390
|
+
if (reconnecting) retry(); else void stop(1);
|
|
391
|
+
}, CONNECTION_TIMEOUT);
|
|
392
|
+
current.addEventListener("open", () => {
|
|
393
|
+
if (socket !== current || stopped) return;
|
|
394
|
+
clearTimeout(connectionTimer);
|
|
395
|
+
retryDelay = 100;
|
|
396
|
+
if (reconnecting) retryUntil = 0;
|
|
397
|
+
send = packet => {
|
|
398
|
+
if (current.readyState !== WebSocket.OPEN) return false;
|
|
399
|
+
try { current.send(JSON.stringify(packet)); return true; } catch { return false; }
|
|
400
|
+
};
|
|
401
|
+
if (!reconnecting) send({ type: "open", mode: "proxy" });
|
|
402
|
+
});
|
|
403
|
+
current.addEventListener("message", event => {
|
|
404
|
+
if (typeof event.data !== "string") return;
|
|
405
|
+
let packet;
|
|
406
|
+
try { packet = JSON.parse(event.data); } catch { return; }
|
|
407
|
+
if (packet.type === "credential") {
|
|
408
|
+
if (typeof packet.credential !== "string" || !credentialPattern.test(packet.credential)) return void stop(1);
|
|
409
|
+
credential = packet.credential;
|
|
410
|
+
} else if (packet.type === "session") {
|
|
411
|
+
if (!validSessionUrl(packet.url)) return void stop(1);
|
|
412
|
+
sessionUrl = packet.url;
|
|
413
|
+
retryDelay = 100;
|
|
414
|
+
if (!ready) {
|
|
415
|
+
ready = true;
|
|
416
|
+
process.stdout.write(`${JSON.stringify({ type: "ready", url: sessionUrl })}\n`);
|
|
417
|
+
}
|
|
418
|
+
} else if (packet.type === "http_request") {
|
|
419
|
+
void handleRequest(vite, packet, response => send(response)).catch(() => send({ type: "http_response", request_id: packet.request_id, status: 500, headers: { "Content-Type": "text/plain; charset=utf-8" }, body: Buffer.from("preview request failed").toString("base64") }));
|
|
420
|
+
} else if (packet.type === "closed") {
|
|
421
|
+
void stop(0);
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
current.addEventListener("error", () => {});
|
|
425
|
+
current.addEventListener("close", () => {
|
|
426
|
+
if (socket !== current || stopped) return;
|
|
427
|
+
clearTimeout(connectionTimer);
|
|
428
|
+
send = () => false;
|
|
429
|
+
socket = undefined;
|
|
430
|
+
if (!credential || !sessionUrl) return void stop(1);
|
|
431
|
+
if (!retryUntil) retryUntil = Date.now() + GRACE_SECONDS * 1_000;
|
|
432
|
+
retry();
|
|
433
|
+
});
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
connect();
|
|
437
|
+
await new Promise(() => {});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
await start(process.argv.slice(2));
|