letmeknow-cli 0.4.2 → 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.
Files changed (4) hide show
  1. package/README.md +32 -131
  2. package/SKILL.md +46 -118
  3. package/bin/letmeknow.js +412 -251
  4. package/package.json +6 -3
package/README.md CHANGED
@@ -1,175 +1,76 @@
1
1
  # LetMeKnow
2
2
 
3
- LetMeKnow gives one agent and one human browser a temporary HTML and CSS workspace. The agent renders a page, receives normalized form and button actions, and responds with HTML fragments. A private browser WebSocket carries renders and actions; assets use ordinary HTTP.
3
+ LetMeKnow gives an agent-managed folder a public, live Vite preview. The CLI runs Vite in middleware mode and makes only an outbound WebSocket connection to the relay; it does not listen on a network port.
4
4
 
5
- It is not a localhost proxy or a programmable frontend. Agents provide presentation and semantic actions, not JavaScript or HTTP handlers.
5
+ ## Start
6
6
 
7
- ## CLI
8
-
9
- Node 22 or newer is required.
10
-
11
- ```bash
12
- npx letmeknow-cli
13
- ```
14
-
15
- The deployed service is used by default. Set `LETMEKNOW_URL` for local development:
7
+ Node.js 22.12 or newer is required.
16
8
 
17
9
  ```bash
18
- LETMEKNOW_URL=http://localhost:8787 npx letmeknow-cli
19
- ```
20
-
21
- stdin contains one compact JSON command per line. stdout contains one JSON event per line. Diagnostics go to stderr.
22
-
23
- ```bash
24
- npx letmeknow-cli --skill
25
- ```
26
-
27
- prints the agent instructions without connecting.
28
-
29
- ## Example
30
-
31
- Open a session:
32
-
33
- ```json
34
- {"type":"open","id":"open-1"}
10
+ npx letmeknow-cli ./workspace
35
11
  ```
36
12
 
37
- The CLI emits its temporary URL:
13
+ The CLI prints JSON lines to stdout. The first line contains the public preview URL:
38
14
 
39
15
  ```json
40
- {"type":"session","id":"open-1","url":"https://0123456789abcdef0123.letmeknow.dev/","expires_after_disconnect":600}
16
+ {"type":"ready","url":"https://0123456789abcdef0123.letmeknow.dev/"}
41
17
  ```
42
18
 
43
- The URL serves a trusted shell immediately. Send HTML and optional CSS with `render`:
19
+ Open that URL in one browser. The directory defaults to the current working directory. Set `LETMEKNOW_URL` to use another compatible relay:
44
20
 
45
- ```json
46
- {"type":"render","id":"render-1","body":"<h1>Search invoices</h1><form id=\"search\" action=\"search\" method=\"post\" data-lmk-target=\"results\"><label>Customer<input name=\"customer\" required></label><button>Search</button></form><section id=\"results\"><p>Enter a customer.</p></section>","css":"#results { margin-top: 2rem; }"}
47
- ```
48
-
49
- A connected browser receives the render immediately. The acknowledgement contains its revision:
50
-
51
- ```json
52
- {"type":"ack","id":"render-1","render_id":"b87438c2-4f1c-44bd-9875-6cc64370b8aa"}
53
- ```
54
-
55
- Submitting the form produces one normalized action:
56
-
57
- ```json
58
- {"type":"action","id":"2ee81a6b-1035-40a7-a90d-c1e02f426baa","render_id":"b87438c2-4f1c-44bd-9875-6cc64370b8aa","action_id":"search","form_id":"search","target_id":"results","trigger":{"id":null,"name":null,"value":null},"values":{"customer":"Acme Ltd"}}
21
+ ```bash
22
+ LETMEKNOW_URL=https://letmeknow.dev npx letmeknow-cli ./workspace
59
23
  ```
60
24
 
61
- Respond to that ID with HTML for the target's contents:
25
+ There are no `--host` or `--port` options because the CLI intentionally has no listening network socket. Diagnostics go to stderr. `--skill` prints the agent instructions without starting a session.
62
26
 
63
- ```json
64
- {"type":"response","id":"response-1","request_id":"2ee81a6b-1035-40a7-a90d-c1e02f426baa","body":"<table><tr><th>Invoice</th><th>Amount</th></tr><tr><td>INV-42</td><td>$800</td></tr></table>"}
65
- ```
66
-
67
- The browser inserts the fragment into `#results`. Without `data-lmk-target`, the response replaces the whole workspace.
27
+ ## File workflow
68
28
 
69
- ## HTML actions
29
+ The CLI does not receive file commands. The agent reads and writes the directory directly. Keep a normal Vite entry point such as `index.html`; JavaScript, CSS, images, and other Vite-supported files can be requested through the relay.
70
30
 
71
- ### Forms
31
+ When a watched file changes, the browser receives an update. HTML changes replace the current document body without a page reload and preserve form values, focus, selection, and scroll position. CSS links are refreshed without navigating. Changes to a different HTML route do not disturb the current page.
72
32
 
73
- Interactive forms use standard HTML:
33
+ An optional element can display submission status:
74
34
 
75
35
  ```html
76
- <form id="decision" action="decide" method="post">
77
- <label>Comment<textarea name="comment"></textarea></label>
78
- <button name="decision" value="approve" formaction="approve">Approve</button>
79
- <button name="decision" value="reject" formaction="reject">Reject</button>
80
- </form>
36
+ <p data-letmeknow-status aria-live="polite"></p>
81
37
  ```
82
38
 
83
- Forms require `method="post"`, a stable `id`, a relative action identifier, and meaningful control names. A submit button's `formaction` overrides the form's `action`. Native validation runs locally. `FormData(form, submitter)` is captured before controls are disabled, so selected controls and the clicked button are included. Repeated names become string arrays. File inputs are rejected.
39
+ ## Form submissions
84
40
 
85
- ### Standalone actions
86
-
87
- A button can invoke an action without a form:
41
+ Forms are submitted without navigation. GET and POST forms are sent through the relay to the CLI, which prints each submission as one JSON line on stdout. The agent can read that line and edit the folder in response.
88
42
 
89
43
  ```html
90
- <button type="button" data-lmk-action="refresh-status" data-lmk-target="status">Refresh</button>
44
+ <form id="decision" action="/decide" method="post">
45
+ <label>Comment <textarea name="comment"></textarea></label>
46
+ <button name="decision" value="approve">Approve</button>
47
+ <button name="decision" value="reject">Reject</button>
48
+ </form>
91
49
  ```
92
50
 
93
- Its event has `form_id: null`, empty `values`, and its optional `id`, `name`, and `value` in `trigger`.
94
-
95
- ### Targeted updates
96
-
97
- `data-lmk-target` accepts one bare element ID. All updates use `innerHTML`. There are no alternate swap modes or selector targets. The default target is `lmk-view`, the whole workspace.
98
-
99
- Typing, focusing, expanding `<details>`, validation, scrolling, and other local browser behavior produce no agent events.
100
-
101
- ## CSS
102
-
103
- `render` accepts page CSS in its `css` field. A `response` may include `css` to replace it; omitting `css` preserves it.
104
-
105
- Modern CSS is supported, including grid, flexbox, media queries, variables, transitions, and print styles. External stylesheets, `@import`, scripts, inline handlers, and inline `style` attributes are blocked. Local assets work in HTML and CSS. The built-in style is deliberately document-like and avoids decorative cards, gradients, shadows, and rounded controls.
106
-
107
- ## One browser client
108
-
109
- At most one browser is active for a session. The first browser receives a private credential stored in that tab's `sessionStorage`.
110
-
111
- - Reloads and reconnects reuse the credential.
112
- - A competing browser triggers at most one liveness probe every five seconds.
113
- - If the active browser answers, the claimant sees “This session is open elsewhere.”
114
- - After a disconnect, the previous credential has a five-second exclusive reconnect period.
115
- - After five seconds, either the old browser or a new claimant may connect; first connection wins.
116
- - Laptop sleep does not invalidate the credential.
117
-
118
- The server sends a connecting browser one canonical snapshot of the current HTML, CSS, render ID, and pending actions. It does not replay user actions. The browser restores same-tab drafts from `sessionStorage`; drafts do not transfer to another browser.
119
-
120
- The server keeps committed page state and assets for the producer session. A full `render` replaces the page and clears old pending actions. A targeted response updates the canonical page. Browser disconnect alone does not delete state.
121
-
122
- ## Assets
123
-
124
- `put` stores or replaces passive resources only under `/assets/`:
51
+ Submitting `Approve` prints an event like:
125
52
 
126
53
  ```json
127
- {"type":"put","id":"logo","path":"/assets/logo.png","content_type":"image/png","encoding":"base64","body":"iVBORw0KGgo..."}
54
+ {"type":"submit","id":"","method":"POST","action":"/decide","form_id":"decision","trigger":{"id":null,"name":"decision","value":"approve"},"values":{"comment":"Looks good","decision":"approve"}}
128
55
  ```
129
56
 
130
- Reference them relatively:
57
+ Repeated field names become arrays. Native browser validation still runs before a submission is sent. File inputs and cross-origin form actions are not supported. Forms can use a submitter's standard `formaction`, `formmethod`, and `name`/`value` attributes.
131
58
 
132
- ```html
133
- <img src="assets/logo.png" alt="Company logo">
134
- ```
135
-
136
- Assets answer only `GET` and `HEAD`. Each body is limited to 1 MiB. A session accepts 100 assets and 10 MiB decoded asset data. There is no `delete`; assets disappear with the session.
137
-
138
- ## Protocol
139
-
140
- Commands:
141
-
142
- - `open`: create the session; it must be first.
143
- - `render`: replace the committed HTML and CSS and push it to the browser.
144
- - `put`: store an asset under `/assets/`.
145
- - `response`: resolve one pending action with HTML and optional CSS.
146
- - `close`: destroy the session immediately.
147
-
148
- Events:
59
+ The event ID identifies the submission. It is not a request/response handle: update the files and let the live preview show the result.
149
60
 
150
- - `session`: public URL and producer disconnect grace.
151
- - `action`: normalized form or standalone-button action.
152
- - `ack`: command completion.
153
- - `error`: invalid command or protocol state.
154
- - `closing`: explicit session destruction.
61
+ ## Security
155
62
 
156
- Actions remain pending until `response`, a superseding full `render`, or session close. One form or overlapping target can be pending at a time; independent regions may proceed concurrently. Match responses by action ID.
63
+ The preview URL is a bearer capability. The relay receives the served files and submitted values. Do not put secrets in the preview folder or submit credentials unless that is intentional. The folder is trusted executable code from the browser's perspective.
157
64
 
158
- HTML, CSS, asset, and action bodies are each limited to 1 MiB. Up to 32 actions may be pending.
159
-
160
- ## Security and lifecycle
161
-
162
- HTML fragments are sanitized. Scripts, style elements, inline handlers, HTMX attributes, frames, active metadata, external form actions, and invalid LetMeKnow attributes are removed. CSP blocks arbitrary browser connections and external resources.
163
-
164
- The URL is a bearer secret. Share it only with the intended human. Browser and producer reconnect credentials remain private and never appear in protocol output.
165
-
166
- If the producer disconnects, the current page remains visible, drafts are preserved, and actions are disabled. The CLI can reconnect for ten minutes. Browser traffic does not extend that grace. `close` or producer-grace expiry deletes page state, credentials, pending actions, and assets.
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.
167
66
 
168
67
  ## Development
169
68
 
170
69
  ```bash
171
70
  npm install
172
- npm run dev
173
71
  npm test
72
+ npm run dev
174
73
  npm run deploy
175
74
  ```
75
+
76
+ `npm run dev` and `npm run deploy` operate the Cloudflare relay.
package/SKILL.md CHANGED
@@ -1,166 +1,94 @@
1
1
  ---
2
2
  name: letmeknow
3
- description: Show one human a temporary HTML and CSS workspace and handle normalized form or button actions through the LetMeKnow NDJSON CLI.
3
+ description: Give a human a live public preview of an agent-managed folder and receive browser form submissions as JSON lines.
4
4
  ---
5
5
 
6
6
  # LetMeKnow
7
7
 
8
- Use LetMeKnow when one human needs a temporary rich document, dashboard, report, preview, form, approval, quiz, table, or status view. The agent supplies semantic HTML and CSS. The browser sends only declared actions; typing and local UI state do not create events.
8
+ Use LetMeKnow when one human needs to inspect or interact with a temporary page, report, dashboard, approval form, quiz, table, or status view. The agent owns the files in a folder. The CLI runs Vite in middleware mode, connects to the public relay over an outbound WebSocket, and reports browser submissions on stdout.
9
9
 
10
- LetMeKnow is not a localhost proxy, persistent application, or arbitrary JavaScript environment.
10
+ The CLI does **not** listen on a local network port.
11
11
 
12
12
  ## Start
13
13
 
14
- Node.js 22 or newer is required.
14
+ Run the CLI as a long-lived child process with the folder you will edit:
15
15
 
16
16
  ```bash
17
- npx letmeknow-cli
17
+ npx letmeknow-cli ./workspace
18
18
  ```
19
19
 
20
- Run it as a long-lived child process. Write one compact JSON object per line to stdin, keep stdin open, and read one JSON event per line from stdout. Read stderr separately.
20
+ Node.js 22.12 or newer is required. Read stdout and stderr separately. Stdout is JSONL; the first event is:
21
21
 
22
22
  ```json
23
- {"type":"open","id":"open-1"}
23
+ {"type":"ready","url":"https://0123456789abcdef0123.letmeknow.dev/"}
24
24
  ```
25
25
 
26
- Wait for the session URL:
26
+ Open the public URL for the human. The directory defaults to the current working directory. Set `LETMEKNOW_URL` to use another compatible relay. There are no host or port options: the CLI intentionally has no listening socket.
27
27
 
28
- ```json
29
- {"type":"session","id":"open-1","url":"https://0123456789abcdef0123.letmeknow.dev/","expires_after_disconnect":600}
30
- ```
31
-
32
- The URL immediately serves a shell with a waiting message. One browser may use it at a time.
33
-
34
- ## Render HTML and CSS
35
-
36
- ```json
37
- {"type":"render","id":"render-1","body":"<h1>Search invoices</h1><form id=\"search\" action=\"search\" method=\"post\" data-lmk-target=\"results\"><label>Customer<input name=\"customer\" required></label><button>Search</button></form><section id=\"results\"><p>Enter a customer.</p></section>","css":"#results { margin-top: 2rem; }"}
38
- ```
39
-
40
- A connected browser receives the render immediately. Wait for its acknowledgement and retain the `render_id`:
41
-
42
- ```json
43
- {"type":"ack","id":"render-1","render_id":"b87438c2-4f1c-44bd-9875-6cc64370b8aa"}
44
- ```
45
-
46
- Use semantic HTML: headings, sections, paragraphs, lists, tables, `dl`, forms, labels, controls, buttons, `details`, progress, and images. Do not include `<html>`, `<head>`, `<body>`, `<main id="lmk-view">`, scripts, style elements, inline handlers, inline `style`, iframes, or HTMX attributes.
28
+ Do not send file commands to stdin. Read and write the folder directly. Keep the process running while the human uses the page.
47
29
 
48
- The optional `css` field supports modern CSS, including grid, flexbox, media queries, variables, transitions, and print styles. External stylesheets, `@import`, and remote resources do not work. Use relative session assets.
30
+ ## Build the page
49
31
 
50
- Unless the human asks for a visual treatment, keep the page document-like: plain backgrounds, restrained typography, square corners, and simple borders. Avoid gradients, shadows, pill badges, rounded card grids, and decorative dashboard styling by default.
32
+ Create an ordinary Vite page in the folder, usually `index.html`, plus any CSS, JavaScript, images, or other assets it needs. Use semantic HTML and accessible labels, headings, sections, tables, and controls.
51
33
 
52
- A full `render` intentionally replaces the current page, resets drafts, and cancels actions from the previous render. Use `response` rather than `render` after a user action.
34
+ The relay is live:
53
35
 
54
- ## Forms
36
+ - HTML changes update the current document body without a full page reload.
37
+ - Existing form values, focus, text selection, and scroll position are restored after an HTML update.
38
+ - CSS links are refreshed without navigating.
39
+ - Changes to another HTML route do not replace the current route.
55
40
 
56
- Interactive forms use standard HTML:
41
+ An optional status element gives the human feedback after a form submission:
57
42
 
58
43
  ```html
59
- <form id="decision" action="decide" method="post">
60
- <label>Reason<textarea name="reason" required></textarea></label>
61
- <button name="decision" value="approve" formaction="approve">Approve</button>
62
- <button name="decision" value="reject" formaction="reject">Reject</button>
63
- </form>
64
- ```
65
-
66
- Rules:
67
-
68
- - Use `method="post"`.
69
- - Give each form a stable, unique `id`.
70
- - Give controls meaningful `name` values.
71
- - Use an action identifier containing letters, digits, `.`, `_`, `:`, or `-`.
72
- - A submit button's standard `formaction` may override the form action.
73
- - Native `required`, input types, ranges, and patterns validate locally.
74
-
75
- The runtime captures `FormData(form, submitter)` before disabling controls. Selected radios, checked boxes, ordinary controls, and the clicked submit button are included. Repeated names become string arrays. File inputs are rejected.
76
-
77
- ## Standalone actions
78
-
79
- ```html
80
- <button type="button" data-lmk-action="refresh-status" data-lmk-target="status">
81
- Refresh
82
- </button>
44
+ <p data-letmeknow-status aria-live="polite"></p>
83
45
  ```
84
46
 
85
- Use standalone actions for refresh, retry, cancel, generate, inspect, load-more, and export. Their events have `form_id: null`, empty `values`, and the button's optional `id`, `name`, and `value` in `trigger`.
86
-
87
- ## Whole and partial updates
47
+ ## Receive form submissions
88
48
 
89
- Responses replace the whole workspace by default. To update a region, put `data-lmk-target="element-id"` on the form or action button:
49
+ GET and POST forms are intercepted before navigation and sent through the relay to the CLI. Read stdout for a `submit` event:
90
50
 
91
51
  ```html
92
- <form id="search" action="search" method="post" data-lmk-target="results">
93
- <input name="query">
94
- <button>Search</button>
52
+ <form id="search" action="/search" method="post">
53
+ <label>Query <input name="query" required></label>
54
+ <button name="scope" value="all">Search all</button>
95
55
  </form>
96
- <section id="results"></section>
97
- ```
98
-
99
- The target is one bare element ID without `#`. Every update uses `innerHTML`. A submit button may override its form's target.
100
-
101
- ## Handle actions
102
-
103
- ```json
104
- {"type":"action","id":"2ee81a6b-1035-40a7-a90d-c1e02f426baa","render_id":"b87438c2-4f1c-44bd-9875-6cc64370b8aa","action_id":"search","form_id":"search","target_id":"results","trigger":{"id":null,"name":null,"value":null},"values":{"query":"quarterly report"}}
105
- ```
106
-
107
- Use:
108
-
109
- - `id` to respond to this exact action.
110
- - `render_id` to identify the page revision that produced it.
111
- - `action_id` for user intent.
112
- - `form_id` and `target_id` for context.
113
- - `trigger` for the clicked button.
114
- - `values` for the submitted form snapshot.
115
-
116
- Validate actions and values. Treat values as untrusted and HTML-escape reflected text.
117
-
118
- Respond with HTML for the target contents:
119
-
120
- ```json
121
- {"type":"response","id":"response-1","request_id":"2ee81a6b-1035-40a7-a90d-c1e02f426baa","body":"<table><tr><th>Invoice</th><th>Amount</th></tr><tr><td>INV-42</td><td>$800</td></tr></table>"}
122
56
  ```
123
57
 
124
- A response may include `css` to replace the page CSS. Omitting it preserves the existing CSS:
58
+ The event is:
125
59
 
126
60
  ```json
127
- {"type":"response","id":"response-2","request_id":"event-2","body":"<h1 class=\"success\">Approved</h1>","css":".success { color: green; }"}
61
+ {"type":"submit","id":"","method":"POST","action":"/search","form_id":"search","trigger":{"id":null,"name":"scope","value":"all"},"values":{"query":"quarterly report","scope":"all"}}
128
62
  ```
129
63
 
130
- Wait for the acknowledgement and its new `render_id`. An action remains pending until `response`, a superseding full `render`, or session close. Independent regions may be pending concurrently, so always match by action ID.
131
-
132
- ## One-browser behavior
133
-
134
- The first browser claims the session with a private credential. Reload and laptop wake reconnect automatically. A second browser cannot connect while the first is active.
135
-
136
- Liveness is checked only when another browser tries to claim, at most once every five seconds. After a browser disconnect, its credential has five seconds of exclusive reconnect priority. After that, the first old or new browser to connect wins.
137
-
138
- A reconnect receives one canonical snapshot of committed HTML, CSS, render ID, and pending actions. It does not replay old user actions. Same-tab drafts are restored from `sessionStorage`; drafts do not transfer during takeover.
139
-
140
- ## Assets
64
+ Rules:
141
65
 
142
- ```json
143
- {"type":"put","id":"logo","path":"/assets/logo.png","content_type":"image/png","encoding":"base64","body":"iVBORw0KGgo..."}
144
- ```
66
+ - Give interactive forms a stable, meaningful `id`.
67
+ - Give controls meaningful `name` values.
68
+ - Use normal relative or same-origin actions.
69
+ - Use `formaction` and `formmethod` on submitters when different buttons have different intents.
70
+ - Native `required`, input types, ranges, and patterns validate in the browser before the event is sent.
71
+ - Repeated names become string arrays.
72
+ - File inputs and cross-origin actions are not supported.
145
73
 
146
- Store resources only under `/assets/` and reference them relatively:
74
+ The event ID identifies the submission. There is no response packet. Validate its values, edit the files, and let the live preview show the result. Do not write JSON commands to stdin.
147
75
 
148
- ```html
149
- <img src="assets/logo.png" alt="Company logo">
150
- ```
76
+ ## Example response workflow
151
77
 
152
- `encoding` is `utf8` by default or `base64` for binary data. Assets answer only `GET` and `HEAD`. There is no `delete`; another `put` replaces an asset.
78
+ 1. Render the initial state in `index.html`.
79
+ 2. Wait for a `submit` event on stdout.
80
+ 3. Validate its `values` and `action`.
81
+ 4. Rewrite the relevant HTML or data file in the workspace.
82
+ 5. The browser updates in place through the relay.
153
83
 
154
- ## Close and lifecycle
84
+ Escape untrusted values before placing them in HTML. Treat browser input as untrusted even though the folder is local to the agent.
155
85
 
156
- ```json
157
- {"type":"close","id":"close-1"}
158
- ```
86
+ ## Security
159
87
 
160
- Wait for `ack` and `closing`. Closing stdin only disconnects the producer.
88
+ The public URL is a bearer capability. The relay receives the served files and submitted values. Do not put secrets in the preview folder or submit credentials unless that is intentional. The folder is trusted executable code from the browser's perspective.
161
89
 
162
- If the producer disconnects, the page remains visible, drafts remain, and actions are disabled. The CLI has ten minutes to reconnect. Browser traffic does not extend that period. Explicit close or expiry deletes HTML, CSS, credentials, actions, and assets.
90
+ The CLI disables Vite config discovery and limits filesystem access to the selected folder. It makes outbound relay connections only; it does not accept inbound browser connections.
163
91
 
164
- Limits: 1 MiB per HTML, CSS, asset, or action body; 100 assets; 10 MiB decoded asset storage; and 32 pending actions.
92
+ ## Stop
165
93
 
166
- Treat the URL as a bearer secret. Never expose either private reconnect credential.
94
+ Send `SIGINT` or `SIGTERM` to stop the CLI. The relay session expires after producer disconnect. `--skill` prints these instructions without starting a session.
package/bin/letmeknow.js CHANGED
@@ -1,299 +1,460 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { readFileSync, writeSync } from "node:fs";
4
- import readline from "node:readline";
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";
5
7
 
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]+$/;
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);
20
19
  let socket;
21
- let input;
22
- let credential;
23
- let sessionUrl;
24
20
  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
- }
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;
51
31
  }
52
- return url;
53
- }
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
+ `;
54
99
 
55
- function clearConnectionTimer() {
56
- if (connectionTimer) clearTimeout(connectionTimer);
57
- connectionTimer = undefined;
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
+ }
58
108
  }
59
109
 
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;
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
+ }
67
116
  }
68
117
 
69
- function validCredential(value) {
70
- return typeof value === "string" && subprotocolTokenPattern.test(value);
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
+ });
71
138
  }
72
139
 
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;
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");
80
153
  }
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);
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();
84
171
  }
85
172
 
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;
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;
96
186
  }
97
- }
98
187
 
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;
188
+ _read() {
189
+ if (this.sent) return;
190
+ this.sent = true;
191
+ this.push(this.body);
192
+ this.push(null);
112
193
  }
113
194
  }
114
195
 
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();
196
+ class RelayResponse extends Writable {
197
+ constructor() {
198
+ super();
199
+ this.statusCode = 200;
200
+ this.headers = new Map();
201
+ this.chunks = [];
126
202
  }
127
- if (closeCommandSent) queued.length = 0;
128
- if (!queued.length && stdinClosed && !closeCommandSent && socket && connected) {
129
- socket.close(1000, "stdin closed");
203
+
204
+ setHeader(name, value) {
205
+ this.headers.set(name.toLowerCase(), Array.isArray(value) ? value.join(", ") : String(value));
206
+ return this;
130
207
  }
131
- }
132
208
 
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
- }
209
+ getHeader(name) {
210
+ return this.headers.get(name.toLowerCase());
143
211
  }
144
- finish(1);
145
- }
146
212
 
147
- function handleMessage(event) {
148
- if (finished) return;
149
- if (typeof event.data !== "string") {
150
- protocolFailure("binary WebSocket frame");
151
- return;
213
+ getHeaders() {
214
+ return Object.fromEntries(this.headers);
152
215
  }
153
- const text = event.data;
154
- let packet;
155
- try {
156
- packet = JSON.parse(text);
157
- } catch {
158
- protocolFailure("invalid JSON");
159
- return;
216
+
217
+ hasHeader(name) {
218
+ return this.headers.has(name.toLowerCase());
160
219
  }
161
- if (!packet || typeof packet !== "object" || Array.isArray(packet)) {
162
- protocolFailure("packet must be a JSON object");
163
- return;
220
+
221
+ removeHeader(name) {
222
+ this.headers.delete(name.toLowerCase());
164
223
  }
165
- if (typeof packet.type !== "string") {
166
- protocolFailure("packet type is required");
167
- return;
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;
168
229
  }
169
- if (packet.type === "credential") {
170
- if (!validCredential(packet.credential)) {
171
- protocolFailure("invalid credential");
172
- return;
173
- }
174
- credential = packet.credential;
175
- return;
230
+
231
+ flushHeaders() {}
232
+
233
+ _write(chunk, _encoding, callback) {
234
+ this.chunks.push(Buffer.from(chunk));
235
+ callback();
176
236
  }
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;
237
+
238
+ body() {
239
+ return Buffer.concat(this.chunks);
190
240
  }
191
- if (packet.type === "closing") explicitSessionClosed = true;
192
- process.stdout.write(`${text}\n`);
193
241
  }
194
242
 
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);
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
+ };
205
253
  }
206
254
 
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");
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);
217
262
  try {
218
- current.close();
219
- } catch {
220
- // The close event is not available when construction failed.
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);
221
271
  }
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
272
  });
273
+ send(middlewareResponse(response));
257
274
  }
258
275
 
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;
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);
269
282
  }
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);
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);
281
300
  }
282
- });
301
+ return url;
302
+ }
283
303
 
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.
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}`;
293
352
  }
294
- }
295
- finish(0);
353
+ }]
296
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(() => {});
297
443
  }
298
444
 
299
- start();
445
+ if (process.argv[2] === "--skill") {
446
+ if (process.argv.length !== 3) {
447
+ process.stderr.write("Usage: npx letmeknow-cli --skill\n");
448
+ process.exit(1);
449
+ }
450
+ writeSync(1, readFileSync(new URL("../SKILL.md", import.meta.url)));
451
+ } else if (process.argv.slice(2).includes("--help") || process.argv.slice(2).includes("-h")) {
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");
453
+ } else {
454
+ try {
455
+ await start(process.argv.slice(2));
456
+ } catch (cause) {
457
+ process.stderr.write(`letmeknow: ${cause instanceof Error ? cause.message : "server failed"}\n`);
458
+ process.exitCode = 1;
459
+ }
460
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "letmeknow-cli",
3
- "version": "0.4.2",
4
- "description": "A temporary interactive web surface for agents.",
3
+ "version": "0.4.4",
4
+ "description": "A live Vite preview with agent-readable form submissions.",
5
5
  "files": [
6
6
  "bin",
7
7
  "SKILL.md"
@@ -11,7 +11,7 @@
11
11
  "letmeknow": "bin/letmeknow.js"
12
12
  },
13
13
  "engines": {
14
- "node": ">=22"
14
+ "node": ">=22.12.0"
15
15
  },
16
16
  "scripts": {
17
17
  "dev": "wrangler dev",
@@ -27,5 +27,8 @@
27
27
  "vitest": "^4.1.11",
28
28
  "wrangler": "^4.126.0",
29
29
  "ws": "^8.21.3"
30
+ },
31
+ "dependencies": {
32
+ "vite": "^7.3.6"
30
33
  }
31
34
  }