letmeknow-cli 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -69
- package/SKILL.md +156 -39
- package/bin/letmeknow.js +271 -199
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -1,98 +1,53 @@
|
|
|
1
1
|
# LetMeKnow
|
|
2
2
|
|
|
3
|
-
LetMeKnow gives an agent a temporary public browser surface
|
|
3
|
+
LetMeKnow gives an agent a temporary public browser surface for structured human feedback. The agent serves ordinary HTML and static assets, receives browser submissions, and can push ordered HTML replacements.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Quick start
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Create a directory with an `index.html`, then run:
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
npx letmeknow-cli serve ./preview
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
The
|
|
13
|
+
The CLI prints one JSON line containing the public bearer URL:
|
|
14
14
|
|
|
15
15
|
```json
|
|
16
|
-
{"type":"ready","url":"https://0123456789abcdef0123.letmeknow.dev/","
|
|
16
|
+
{"type":"ready","url":"https://0123456789abcdef0123.letmeknow.dev/","page_event":0,"page_hash":"…"}
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
Give the URL to the human. Anyone with the URL can view the page and submit its forms.
|
|
20
20
|
|
|
21
|
-
##
|
|
21
|
+
## Commands
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
`pull` returns pending browser interactions, the current workspace, and an opaque batch token:
|
|
31
|
-
|
|
32
|
-
```json
|
|
33
|
-
{
|
|
34
|
-
"ok": true,
|
|
35
|
-
"type": "batch",
|
|
36
|
-
"token": "…",
|
|
37
|
-
"workspace": "…",
|
|
38
|
-
"workspace_sequence": 1,
|
|
39
|
-
"frontier": 1,
|
|
40
|
-
"events": [
|
|
41
|
-
{
|
|
42
|
-
"type": "submit",
|
|
43
|
-
"id": "…",
|
|
44
|
-
"form_id": "decision",
|
|
45
|
-
"values": {"decision":"approve"},
|
|
46
|
-
"based_on": "…",
|
|
47
|
-
"context": {"based_on":"…","current":"…","relationship":"current"}
|
|
48
|
-
}
|
|
49
|
-
]
|
|
50
|
-
}
|
|
23
|
+
```text
|
|
24
|
+
serve <dir>
|
|
25
|
+
show <dir>
|
|
26
|
+
pull <dir> [--wait seconds]
|
|
27
|
+
push <dir> --batch TOKEN [--updates FILE|-]
|
|
51
28
|
```
|
|
52
29
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
If a batch requires no workspace change, commit it without publishing:
|
|
56
|
-
|
|
57
|
-
```bash
|
|
58
|
-
npx letmeknow-cli ack ./preview --based-on <batch-token>
|
|
59
|
-
```
|
|
30
|
+
`serve` reads `index.html` once as the canonical page. Other files are served live from the directory. `show` prints the accepted canonical HTML. `pull` returns browser events without consuming them; `push` commits a pulled batch, optionally with ordered replacements. See [`SKILL.md`](SKILL.md) for the complete workflow and protocol.
|
|
60
31
|
|
|
61
|
-
|
|
32
|
+
## Page updates
|
|
62
33
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
## Workspace behavior
|
|
66
|
-
|
|
67
|
-
A published workspace is an immutable temporary snapshot of the selected folder. It may contain HTML, CSS, JavaScript, images, data, and linked pages. The relay injects a small runtime into HTML and serves all files from the same workspace revision.
|
|
68
|
-
|
|
69
|
-
A successful push sends one revision notification. Browsers reload and preserve scroll position plus the values, checked state, and selected state of controls with stable unique IDs. Missing pages and connection-status pages remain live and recover on a later publication or reconnect.
|
|
70
|
-
|
|
71
|
-
## Forms
|
|
72
|
-
|
|
73
|
-
Use native same-origin GET or POST forms:
|
|
34
|
+
Use stable IDs as replacement boundaries:
|
|
74
35
|
|
|
75
36
|
```html
|
|
76
|
-
<
|
|
77
|
-
<label>Comment <textarea id="comment" name="comment"></textarea></label>
|
|
78
|
-
<button name="decision" value="approve">Approve</button>
|
|
79
|
-
<button name="decision" value="reject">Reject</button>
|
|
80
|
-
</form>
|
|
37
|
+
<output id="counter">41</output>
|
|
81
38
|
```
|
|
82
39
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
POST forms may include files within the 1 MiB total request limit. `pull` events contain attachment metadata and private temporary paths. Attachments remain available until `serve` stops and are not public unless deliberately copied into the workspace and pushed.
|
|
40
|
+
```json
|
|
41
|
+
{"updates":[{"target":"counter","html":"<output id=\"counter\">42</output>"}]}
|
|
42
|
+
```
|
|
88
43
|
|
|
89
|
-
|
|
44
|
+
Each replacement must contain one element with the target's ID and cannot contain scripts. Replacements in one push are applied in order and atomically. Replacing `letmeknow-root` is the broad page-update case and intentionally discards browser-local state inside that root.
|
|
90
45
|
|
|
91
|
-
##
|
|
46
|
+
## Session and security basics
|
|
92
47
|
|
|
93
|
-
|
|
48
|
+
Sessions are temporary and held in memory by the CLI and relay. The service may expire a session at any time. If the producer disconnects, reconnect is best-effort and may be available only for a limited period; clients should handle disconnects without assuming that reconnect will succeed.
|
|
94
49
|
|
|
95
|
-
The
|
|
50
|
+
The public URL is a bearer capability: anyone who has it can view the page and submit forms. Treat browser values as untrusted input and escape them before placing them in HTML. Keep secrets and unrelated files outside the served directory.
|
|
96
51
|
|
|
97
52
|
## Development
|
|
98
53
|
|
|
@@ -104,4 +59,4 @@ npm run dev
|
|
|
104
59
|
npm run deploy
|
|
105
60
|
```
|
|
106
61
|
|
|
107
|
-
The browser test requires Firefox and geckodriver. `npm run dev` and `npm run deploy` operate the Cloudflare relay.
|
|
62
|
+
The browser smoke test requires Firefox and geckodriver. `npm run dev` and `npm run deploy` operate the Cloudflare relay. For the detailed agent workflow, forms, event causality, reconnect behavior, and static-asset guidance, read [`SKILL.md`](SKILL.md).
|
package/SKILL.md
CHANGED
|
@@ -1,89 +1,206 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: letmeknow
|
|
3
|
-
description:
|
|
3
|
+
description: Serve a temporary live HTML page, collect structured human feedback, and push ordered HTML replacements.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# LetMeKnow
|
|
7
7
|
|
|
8
|
-
Use LetMeKnow when a human should inspect or interact with an agent-
|
|
8
|
+
Use LetMeKnow when a human should inspect or interact with an agent-authored page, report, dashboard, approval, quiz, table, or prototype.
|
|
9
9
|
|
|
10
10
|
## Start
|
|
11
11
|
|
|
12
|
-
Create a
|
|
12
|
+
Create a directory containing the public files and an initial `index.html`, then run:
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
15
|
npx letmeknow-cli serve ./preview
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
`serve` reads `index.html` once as the initial canonical dynamic page and serves it at `/`. Other files—such as CSS, JavaScript, images, and data—are served live from the directory. The first stdout JSON line contains the public bearer URL and initial page metadata:
|
|
19
19
|
|
|
20
20
|
```json
|
|
21
|
-
{"type":"ready","url":"https://0123456789abcdef0123.letmeknow.dev/","
|
|
21
|
+
{"type":"ready","url":"https://0123456789abcdef0123.letmeknow.dev/","page_event":0,"page_hash":"…"}
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
The initial page should have a stable shell and a dynamic root:
|
|
25
|
+
|
|
26
|
+
```html
|
|
27
|
+
<body>
|
|
28
|
+
<main id="letmeknow-root">
|
|
29
|
+
...
|
|
30
|
+
</main>
|
|
31
|
+
<script type="module" src="/app.js"></script>
|
|
32
|
+
</body>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The runtime is injected into the page separately. Keep agent-authored scripts and the runtime outside normal replacement targets.
|
|
36
|
+
|
|
37
|
+
## Session lifetime and reconnect
|
|
38
|
+
|
|
39
|
+
A producer must successfully send `open` shortly after connecting. Sessions are temporary and may be expired by the service at any time.
|
|
40
|
+
|
|
41
|
+
If an opened producer disconnects, reconnect is best-effort and may be available only for a limited period. A reconnect uses the existing session credential, but clients must handle disconnects without assuming that reconnect will succeed. When the service expires a session, the session and its browser connections end.
|
|
25
42
|
|
|
26
43
|
## Agent loop
|
|
27
44
|
|
|
28
|
-
|
|
45
|
+
Pull browser events, update one or more HTML fragments, and push the replacements:
|
|
29
46
|
|
|
30
47
|
```bash
|
|
31
|
-
npx letmeknow-cli pull ./preview --wait 30
|
|
32
|
-
|
|
33
|
-
|
|
48
|
+
batch=$(npx letmeknow-cli pull ./preview --wait 30)
|
|
49
|
+
token=$(printf '%s\n' "$batch" | jq -r .token)
|
|
50
|
+
# inspect events and write fragments such as counter.html and status.html
|
|
51
|
+
npx letmeknow-cli push ./preview --batch "$token" --updates updates.json
|
|
34
52
|
```
|
|
35
53
|
|
|
36
|
-
|
|
54
|
+
Commands:
|
|
37
55
|
|
|
38
|
-
|
|
56
|
+
```text
|
|
57
|
+
serve <dir>
|
|
58
|
+
show <dir>
|
|
59
|
+
pull <dir> [--wait seconds]
|
|
60
|
+
push <dir> --batch TOKEN [--updates FILE|-]
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`show` returns the current canonical page without changing the event stream:
|
|
39
64
|
|
|
40
65
|
```bash
|
|
41
|
-
npx letmeknow-cli
|
|
66
|
+
npx letmeknow-cli show ./preview > current.html
|
|
42
67
|
```
|
|
43
68
|
|
|
44
|
-
|
|
69
|
+
The public URL is the visual preview. `show` returns canonical HTML, not a browser's local focus, open disclosures, unsent input, scroll position, or JavaScript state.
|
|
45
70
|
|
|
46
|
-
|
|
71
|
+
## Push updates
|
|
47
72
|
|
|
48
|
-
|
|
73
|
+
A push accepts one JSON document:
|
|
49
74
|
|
|
50
|
-
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"updates": [
|
|
78
|
+
{"target": "counter", "file": "counter.html"},
|
|
79
|
+
{"target": "status", "html": "<output id=\"status\">Saved</output>"}
|
|
80
|
+
]
|
|
81
|
+
}
|
|
82
|
+
```
|
|
51
83
|
|
|
52
|
-
|
|
84
|
+
Each update must contain exactly one of `html` or `file`. A file supplies the replacement HTML. The normal operation is direct replacement of one element identified by its unique stable `id`:
|
|
53
85
|
|
|
54
86
|
```html
|
|
55
|
-
<
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
</
|
|
87
|
+
<output id="counter">41</output>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{"updates":[{"target":"counter","html":"<output id=\"counter\">42</output>"}]}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The replacement must contain exactly one element, and that element must have the same ID as the target. Replacement HTML cannot contain scripts. The target must exist when the replacement is applied.
|
|
95
|
+
|
|
96
|
+
Use small output regions for ordinary updates. The replacement is destructive inside its target but leaves the rest of the page alone, so a counter update does not disturb a form or button elsewhere.
|
|
97
|
+
|
|
98
|
+
The document root is just another target. To intentionally replace all dynamic content, target the root:
|
|
99
|
+
|
|
100
|
+
```html
|
|
101
|
+
<main id="letmeknow-root">
|
|
102
|
+
...
|
|
103
|
+
</main>
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
{"updates":[{"target":"letmeknow-root","file":"root.html"}]}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Replacing the root intentionally discards browser-local state inside it. Use it when that is acceptable, not for every small change.
|
|
111
|
+
|
|
112
|
+
A single push may contain many replacements. They are applied in the order listed. Later replacements may target elements introduced by earlier replacements in the same push, so introduce a target before updating it. Conversely, a replacement that removes a later target makes a following update invalid. The CLI validates the complete ordered batch before committing anything.
|
|
113
|
+
|
|
114
|
+
Each replacement becomes its own `update_ui` event with its own global event number. The complete push is still atomic: either all replacements and the pulled browser-event batch commit, or none do. Connected browsers receive committed replacements in order.
|
|
115
|
+
|
|
116
|
+
A push without `--updates` commits the pulled browser events without changing the page:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
npx letmeknow-cli push ./preview --batch "$token"
|
|
60
120
|
```
|
|
61
121
|
|
|
62
|
-
|
|
122
|
+
There is no separate acknowledgement command and no `--page` mode. Replacing `letmeknow-root` provides the broad page-update case.
|
|
63
123
|
|
|
64
|
-
|
|
124
|
+
## Event stream and page causality
|
|
125
|
+
|
|
126
|
+
Browser submissions and CLI page replacements share one ordered, in-memory event stream:
|
|
127
|
+
|
|
128
|
+
```text
|
|
129
|
+
submit browser
|
|
130
|
+
submit browser
|
|
131
|
+
update_ui CLI: replace #counter
|
|
132
|
+
update_ui CLI: replace #status
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The CLI assigns event numbers in acceptance order. They do not claim to be the physical order in which people clicked. Browser submissions are delivered to the agent through `pull`; raw submissions are not broadcast to other browsers. Page replacements are broadcast to all connected browsers.
|
|
136
|
+
|
|
137
|
+
`pull` returns an opaque batch token, current-page metadata, and browser events not yet committed by the agent. Pulling does not consume events. Events arriving while the agent works remain for a later pull.
|
|
65
138
|
|
|
66
139
|
```json
|
|
67
140
|
{
|
|
68
|
-
"
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
"
|
|
72
|
-
"
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
141
|
+
"token": "…",
|
|
142
|
+
"frontier": 7,
|
|
143
|
+
"page_event": 5,
|
|
144
|
+
"page_hash": "…",
|
|
145
|
+
"events": [
|
|
146
|
+
{
|
|
147
|
+
"type": "submit",
|
|
148
|
+
"id": "…",
|
|
149
|
+
"event_number": 7,
|
|
150
|
+
"page_event": 5,
|
|
151
|
+
"form_id": "decision",
|
|
152
|
+
"action": "/decide",
|
|
153
|
+
"trigger": {"name": "decision", "value": "approve"},
|
|
154
|
+
"values": {"comment": "Looks good", "decision": "approve"}
|
|
155
|
+
}
|
|
156
|
+
]
|
|
78
157
|
}
|
|
79
158
|
```
|
|
80
159
|
|
|
81
|
-
|
|
160
|
+
`page_event` is the page-update event number displayed when the browser submitted. Compare each event's `page_event` with the batch's current page before applying old input to the current HTML. `frontier` is the latest global event number, including events that are not browser submissions.
|
|
161
|
+
|
|
162
|
+
The token identifies exactly the browser-event frontier the agent saw. A successful push commits that frontier and its ordered replacements together. Browser events accepted after the pull remain for the next batch.
|
|
163
|
+
|
|
164
|
+
## Forms
|
|
165
|
+
|
|
166
|
+
Use ordinary same-origin forms with stable IDs and meaningful field names:
|
|
167
|
+
|
|
168
|
+
```html
|
|
169
|
+
<form id="decision" action="/decide" method="post">
|
|
170
|
+
<label>Comment <textarea id="comment" name="comment"></textarea></label>
|
|
171
|
+
<button name="decision" value="approve">Approve</button>
|
|
172
|
+
<button name="decision" value="reject">Reject</button>
|
|
173
|
+
</form>
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
The runtime converts native form submission into a JSON `submit` event. It assigns an opaque UUID, stores each event in a durable browser outbox before delivery, retries after connection failures, and reuses the UUID on retry. The CLI deduplicates repeated delivery of the same event. Distinct intentional submissions remain distinct, including rapid repeated clicks. File uploads are not supported.
|
|
177
|
+
|
|
178
|
+
Treat pulled values as untrusted input. Validate them and escape them before putting them into HTML.
|
|
179
|
+
|
|
180
|
+
## Browser and HTML rules
|
|
181
|
+
|
|
182
|
+
Normal updates are direct replacements, not DOM morphs. Stable IDs are therefore important for naming update boundaries, not for preserving DOM nodes.
|
|
183
|
+
|
|
184
|
+
- The CLI owns content inside replacement targets.
|
|
185
|
+
- The browser owns local focus, open/closed disclosure state, and hide/show behavior outside deliberately replaced targets.
|
|
186
|
+
- A root replacement can destroy all local state inside the root.
|
|
187
|
+
- Load agent-authored JavaScript from the initial page as a static asset.
|
|
188
|
+
- Use delegated event listeners because replaced elements are new DOM nodes.
|
|
189
|
+
- Scripts in update fragments are not executed.
|
|
190
|
+
- Do not have browser JavaScript and pushed HTML independently own the same state.
|
|
191
|
+
|
|
192
|
+
When a session ends, the browser shows a permanent closed status and discards unsent submissions rather than retrying them.
|
|
193
|
+
|
|
194
|
+
## Static assets
|
|
195
|
+
|
|
196
|
+
CSS, JavaScript, images, and other non-`index.html` files are served live. Finish writing an asset before pushing HTML that references it. Write assets atomically, and use versioned filenames or cache-busting URLs when the browser must fetch a changed asset with the page update.
|
|
197
|
+
|
|
198
|
+
This design intentionally does not provide atomic publication of the whole directory. The dynamic page changes through ordered replacements; other files can change as soon as they are written.
|
|
82
199
|
|
|
83
|
-
|
|
200
|
+
## Security
|
|
84
201
|
|
|
85
|
-
|
|
202
|
+
The URL is a bearer capability. Anyone who has it can view the page and submit forms. Keep secrets and unrelated files outside the served directory. Browser values are untrusted input; escape them before placing them in HTML.
|
|
86
203
|
|
|
87
204
|
## Stop
|
|
88
205
|
|
|
89
|
-
Send `SIGINT` or `SIGTERM` to `serve`.
|
|
206
|
+
Send `SIGINT` or `SIGTERM` to `serve`. The temporary session ends when the process stops.
|
package/bin/letmeknow.js
CHANGED
|
@@ -1,25 +1,31 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { constants, existsSync, readFileSync, statSync, writeSync } from "node:fs";
|
|
4
|
-
import { chmod,
|
|
3
|
+
import { constants, createReadStream, existsSync, readFileSync, statSync, writeSync } from "node:fs";
|
|
4
|
+
import { chmod, open, readFile, realpath, stat, unlink } from "node:fs/promises";
|
|
5
5
|
import { createHash, randomUUID } from "node:crypto";
|
|
6
6
|
import net from "node:net";
|
|
7
|
-
import { dirname,
|
|
7
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
9
|
import { parseArgs } from "node:util";
|
|
10
|
+
import { parse, parseFragment, serialize } from "parse5";
|
|
10
11
|
import { lookup } from "mrmime";
|
|
11
12
|
|
|
12
13
|
const MAX_BODY_BYTES = 1024 * 1024;
|
|
13
|
-
const
|
|
14
|
+
const UPDATE_BATCH_MAX_BYTES = 16 * MAX_BODY_BYTES;
|
|
15
|
+
const MAX_BATCH_TOKENS = 100_000;
|
|
16
|
+
const MAX_UNIQUE_SUBMISSIONS = 100_000;
|
|
17
|
+
const MAX_RETAINED_SUBMISSION_BYTES = 256 * 1024 * 1024;
|
|
18
|
+
const CONTROL_MAX_BYTES = UPDATE_BATCH_MAX_BYTES * 2;
|
|
19
|
+
const RECONNECT_RETRY_SECONDS = 10 * 60;
|
|
14
20
|
const CONNECTION_TIMEOUT = 10_000;
|
|
15
21
|
const CONTROL_TIMEOUT = 35_000;
|
|
16
|
-
const MAX_RETRY_DELAY = 5_000;
|
|
17
22
|
const CONTROL_PREFIX = "letmeknow-control-";
|
|
18
|
-
const SNAPSHOT_PREFIX = "letmeknow-snapshot-";
|
|
19
23
|
const CONTROL_URL = "https://letmeknow.dev";
|
|
20
24
|
const credentialPattern = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
21
25
|
const privateNames = new Set([".env", ".git", ".ssh", "id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"]);
|
|
22
26
|
const privateFilePattern = /^\.env\.|\.(?:key|pem|p12|ppk|p8|sqlite|sqlite3|db|db3)$|-(?:wal|shm|journal)$/i;
|
|
27
|
+
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
28
|
+
const forbiddenUpdateTargets = new Set(["html", "head", "body", "script"]);
|
|
23
29
|
|
|
24
30
|
function getMimeType(filename) {
|
|
25
31
|
const type = lookup(filename);
|
|
@@ -34,17 +40,6 @@ function header(packet, name) {
|
|
|
34
40
|
return typeof entry?.[1] === "string" && entry[1] !== "" ? entry[1] : null;
|
|
35
41
|
}
|
|
36
42
|
|
|
37
|
-
function encodedHeader(packet, name) {
|
|
38
|
-
const value = header(packet, name);
|
|
39
|
-
if (value === null) return null;
|
|
40
|
-
try { return decodeURIComponent(value); } catch { return null; }
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function addValue(values, name, value) {
|
|
44
|
-
if (Object.prototype.hasOwnProperty.call(values, name)) values[name] = Array.isArray(values[name]) ? [...values[name], value] : [values[name], value];
|
|
45
|
-
else values[name] = value;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
43
|
function response(packet, status, body = Buffer.alloc(0), headers = {}) {
|
|
49
44
|
if (body.byteLength > MAX_BODY_BYTES) return response(packet, 413, Buffer.from("response body is too large"), { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" });
|
|
50
45
|
const outputHeaders = { "Cache-Control": "no-store", ...headers };
|
|
@@ -93,12 +88,15 @@ function requestUrl(packet) {
|
|
|
93
88
|
return { pathname, encodedPathname: url.pathname, search: url.search };
|
|
94
89
|
}
|
|
95
90
|
|
|
96
|
-
async function staticResponse(root, packet,
|
|
97
|
-
const published = (status, body = Buffer.alloc(0), headers = {}) => response(packet, status, body,
|
|
91
|
+
async function staticResponse(root, packet, page, pageEvent) {
|
|
92
|
+
const published = (status, body = Buffer.alloc(0), headers = {}) => response(packet, status, body, headers);
|
|
98
93
|
const method = typeof packet.method === "string" ? packet.method.toUpperCase() : "";
|
|
99
94
|
if (method !== "GET" && method !== "HEAD") return errorResponse(packet, 405, "method not allowed");
|
|
100
95
|
let request;
|
|
101
96
|
try { request = requestUrl(packet); } catch { return errorResponse(packet, 400, "bad request"); }
|
|
97
|
+
if (request.pathname === "/" || request.pathname === "/index.html") {
|
|
98
|
+
return published(200, Buffer.from(page), { "Content-Type": "text/html; charset=utf-8", "X-LetMeKnow-Page-Event": String(pageEvent) });
|
|
99
|
+
}
|
|
102
100
|
if (deniedPath(request.pathname)) return errorResponse(packet, 403, "forbidden");
|
|
103
101
|
const candidate = resolve(root, "." + request.pathname);
|
|
104
102
|
if (!inside(root, candidate)) return errorResponse(packet, 403, "forbidden");
|
|
@@ -151,69 +149,157 @@ async function staticResponse(root, packet, workspaceId) {
|
|
|
151
149
|
}
|
|
152
150
|
}
|
|
153
151
|
|
|
154
|
-
async function
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
152
|
+
async function readInitialPage(root) {
|
|
153
|
+
const candidate = join(root, "index.html");
|
|
154
|
+
const target = await safeRealpath(root, candidate);
|
|
155
|
+
if (target === null || target === undefined || deniedPath("/" + relative(root, target).split(sep).join("/"))) throw new Error("index.html is required");
|
|
156
|
+
const info = await stat(target);
|
|
157
|
+
if (!info.isFile()) throw new Error("index.html must be a file");
|
|
158
|
+
if (info.size > MAX_BODY_BYTES) throw new Error("index.html is too large");
|
|
159
|
+
return (await readFile(target)).toString("utf8");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function nodeId(node) {
|
|
163
|
+
return node.attrs?.find(attribute => attribute.name === "id")?.value;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function elementsWithId(node, id, matches = []) {
|
|
167
|
+
if (node.nodeName !== "#text" && node.nodeName !== "#comment" && nodeId(node) === id) matches.push(node);
|
|
168
|
+
for (const child of node.childNodes || []) elementsWithId(child, id, matches);
|
|
169
|
+
return matches;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function containsScript(node) {
|
|
173
|
+
if (node.nodeName === "script") return true;
|
|
174
|
+
if (node.content && containsScript(node.content)) return true;
|
|
175
|
+
return (node.childNodes || []).some(containsScript);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function updateFragment(target, html, context) {
|
|
179
|
+
const fragment = parseFragment(context, html);
|
|
180
|
+
const meaningful = (fragment.childNodes || []).filter(node => node.nodeName !== "#text" || node.value.trim() !== "");
|
|
181
|
+
if (meaningful.length !== 1 || meaningful[0].nodeName === "#comment" || meaningful[0].nodeName?.startsWith("#")) throw new Error(`update for ${target} must contain exactly one root element`);
|
|
182
|
+
const root = meaningful[0];
|
|
183
|
+
if (containsScript(root)) throw new Error(`update for ${target} cannot contain script elements`);
|
|
184
|
+
if (nodeId(root) !== target) throw new Error(`update root ID must be ${target}`);
|
|
185
|
+
return root;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function replaceElement(document, target, html) {
|
|
189
|
+
if (forbiddenUpdateTargets.has(target.nodeName)) throw new Error(`cannot update ${target.nodeName} element`);
|
|
190
|
+
const parent = target.parentNode;
|
|
191
|
+
const index = parent?.childNodes.indexOf(target);
|
|
192
|
+
if (!parent || index === undefined || index < 0) throw new Error("update target has no parent");
|
|
193
|
+
const replacement = updateFragment(nodeId(target), html, parent);
|
|
194
|
+
parent.childNodes[index] = replacement;
|
|
195
|
+
replacement.parentNode = parent;
|
|
196
|
+
target.parentNode = null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function validateUpdates(updates) {
|
|
200
|
+
if (!Array.isArray(updates)) throw new Error("updates must be an array");
|
|
201
|
+
let bytes = 0;
|
|
202
|
+
for (const update of updates) {
|
|
203
|
+
if (!update || typeof update !== "object" || Array.isArray(update)) throw new Error("each update must be an object");
|
|
204
|
+
if (typeof update.target !== "string" || update.target.trim() === "") throw new Error("update target must be a non-empty ID");
|
|
205
|
+
if (typeof update.html !== "string") throw new Error("update html must be text");
|
|
206
|
+
bytes += Buffer.byteLength(update.html, "utf8");
|
|
207
|
+
if (bytes > UPDATE_BATCH_MAX_BYTES) throw new Error("updates are too large");
|
|
208
|
+
}
|
|
209
|
+
return updates;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function applyUpdates(page, updates) {
|
|
213
|
+
validateUpdates(updates);
|
|
214
|
+
const document = parse(page);
|
|
215
|
+
for (const update of updates) {
|
|
216
|
+
const matches = elementsWithId(document, update.target);
|
|
217
|
+
if (matches.length !== 1) throw new Error(`update target ${update.target} must match exactly one element`);
|
|
218
|
+
replaceElement(document, matches[0], update.html);
|
|
219
|
+
}
|
|
220
|
+
const result = serialize(document);
|
|
221
|
+
if (Buffer.byteLength(result, "utf8") > MAX_BODY_BYTES) throw new Error("updated page is too large");
|
|
222
|
+
return result;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function readInput(filename) {
|
|
226
|
+
const chunks = [];
|
|
227
|
+
let length = 0;
|
|
228
|
+
const input = filename === "-" ? process.stdin : createReadStream(filename);
|
|
229
|
+
for await (const chunk of input) {
|
|
230
|
+
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
231
|
+
length += value.byteLength;
|
|
232
|
+
if (length > UPDATE_BATCH_MAX_BYTES) throw new Error("updates are too large");
|
|
233
|
+
chunks.push(value);
|
|
234
|
+
}
|
|
235
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function readUpdatesInput(filename) {
|
|
239
|
+
const manifest = await readInput(filename);
|
|
240
|
+
let value;
|
|
241
|
+
try { value = JSON.parse(manifest); } catch { throw new Error("invalid updates JSON"); }
|
|
242
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || !Array.isArray(value.updates)) throw new Error("updates must be an object with an updates array");
|
|
243
|
+
const base = filename === "-" ? process.cwd() : dirname(resolve(filename));
|
|
244
|
+
const updates = [];
|
|
245
|
+
let bytes = 0;
|
|
246
|
+
for (const item of value.updates) {
|
|
247
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error("each update must be an object");
|
|
248
|
+
if (typeof item.target !== "string" || item.target.trim() === "") throw new Error("update target must be a non-empty ID");
|
|
249
|
+
const hasHtml = Object.prototype.hasOwnProperty.call(item, "html");
|
|
250
|
+
const hasFile = Object.prototype.hasOwnProperty.call(item, "file");
|
|
251
|
+
if (hasHtml === hasFile) throw new Error(`update for ${item.target} must have exactly one of html or file`);
|
|
252
|
+
let html;
|
|
253
|
+
if (hasHtml) {
|
|
254
|
+
if (typeof item.html !== "string") throw new Error(`update html for ${item.target} must be text`);
|
|
255
|
+
html = item.html;
|
|
256
|
+
} else {
|
|
257
|
+
if (typeof item.file !== "string" || item.file === "") throw new Error(`update file for ${item.target} must be a path`);
|
|
258
|
+
const file = resolve(base, item.file);
|
|
259
|
+
const info = await stat(file);
|
|
260
|
+
if (!info.isFile()) throw new Error(`update file for ${item.target} must be a regular file`);
|
|
261
|
+
if (info.size > UPDATE_BATCH_MAX_BYTES - bytes) throw new Error("updates are too large");
|
|
262
|
+
html = (await readFile(file)).toString("utf8");
|
|
166
263
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
await writeFile(path, bytes, { mode: 0o600, flag: "wx" });
|
|
171
|
-
attachments.push({ field: name, name: value.name, type: value.type, size: bytes.byteLength, path });
|
|
264
|
+
bytes += Buffer.byteLength(html, "utf8");
|
|
265
|
+
if (bytes > UPDATE_BATCH_MAX_BYTES) throw new Error("updates are too large");
|
|
266
|
+
updates.push({ target: item.target, html });
|
|
172
267
|
}
|
|
173
|
-
return
|
|
268
|
+
return updates;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function updatesHash(updates) {
|
|
272
|
+
return createHash("sha256").update(JSON.stringify(updates)).digest("hex");
|
|
174
273
|
}
|
|
175
274
|
|
|
176
|
-
async function submission(packet,
|
|
177
|
-
const
|
|
275
|
+
async function submission(packet, recordInteraction) {
|
|
276
|
+
const request = requestUrl(packet);
|
|
178
277
|
const method = typeof packet.method === "string" ? packet.method.toUpperCase() : "";
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if (
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
} else throw new Error("unsupported submission encoding");
|
|
195
|
-
} else throw new Error("unsupported submission method");
|
|
196
|
-
const event = {
|
|
197
|
-
type: "submit",
|
|
198
|
-
id: encodedHeader(packet, "x-letmeknow-id") || randomUUID(),
|
|
199
|
-
method,
|
|
200
|
-
action: encodedHeader(packet, "x-letmeknow-action") || url.pathname,
|
|
201
|
-
form_id: encodedHeader(packet, "x-letmeknow-form-id"),
|
|
202
|
-
trigger: { id: encodedHeader(packet, "x-letmeknow-trigger-id"), name: encodedHeader(packet, "x-letmeknow-trigger-name"), value: encodedHeader(packet, "x-letmeknow-trigger-value") },
|
|
203
|
-
values
|
|
204
|
-
};
|
|
205
|
-
const basedOn = encodedHeader(packet, "x-letmeknow-based-on");
|
|
206
|
-
if (basedOn !== null) event.based_on = basedOn;
|
|
207
|
-
if (attachments?.length) event.attachments = attachments;
|
|
208
|
-
await recordInteraction(event);
|
|
278
|
+
if (method !== "POST" || request.pathname !== "/_letmeknow/submit") throw new Error("invalid submission endpoint");
|
|
279
|
+
const contentType = header(packet, "content-type")?.split(";", 1)[0].trim().toLowerCase();
|
|
280
|
+
if (contentType !== "application/json") throw new Error("JSON submission is required");
|
|
281
|
+
const body = Buffer.from(typeof packet.body === "string" ? packet.body : "", "base64");
|
|
282
|
+
if (body.byteLength > MAX_BODY_BYTES) throw new Error("submission is too large");
|
|
283
|
+
let value;
|
|
284
|
+
try { value = JSON.parse(body.toString("utf8")); } catch { throw new Error("invalid submission JSON"); }
|
|
285
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("submission must be an object");
|
|
286
|
+
if (typeof value.id !== "string" || !uuidPattern.test(value.id)) throw new Error("submission id must be a UUID");
|
|
287
|
+
if (!Number.isSafeInteger(value.page_event) || value.page_event < 0) throw new Error("page_event must be a non-negative integer");
|
|
288
|
+
if (value.form_id !== null && typeof value.form_id !== "string") throw new Error("form_id must be text or null");
|
|
289
|
+
if (typeof value.action !== "string") throw new Error("action is required");
|
|
290
|
+
if (value.trigger !== null && (typeof value.trigger !== "object" || Array.isArray(value.trigger))) throw new Error("trigger must be an object or null");
|
|
291
|
+
if (!value.values || typeof value.values !== "object" || Array.isArray(value.values)) throw new Error("values are required");
|
|
292
|
+
await recordInteraction({ type: "submit", id: value.id, page_event: value.page_event, form_id: value.form_id, action: value.action, trigger: value.trigger, values: value.values }, body.byteLength);
|
|
209
293
|
return response(packet, 202);
|
|
210
294
|
}
|
|
211
295
|
|
|
212
|
-
async function handleRequest(root,
|
|
213
|
-
|
|
214
|
-
|
|
296
|
+
async function handleRequest(root, page, pageEvent, packet, recordInteraction) {
|
|
297
|
+
let request;
|
|
298
|
+
try { request = requestUrl(packet); } catch { return errorResponse(packet, 400, "bad request"); }
|
|
299
|
+
if (request.pathname === "/_letmeknow/submit") {
|
|
300
|
+
try { return await submission(packet, recordInteraction); } catch (cause) { return errorResponse(packet, cause?.message === "submission is too large" ? 413 : 400, cause instanceof Error ? cause.message : "invalid submission"); }
|
|
215
301
|
}
|
|
216
|
-
return staticResponse(root, packet,
|
|
302
|
+
return staticResponse(root, packet, page, pageEvent);
|
|
217
303
|
}
|
|
218
304
|
|
|
219
305
|
function options(directory) {
|
|
@@ -227,41 +313,6 @@ function controlPath(root) {
|
|
|
227
313
|
return join(tmpdir(), `${CONTROL_PREFIX}${key}.sock`);
|
|
228
314
|
}
|
|
229
315
|
|
|
230
|
-
async function copyDirectory(source, target, root, visited = new Set()) {
|
|
231
|
-
const sourceReal = await realpath(source);
|
|
232
|
-
if (visited.has(sourceReal)) return;
|
|
233
|
-
visited.add(sourceReal);
|
|
234
|
-
await mkdir(target, { recursive: true });
|
|
235
|
-
for (const entry of await readdir(sourceReal, { withFileTypes: true })) {
|
|
236
|
-
const candidate = join(sourceReal, entry.name);
|
|
237
|
-
const pathname = "/" + relative(root, candidate).split(sep).join("/");
|
|
238
|
-
if (deniedPath(pathname)) continue;
|
|
239
|
-
const targetPath = join(target, entry.name);
|
|
240
|
-
const targetReal = await safeRealpath(root, candidate);
|
|
241
|
-
if (targetReal === null) {
|
|
242
|
-
if ((await lstat(candidate)).isSymbolicLink()) await symlink(await readlink(candidate), targetPath);
|
|
243
|
-
continue;
|
|
244
|
-
}
|
|
245
|
-
if (targetReal === undefined) continue;
|
|
246
|
-
if (deniedPath("/" + relative(root, targetReal).split(sep).join("/"))) continue;
|
|
247
|
-
const info = await stat(targetReal);
|
|
248
|
-
if (info.isDirectory()) await copyDirectory(targetReal, targetPath, root, visited);
|
|
249
|
-
else if (info.isFile()) await copyFile(targetReal, targetPath);
|
|
250
|
-
}
|
|
251
|
-
visited.delete(sourceReal);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
async function snapshotDirectory(root) {
|
|
255
|
-
const snapshot = await mkdtemp(join(tmpdir(), SNAPSHOT_PREFIX));
|
|
256
|
-
try {
|
|
257
|
-
await copyDirectory(root, snapshot, root);
|
|
258
|
-
return snapshot;
|
|
259
|
-
} catch (cause) {
|
|
260
|
-
await rm(snapshot, { recursive: true, force: true });
|
|
261
|
-
throw cause;
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
316
|
function mutateQueue() {
|
|
266
317
|
let chain = Promise.resolve();
|
|
267
318
|
return operation => {
|
|
@@ -303,20 +354,20 @@ function connectControl(root, packet) {
|
|
|
303
354
|
});
|
|
304
355
|
}
|
|
305
356
|
|
|
357
|
+
function pageHash(page) {
|
|
358
|
+
return createHash("sha256").update(page).digest("hex");
|
|
359
|
+
}
|
|
360
|
+
|
|
306
361
|
async function start(directory) {
|
|
307
362
|
const { root } = await options(directory);
|
|
308
|
-
let
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
const
|
|
314
|
-
let
|
|
315
|
-
let
|
|
316
|
-
let workspaceSequence = 1;
|
|
317
|
-
const workspaceIds = new Set([workspaceId]);
|
|
318
|
-
const eventLog = [];
|
|
319
|
-
let committedCursor = 0;
|
|
363
|
+
let page = await readInitialPage(root);
|
|
364
|
+
let pageEvent = 0;
|
|
365
|
+
let eventNumber = 0;
|
|
366
|
+
const currentPageHash = () => pageHash(page);
|
|
367
|
+
const browserEvents = [];
|
|
368
|
+
const browserEventBytes = [];
|
|
369
|
+
let retainedSubmissionBytes = 0;
|
|
370
|
+
let committedBrowserCursor = 0;
|
|
320
371
|
const seenEvents = new Set();
|
|
321
372
|
const tokens = new Map();
|
|
322
373
|
const pendingTokens = new Map();
|
|
@@ -333,32 +384,28 @@ async function start(directory) {
|
|
|
333
384
|
let retryUntil = 0;
|
|
334
385
|
let stopped = false;
|
|
335
386
|
let ready = false;
|
|
336
|
-
let
|
|
387
|
+
let stopSession = () => {};
|
|
337
388
|
|
|
338
389
|
const batch = () => {
|
|
339
|
-
const start =
|
|
340
|
-
const end =
|
|
341
|
-
const key = `${
|
|
342
|
-
|
|
343
|
-
if (
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
context: {
|
|
348
|
-
based_on: event.based_on ?? null,
|
|
349
|
-
current: workspaceId,
|
|
350
|
-
relationship: event.based_on === workspaceId ? "current" : workspaceIds.has(event.based_on) ? "stale" : "unknown"
|
|
390
|
+
const start = committedBrowserCursor;
|
|
391
|
+
const end = start + browserEvents.length;
|
|
392
|
+
const key = `${pageEvent}:${start}:${end}`;
|
|
393
|
+
let token = pendingTokens.get(key)?.token;
|
|
394
|
+
if (!token) {
|
|
395
|
+
if (tokens.size >= MAX_BATCH_TOKENS) {
|
|
396
|
+
stopSession();
|
|
397
|
+
throw new Error("session batch token limit exceeded");
|
|
351
398
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
return
|
|
399
|
+
token = randomUUID();
|
|
400
|
+
tokens.set(token, { start, end, page_event: pageEvent, status: "pending", key, updates_hash: null });
|
|
401
|
+
pendingTokens.set(key, { token });
|
|
402
|
+
}
|
|
403
|
+
return { ok: true, type: "batch", token, frontier: eventNumber, page_event: pageEvent, page_hash: currentPageHash(), events: browserEvents.slice() };
|
|
357
404
|
};
|
|
358
405
|
|
|
359
406
|
const notifyPullWaiters = () => {
|
|
360
407
|
for (const waiter of [...pullWaiters]) {
|
|
361
|
-
if (
|
|
408
|
+
if (browserEvents.length === 0) continue;
|
|
362
409
|
pullWaiters.delete(waiter);
|
|
363
410
|
clearTimeout(waiter.timer);
|
|
364
411
|
waiter.resolve(batch());
|
|
@@ -366,53 +413,71 @@ async function start(directory) {
|
|
|
366
413
|
};
|
|
367
414
|
|
|
368
415
|
const pull = waitSeconds => {
|
|
369
|
-
if (
|
|
416
|
+
if (browserEvents.length > 0 || waitSeconds <= 0) return Promise.resolve(batch());
|
|
370
417
|
return new Promise(resolve => {
|
|
371
418
|
const waiter = { resolve, timer: setTimeout(() => { pullWaiters.delete(waiter); resolve(batch()); }, waitSeconds * 1_000) };
|
|
372
419
|
pullWaiters.add(waiter);
|
|
373
420
|
});
|
|
374
421
|
};
|
|
375
422
|
|
|
376
|
-
const commit = async (token,
|
|
423
|
+
const commit = async (token, requestedUpdates) => {
|
|
377
424
|
const record = tokens.get(token);
|
|
378
425
|
if (!record) return { ok: false, error: "unknown batch token" };
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
record.
|
|
383
|
-
|
|
384
|
-
return record.result;
|
|
426
|
+
const updates = validateUpdates(requestedUpdates);
|
|
427
|
+
const requestedUpdatesHash = updatesHash(updates);
|
|
428
|
+
if (record.status !== "pending") {
|
|
429
|
+
if (record.updates_hash === requestedUpdatesHash) return record.result;
|
|
430
|
+
return { ok: false, error: "batch was already committed with a different updates payload" };
|
|
385
431
|
}
|
|
386
|
-
if (record.
|
|
432
|
+
if (record.page_event !== pageEvent || record.start !== committedBrowserCursor) {
|
|
433
|
+
record.updates_hash = requestedUpdatesHash;
|
|
387
434
|
pendingTokens.delete(record.key);
|
|
388
435
|
record.status = "failed";
|
|
389
|
-
record.result = { ok: false, error: "batch is based on an old
|
|
436
|
+
record.result = { ok: false, error: "batch is based on an old page or browser cursor", frontier: eventNumber, page_event: pageEvent, page_hash: currentPageHash() };
|
|
390
437
|
return record.result;
|
|
391
438
|
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
439
|
+
let nextPage = page;
|
|
440
|
+
try { if (updates.length) nextPage = applyUpdates(page, updates); } catch (cause) {
|
|
441
|
+
throw new Error(cause instanceof Error ? cause.message : "invalid page updates");
|
|
442
|
+
}
|
|
443
|
+
const committedCount = record.end - record.start;
|
|
444
|
+
const committedEvents = browserEvents.slice(0, committedCount).map(event => event.id);
|
|
445
|
+
retainedSubmissionBytes -= browserEventBytes.slice(0, committedCount).reduce((total, bytes) => total + bytes, 0);
|
|
446
|
+
const updateEvents = [];
|
|
447
|
+
for (const update of updates) {
|
|
448
|
+
eventNumber += 1;
|
|
449
|
+
updateEvents.push({ type: "update_ui", event_number: eventNumber, target: update.target, html: update.html });
|
|
403
450
|
}
|
|
404
|
-
|
|
451
|
+
page = nextPage;
|
|
452
|
+
pageEvent = updateEvents.at(-1)?.event_number ?? pageEvent;
|
|
453
|
+
browserEvents.splice(0, committedCount);
|
|
454
|
+
browserEventBytes.splice(0, committedCount);
|
|
455
|
+
committedBrowserCursor = record.end;
|
|
405
456
|
pendingTokens.delete(record.key);
|
|
406
457
|
record.status = "committed";
|
|
407
|
-
|
|
458
|
+
record.updates_hash = requestedUpdatesHash;
|
|
459
|
+
record.result = {
|
|
460
|
+
ok: true,
|
|
461
|
+
type: "committed",
|
|
462
|
+
token,
|
|
463
|
+
frontier: eventNumber,
|
|
464
|
+
page_event: pageEvent,
|
|
465
|
+
page_hash: currentPageHash(),
|
|
466
|
+
events: committedEvents,
|
|
467
|
+
updates: updateEvents.map(({ event_number, target }) => ({ event_number, target }))
|
|
468
|
+
};
|
|
469
|
+
for (const update of updateEvents) send(update);
|
|
408
470
|
return record.result;
|
|
409
471
|
};
|
|
410
472
|
|
|
411
473
|
const dispatchControl = async request => {
|
|
412
474
|
if (!request || typeof request !== "object") return { ok: false, error: "invalid control request" };
|
|
413
475
|
if (request.type === "pull") return pull(Number.isFinite(request.wait_seconds) ? Math.max(0, request.wait_seconds) : 0);
|
|
414
|
-
if (request.type === "
|
|
415
|
-
if (request.type === "
|
|
476
|
+
if (request.type === "show") return { ok: true, type: "page", page_event: pageEvent, page_hash: currentPageHash(), html: page };
|
|
477
|
+
if (request.type === "push") {
|
|
478
|
+
if (typeof request.token !== "string") return { ok: false, error: "batch token is required" };
|
|
479
|
+
return commit(request.token, request.updates === undefined ? [] : request.updates);
|
|
480
|
+
}
|
|
416
481
|
return { ok: false, error: "unknown control request" };
|
|
417
482
|
};
|
|
418
483
|
|
|
@@ -424,7 +489,7 @@ async function start(directory) {
|
|
|
424
489
|
let handled = false;
|
|
425
490
|
connection.on("data", async chunk => {
|
|
426
491
|
input += chunk;
|
|
427
|
-
if (input
|
|
492
|
+
if (Buffer.byteLength(input, "utf8") > CONTROL_MAX_BYTES || handled) return;
|
|
428
493
|
const newline = input.indexOf("\n");
|
|
429
494
|
if (newline < 0) return;
|
|
430
495
|
handled = true;
|
|
@@ -440,20 +505,25 @@ async function start(directory) {
|
|
|
440
505
|
});
|
|
441
506
|
await new Promise((resolveListen, reject) => {
|
|
442
507
|
controlServer.once("error", reject);
|
|
443
|
-
controlServer.listen(
|
|
444
|
-
try { await chmod(
|
|
508
|
+
controlServer.listen(controlPath(root), async () => {
|
|
509
|
+
try { await chmod(controlPath(root), 0o600); } catch (cause) { controlServer.close(() => reject(cause)); return; }
|
|
445
510
|
controlServer.off("error", reject);
|
|
446
511
|
resolveListen();
|
|
447
512
|
});
|
|
448
|
-
}).catch(
|
|
449
|
-
await rm(publishedRoot, { recursive: true, force: true });
|
|
450
|
-
throw new Error(`cannot start local control channel: ${cause.message}`);
|
|
451
|
-
});
|
|
513
|
+
}).catch(cause => { throw new Error(`cannot start local control channel: ${cause.message}`); });
|
|
452
514
|
|
|
453
|
-
const recordInteraction = event => mutate(async () => {
|
|
515
|
+
const recordInteraction = (event, bytes) => mutate(async () => {
|
|
454
516
|
if (seenEvents.has(event.id)) return;
|
|
517
|
+
if (seenEvents.size >= MAX_UNIQUE_SUBMISSIONS || retainedSubmissionBytes + bytes > MAX_RETAINED_SUBMISSION_BYTES) {
|
|
518
|
+
stopSession();
|
|
519
|
+
throw new Error("session submission limit exceeded");
|
|
520
|
+
}
|
|
455
521
|
seenEvents.add(event.id);
|
|
456
|
-
|
|
522
|
+
retainedSubmissionBytes += bytes;
|
|
523
|
+
eventNumber += 1;
|
|
524
|
+
const numbered = { ...event, event_number: eventNumber };
|
|
525
|
+
browserEvents.push(numbered);
|
|
526
|
+
browserEventBytes.push(bytes);
|
|
457
527
|
notifyPullWaiters();
|
|
458
528
|
});
|
|
459
529
|
|
|
@@ -466,20 +536,17 @@ async function start(directory) {
|
|
|
466
536
|
try { socket?.close(); } catch {}
|
|
467
537
|
for (const connection of controlConnections) connection.destroy();
|
|
468
538
|
await new Promise(resolveClose => controlServer.close(() => resolveClose()));
|
|
469
|
-
await unlink(
|
|
470
|
-
await rm(publishedRoot, { recursive: true, force: true });
|
|
471
|
-
if (attachmentInboxPromise) {
|
|
472
|
-
try { await rm(await attachmentInboxPromise, { recursive: true, force: true }); } catch {}
|
|
473
|
-
}
|
|
539
|
+
await unlink(controlPath(root)).catch(() => {});
|
|
474
540
|
process.exit(code);
|
|
475
541
|
};
|
|
542
|
+
stopSession = () => { void stop(1); };
|
|
476
543
|
process.once("SIGINT", () => void stop(0));
|
|
477
544
|
process.once("SIGTERM", () => void stop(0));
|
|
478
545
|
|
|
479
546
|
const retry = () => {
|
|
480
547
|
if (stopped || Date.now() >= retryUntil) return void stop(1);
|
|
481
548
|
retryTimer = setTimeout(() => { retryTimer = undefined; connect(); }, retryDelay);
|
|
482
|
-
retryDelay = Math.min(retryDelay * 2,
|
|
549
|
+
retryDelay = Math.min(retryDelay * 2, 5_000);
|
|
483
550
|
};
|
|
484
551
|
|
|
485
552
|
const connect = () => {
|
|
@@ -512,15 +579,11 @@ async function start(directory) {
|
|
|
512
579
|
} else if (packet.type === "session") {
|
|
513
580
|
if (!validSessionUrl(packet.url)) return void stop(1);
|
|
514
581
|
sessionUrl = packet.url;
|
|
515
|
-
if (!
|
|
516
|
-
initialPublished = true;
|
|
517
|
-
send({ type: "revision" });
|
|
518
|
-
}
|
|
519
|
-
if (!ready) { ready = true; process.stdout.write(`${JSON.stringify({ type: "ready", url: sessionUrl, workspace: workspaceId, workspace_sequence: workspaceSequence })}\n`); }
|
|
582
|
+
if (!ready) { ready = true; process.stdout.write(`${JSON.stringify({ type: "ready", url: sessionUrl, page_event: pageEvent, page_hash: currentPageHash() })}\n`); }
|
|
520
583
|
} else if (packet.type === "http_request") {
|
|
521
|
-
const
|
|
522
|
-
const
|
|
523
|
-
void handleRequest(
|
|
584
|
+
const requestPage = page;
|
|
585
|
+
const requestPageEvent = pageEvent;
|
|
586
|
+
void handleRequest(root, requestPage, requestPageEvent, packet, recordInteraction).then(result => send(result)).catch(() => send(errorResponse(packet, 500, "preview request failed")));
|
|
524
587
|
} else if (packet.type === "closed") {
|
|
525
588
|
void stop(0);
|
|
526
589
|
} else if (packet.type === "error") {
|
|
@@ -534,7 +597,7 @@ async function start(directory) {
|
|
|
534
597
|
send = () => false;
|
|
535
598
|
socket = undefined;
|
|
536
599
|
if (!credential || !sessionUrl) return void stop(1);
|
|
537
|
-
if (!retryUntil) retryUntil = Date.now() +
|
|
600
|
+
if (!retryUntil) retryUntil = Date.now() + RECONNECT_RETRY_SECONDS * 1_000;
|
|
538
601
|
retry();
|
|
539
602
|
});
|
|
540
603
|
};
|
|
@@ -564,7 +627,7 @@ function validSessionUrl(value) {
|
|
|
564
627
|
}
|
|
565
628
|
|
|
566
629
|
function usage() {
|
|
567
|
-
return "Usage:\n npx letmeknow-cli serve <directory>\n npx letmeknow-cli pull <directory> [--wait <seconds>]\n npx letmeknow-cli push <directory> --
|
|
630
|
+
return "Usage:\n npx letmeknow-cli serve <directory>\n npx letmeknow-cli show <directory>\n npx letmeknow-cli pull <directory> [--wait <seconds>]\n npx letmeknow-cli push <directory> --batch <token> [--updates <file|->]\n";
|
|
568
631
|
}
|
|
569
632
|
|
|
570
633
|
function commandArgs() {
|
|
@@ -576,7 +639,8 @@ function commandArgs() {
|
|
|
576
639
|
skill: { type: "boolean" },
|
|
577
640
|
help: { type: "boolean", short: "h" },
|
|
578
641
|
wait: { type: "string" },
|
|
579
|
-
|
|
642
|
+
batch: { type: "string" },
|
|
643
|
+
updates: { type: "string" }
|
|
580
644
|
},
|
|
581
645
|
allowPositionals: true,
|
|
582
646
|
strict: true
|
|
@@ -585,24 +649,25 @@ function commandArgs() {
|
|
|
585
649
|
throw new Error(cause instanceof Error ? cause.message : "invalid arguments");
|
|
586
650
|
}
|
|
587
651
|
if (parsed.values.skill || parsed.values.help) {
|
|
588
|
-
if (parsed.positionals.length || parsed.values.wait !== undefined || parsed.values
|
|
652
|
+
if (parsed.positionals.length || parsed.values.wait !== undefined || parsed.values.batch !== undefined || parsed.values.updates !== undefined) throw new Error(usage());
|
|
589
653
|
return { command: parsed.values.skill ? "skill" : "help" };
|
|
590
654
|
}
|
|
591
655
|
const [command, directory, ...extra] = parsed.positionals;
|
|
592
656
|
if (!command || !directory || extra.length) throw new Error(usage());
|
|
593
|
-
if (
|
|
594
|
-
if (command === "
|
|
595
|
-
if (
|
|
596
|
-
if (
|
|
657
|
+
if (!["serve", "show", "pull", "push"].includes(command)) throw new Error(usage());
|
|
658
|
+
if ((command === "serve" || command === "show") && (parsed.values.wait !== undefined || parsed.values.batch !== undefined || parsed.values.updates !== undefined)) throw new Error(usage());
|
|
659
|
+
if (command === "pull" && (parsed.values.batch !== undefined || parsed.values.updates !== undefined)) throw new Error(usage());
|
|
660
|
+
if (command === "push" && parsed.values.wait !== undefined) throw new Error(usage());
|
|
597
661
|
let wait = 0;
|
|
598
662
|
if (parsed.values.wait !== undefined) {
|
|
599
663
|
wait = Number(parsed.values.wait);
|
|
600
664
|
if (!Number.isFinite(wait) || wait < 0) throw new Error("--wait must be a non-negative number");
|
|
601
665
|
}
|
|
602
|
-
if (
|
|
603
|
-
return { command, directory, wait, token: parsed.values
|
|
666
|
+
if (command === "push" && typeof parsed.values.batch !== "string") throw new Error("--batch is required");
|
|
667
|
+
return { command, directory, wait, token: parsed.values.batch, updates: parsed.values.updates };
|
|
604
668
|
}
|
|
605
669
|
|
|
670
|
+
|
|
606
671
|
let command;
|
|
607
672
|
try {
|
|
608
673
|
command = commandArgs();
|
|
@@ -611,8 +676,15 @@ try {
|
|
|
611
676
|
else if (command.command === "serve") await start(command.directory);
|
|
612
677
|
else {
|
|
613
678
|
const { root } = await options(command.directory);
|
|
614
|
-
|
|
615
|
-
|
|
679
|
+
let packet;
|
|
680
|
+
if (command.command === "pull") packet = { type: "pull", wait_seconds: command.wait };
|
|
681
|
+
else if (command.command === "show") packet = { type: "show" };
|
|
682
|
+
else {
|
|
683
|
+
packet = { type: "push", token: command.token, updates: command.updates === undefined ? [] : await readUpdatesInput(command.updates) };
|
|
684
|
+
}
|
|
685
|
+
const result = await connectControl(root, packet);
|
|
686
|
+
if (command.command === "show" && result.ok) process.stdout.write(result.html);
|
|
687
|
+
else process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
616
688
|
if (!result.ok) process.exitCode = 1;
|
|
617
689
|
}
|
|
618
690
|
} catch (cause) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "letmeknow-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "A live
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "A live agent-authored page with structured browser feedback.",
|
|
5
5
|
"files": [
|
|
6
6
|
"bin",
|
|
7
7
|
"SKILL.md"
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"ws": "^8.21.3"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"mrmime": "^2.0.1"
|
|
33
|
+
"mrmime": "^2.0.1",
|
|
34
|
+
"parse5": "^8.0.1"
|
|
34
35
|
}
|
|
35
36
|
}
|