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