@threadedstack/wmd-web 0.1.17 → 0.1.18

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 CHANGED
@@ -155,6 +155,50 @@ The interface this host implements is in [docs/host-interface.md](docs/host-inte
155
155
  too) and shows the last 128 KB. P-B1's merged branch had the same rings at 4 MiB
156
156
  (cherry-picked as `17e9ce50`); the merge keeps P-B2's 32 MiB rings and drop markers.
157
157
 
158
+ ## Building images (`wmd-web/build`)
159
+
160
+ `createBuilder` runs BuildKit (moby/buildkit v0.33.0) in a privileged builder machine in the page and builds a
161
+ Dockerfile there. The result is `{index, packs}`, the shape `pullImage` returns, so `bootWmd` can run it at once.
162
+ Chromium, Firefox and WebKit all pass the same 18 build steps (plan `docs/plans/web-buildkit-impl.md`, section 10).
163
+
164
+ ```js
165
+ import { createBuilder } from 'wmd-web/build'
166
+
167
+ const builder = await createBuilder({
168
+ vmUrl, files, // as bootWmd: the engine and the builder arch's guest files
169
+ arch: 'arm64', // or 'amd64'
170
+ image: { tar: builderTarUrl, sha256 }, // the builder image; must match BUILDER_PINS in src/build/pin.js
171
+ relay: { url, protocol: 'wisp' }, // the guest's network: base images and RUN downloads
172
+ disk: { namespace: 'my-app' }, // BuildKit's cache on an ext4 disk in OPFS (default 10240 MiB)
173
+ converter, // wmdpack.wasm
174
+ pull: ref => pullImage(ref, { fetch, converter, platform: 'linux/arm64' }), // base images for the fast path (off without it)
175
+ })
176
+ const r = await builder.build({ context: { Dockerfile: 'FROM alpine:3.20\nRUN echo hi > /x\n' } })
177
+ // r: {index, packs, log, path: 'fast'|'buildkit', ms, cached}
178
+ await builder.close()
179
+ ```
180
+
181
+ - **The cache stays.** The disk outlives the builder and the visit. After a reload, or after a tab was killed
182
+ mid-build, the next build mounts the disk again and its steps come back `CACHED`. `close({keep: false})` deletes
183
+ the disk.
184
+ - **A second tab** that asks for a disk already open gets a temporary disk, deleted when it closes.
185
+ - **Features:** `RUN --mount=type=cache` and `type=secret`, `# syntax=` frontends, build args and target stages,
186
+ `.dockerignore`, file capabilities (`setcap`), push to a registry (TLS, or plain http in `httpRegistries`), and
187
+ `tar: true` for a Docker tar that `docker load` takes. `--mount=type=ssh` and another platform are refused before
188
+ any work.
189
+ - **The fast path.** With `pull` given, a Dockerfile with no `RUN` is built in the page without BuildKit, layer
190
+ for layer the same as BuildKit's (40 compared cases): under 0.4 s once its base image is pulled, where a warm
191
+ BuildKit takes 5 to 14 s.
192
+ - **Errors:** `BuildError {step, tail, reason}`: `reason` is `'no-space'`, `'quota'`, `'disk'`, `'timeout'`, `'pin'`
193
+ or `'secret'`. Cancelling with `signal` stops the step and leaves nothing behind.
194
+ - **The builder images** are built with `make web-builder` (wmd builds them; Docker Desktop is not used) into
195
+ `web/dist/builder/builder-<arch>.tar`, and their sha256 is pinned in `src/build/pin.js`. `make web-release` puts
196
+ them next to the release tgz.
197
+ - **Docker Hub's rate limit** applies to base images. `registryMirrors` gives BuildKit a mirror to try first.
198
+ - **Tests:** `bash ../scripts/bootlock.sh run --max 1800 -- bunx playwright test -c test/build --project=<browser>`
199
+ (`build.spec.js`: the 18 steps; `parity.spec.js`: the fast path against BuildKit). `test/build/seed-mirror.sh`
200
+ seeds a local mirror so the runs never touch Docker Hub.
201
+
158
202
  ## Running the tests
159
203
 
160
204
  ```sh
package/RELEASE.json CHANGED
@@ -1,38 +1,9 @@
1
1
  {
2
2
  "name": "wmd-web",
3
- "version": "0.3.2",
4
- "commit": "fa2b21cfd0fd0bf679498e6feeefc26c33f17372",
5
- "previous": null,
3
+ "version": "0.4.0",
4
+ "commit": "cb2d3874a3d65236337c76c3c59147fe152a010c",
5
+ "previous": "wmd-web-v0.1.0",
6
6
  "decisions": [
7
- "D-388",
8
- "D-395",
9
- "D-408",
10
- "D-415",
11
- "D-416",
12
- "D-417",
13
- "D-419",
14
- "D-423",
15
- "D-428",
16
- "D-430",
17
- "D-432",
18
- "D-433",
19
- "D-438",
20
- "D-442",
21
- "D-443",
22
- "D-444",
23
- "D-445",
24
- "D-446",
25
- "D-447",
26
- "D-448",
27
- "D-449",
28
- "D-451",
29
- "D-453",
30
- "D-454",
31
- "D-468",
32
- "D-476",
33
- "D-477",
34
- "D-481",
35
- "D-482",
36
7
  "D-486",
37
8
  "D-488",
38
9
  "D-494",
@@ -49,43 +20,53 @@
49
20
  "D-539",
50
21
  "D-542",
51
22
  "D-544",
52
- "D-545"
23
+ "D-545",
24
+ "D-547",
25
+ "D-548"
53
26
  ],
54
- "vmSources": "c7f2793941170d96db37d9a12a8a5ad072fda7de",
27
+ "vmSources": "7a707b82c43d3f1eb999476cad68e7ce79cc9cb5",
55
28
  "guest": {
56
29
  "arch": "arm64",
57
30
  "files": {
58
31
  "vm.wasm": {
59
- "sha256": "2504f4071537e205d78766f2c84219fe49ea4709314becff3a314765733e7fdf",
60
- "bytes": 1561314
32
+ "sha256": "40299e37e788189e6d3bea9d9cdaefa241a5acf27931e6aae14117c100b036ec",
33
+ "bytes": 1561499
61
34
  },
62
35
  "Image": {
63
- "sha256": "a6b3209cf69d5bc1dacf0f3ed67f92c535dbb4108d67b47091c2aef87c6d7b04",
64
- "bytes": 39047176
36
+ "sha256": "1d296c86b1fee0a10d0c2571b7e19854fe85ca0250cc9fadfe75eb268e48be7b",
37
+ "bytes": 39049224
65
38
  },
66
39
  "Image-4k": {
67
- "sha256": "8ff6e338a0b42d9f774b35423e0e7f9d7f779c635446e8de2a3beb83d3518f8a",
40
+ "sha256": "6296e3d9fc7bb06a26340bb4453b5f0970d6b55452903546ba2c762820e2df5b",
68
41
  "bytes": 38828040
69
42
  },
70
43
  "initramfs.cpio.gz": {
71
- "sha256": "a2260db90e5d8acdf12a2123f696b4512a1465856d5653e20a85504069a3480c",
72
- "bytes": 4685323
44
+ "sha256": "f008aa9e0f6b4e913242f9982d3b194dba7dc39042a24bc89be64630672125af",
45
+ "bytes": 4685204
73
46
  }
74
47
  }
75
48
  },
76
49
  "files": {
77
- "README.md": "6873b42beaf44e02eded67fa54aaa2c13d766597048fbc8633909b6d77b049e0",
50
+ "README.md": "3c12ca29aab5160b955ba3f50035f19be1dbf0278c4fb006e253a37ac9efbe6a",
78
51
  "export-page/coi-sw.js": "0300e670b413cb2e0aae35843fc583546bbf6687df971ab6df41cfa3b0d494d7",
79
52
  "export-page/index.html": "f5450c89e63601bbbbffef421f03491b231d1f143070012c1c5315d4b0059f43",
80
53
  "export-page/run.css": "b57adf1e76553549c1f89ff157b7973409dedc21889cd3932948cd05b51adc6f",
81
54
  "export-page/run.js": "717636a632a40469106ddf1383e198eaac5d83651b5866d0699ec04b82462b7f",
82
- "guest/arm64/Image": "a6b3209cf69d5bc1dacf0f3ed67f92c535dbb4108d67b47091c2aef87c6d7b04",
83
- "guest/arm64/Image-4k": "8ff6e338a0b42d9f774b35423e0e7f9d7f779c635446e8de2a3beb83d3518f8a",
84
- "guest/arm64/initramfs.cpio.gz": "a2260db90e5d8acdf12a2123f696b4512a1465856d5653e20a85504069a3480c",
85
- "guest/arm64/vm.wasm": "2504f4071537e205d78766f2c84219fe49ea4709314becff3a314765733e7fdf",
86
- "package.json": "17aa3a8406e8d2d85b647c426438d3fd75edcb59d83e257882227e7b93cccbc0",
55
+ "guest/arm64/Image": "1d296c86b1fee0a10d0c2571b7e19854fe85ca0250cc9fadfe75eb268e48be7b",
56
+ "guest/arm64/Image-4k": "6296e3d9fc7bb06a26340bb4453b5f0970d6b55452903546ba2c762820e2df5b",
57
+ "guest/arm64/initramfs.cpio.gz": "f008aa9e0f6b4e913242f9982d3b194dba7dc39042a24bc89be64630672125af",
58
+ "guest/arm64/vm.wasm": "40299e37e788189e6d3bea9d9cdaefa241a5acf27931e6aae14117c100b036ec",
59
+ "package.json": "778ef2457ab01f302387c811d796b7c61f97feb095725da285655629965f2b5e",
87
60
  "src/base.js": "982f8e46dbb727ec542a494ebdb884ccd19a31ff4e091425b2d1fc01b0d4754a",
88
- "src/boot.js": "911a40e01fa4833249743653b7698e8fed217d52a34056a92524562f3f8daacc",
61
+ "src/boot.js": "62f5cf901d797d53bef584b3c6c0c7fb1a4cc8e2450e9570d6289c72036ed025",
62
+ "src/build/builder.js": "751ea022e1f6f0c6ab79bd4efd6cfe0d705268ff7f699af87dcc64d1871ccc52",
63
+ "src/build/context.js": "4baf1884d366455f3f76f397575d515671585ebf5deb27bc35bc2b1dd9c6bc11",
64
+ "src/build/dockerfile.js": "5957fe3d4ac8ff8b312f980ee04bd1d98ad4373a61f9a33e02f1ff1108b6ea53",
65
+ "src/build/dockerignore.js": "8fc03d09da020f43ea483979b1183891b71d76f012379ab7813838ea0e53c546",
66
+ "src/build/fast.js": "bf7373a43cce995b21751c317a58d18438f9fd2f670266b314b1151ef3cdb9e8",
67
+ "src/build/inspect.js": "52aa26d332c08406e8eeaf8f72661d8acc36bbd4f547a7d68e72b4f0fe265a22",
68
+ "src/build/pin.js": "8c67f017ae3ec560d2638fdfc0bd91ddd758150af16c188bbe3bf2cb4d7e28b2",
69
+ "src/build/tar.js": "25cf477488d9a60da9015be9ca5830bad3ad24428da1546f0621411cb7f44949",
89
70
  "src/calib-worker.js": "3d610591a1b3b58f59c3a2ab457f43c90727d95973cd43055b3b73b76180611d",
90
71
  "src/fs/9p/codec.js": "69711eb4ebc84eff39256138169fe18f5a5066d6cd9e4ff2daf61c65380d7ef7",
91
72
  "src/fs/9p/server.js": "da0ba00ebb5522161bb0ef7cd94bfc9616e342bf865633dfef0ba5bc67627d7a",
@@ -110,7 +91,10 @@
110
91
  "src/host/ckpt-save.js": "e222571033ae74a4439596c5e90e03b2aa9a5516665dd290f8dd243712294523",
111
92
  "src/host/ckpt.js": "2155b5c8e6f609478805eb8a0b5d5210d47cdf35a80f79e0b9ae42b2c3170923",
112
93
  "src/host/clock.js": "aba6af3a8b1a985ae340892222e4b482d33afe20f5c8b3a4c7403ce87d1e247c",
113
- "src/host/ctl.js": "654c55c64efa3df4df8b7b28024a10bd0e7ed3afb89004f8a583b9e62f17f58f",
94
+ "src/host/ctl.js": "ca2ebbb940c9668cb0573c959cd2ff8982f96d58dfdb8dce39d304b89e033dd7",
95
+ "src/host/disk-map.js": "3d7e0d7d488ce25fc6a0e4dc0a9b1c8fc2af96bac1c03491a1d30be392af268b",
96
+ "src/host/disk-worker.js": "9d470cdc72d9e1fbf955e466ec2562abe780f2c6d89174ca3e4fbb1828f8dd76",
97
+ "src/host/disk.js": "373e23539e3916674b7e8708d2e3ae22578214129e56936765db5c8f465cb343",
114
98
  "src/host/errno.js": "26021d85962084be2c43c98fb3accafc2a8f11fbf3c8a5b8d484bd6a088ae4db",
115
99
  "src/host/fs.js": "d6753f74db35b7b01baa8709fc7fdcdfc8d638dfe42f454b0a833b22111e8f4a",
116
100
  "src/host/gcnudge.js": "4a6569cb73ae24a11fc4ac8c467380bd9efba0bb5fa9867b797bc4f153ab15ab",
@@ -127,9 +111,9 @@
127
111
  "src/host/stdio.js": "43973675578eb1d324c2c6d7078e51a6a1f121190ada5ef14a5551364b8c7f5e",
128
112
  "src/host/threads.js": "952381940e7f43114867e9a9714528f2f20c8064b6b0f2018ab075128dc601be",
129
113
  "src/host/wasi.js": "fbafa239b8fb4083f7e8c910b1adab38901e8e7cffd247284a9abb2847eaf4bd",
130
- "src/host/wmd-imports.js": "094f994758c239b75177781c0613decbbd926090b0e8974163ce7caf8a99234d",
114
+ "src/host/wmd-imports.js": "3a00562f7c9c7787ab3841b14dde5d903a78f43ed19c43a2f5e31fd8a14c09d2",
131
115
  "src/host/yamux.js": "f4044f14f7e836224bdb4b1e53e59c565ee478b83b2e428fffb9f7a048768f79",
132
- "src/image-files.js": "539856f3991b9f89202dd04ec3816ba8bce7787034ab810534c5170a205ffde7",
116
+ "src/image-files.js": "7bac16bd278d17ba291dfe4771564627b5efbdda927b429018a75e8b906dd4cd",
133
117
  "src/net/dhcp-dns.js": "fdfa7c7d5cbbb4a8774b7dee195bf2df22a0c3fe8f20ac9694b24485fa8f25d5",
134
118
  "src/net/net-worker.js": "0a2ce998f69807e386ded7ff1bee53687a1d59ed76d7ef46e2eb6f958b14ad38",
135
119
  "src/net/packet.js": "d658f26fa84324a88d7dbc26af9c4a9c10e977c3060f16f7db431496eacd1127",
@@ -143,12 +127,13 @@
143
127
  "src/net/wisp.js": "06c50159f416d40a912fe2cce4806388b938f2ab0220b2ee6da890630ce3d113",
144
128
  "src/registry/client.js": "fd38550e9afd82c5cb498513d245c33357a71697b3b2e6dd78538934bcc9d879",
145
129
  "src/registry/converter.js": "1fb8294fd5275f6772c27130cc9ba70bd46fafaf440884d42448036618229f37",
146
- "src/registry/hub.js": "22323e0f799a7078843514c9f87bbcdaa4173fd991ad4e059377befa773762d4",
130
+ "src/registry/hub.js": "cd672323bd1f96bd1d7e9eed80d4f36891e919e456fc0deb378fa0c7e3b6f249",
131
+ "src/registry/load-tar.js": "9dd2b5d8884c64247339b141ab5a57b8ab1a2e007c32aa45b1103b37405b0ed7",
147
132
  "src/registry/oci.js": "6a51ea16b34a65c0e732730f3e07d7ad16c50f6943a486ec05ca6aa3e98676a1",
148
133
  "src/registry/pack-worker.js": "fd6798851cb7815dacfcb67fc78c41654209c5504d8fd343aae524e99de1c741",
149
- "src/registry/pull.js": "66edf4602aca8d41cc68393d01a2da01c4667de7c493af5ae400e8f249d4e3ae",
134
+ "src/registry/pull.js": "24f2e01a76c9f7f27896af69b989f8c97f0ca31712b3e8110aaa5b5e3148b04b",
150
135
  "src/registry/ref.js": "24038e0fbcb6ac143cb8e8f7b9fac4dd0803d0891961786dc88dba01a4402e7b",
151
136
  "src/registry/wasi-mem.js": "a7f684343010892460db27ee8c1e38e47e797c93dc4ebe4c30185dca6ca56ee2",
152
- "src/worker.js": "fe23f1b99acedc65e56f92f7246923fdbdbefd085bae427b57cbe1402b4f5161"
137
+ "src/worker.js": "c654be359e741d5b1fde1be05f8909968d4e00d1fca61be169e60ea691daa662"
153
138
  }
154
139
  }
package/guest/arm64/Image CHANGED
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: package/guest/arm64/Image
3
- size: 39047176 bytes
4
- sha256: a6b3209cf69d5bc1dacf0f3ed67f92c535dbb4108d67b47091c2aef87c6d7b04
3
+ size: 39049224 bytes
4
+ sha256: 1d296c86b1fee0a10d0c2571b7e19854fe85ca0250cc9fadfe75eb268e48be7b
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: package/guest/arm64/Image-4k
3
3
  size: 38828040 bytes
4
- sha256: 8ff6e338a0b42d9f774b35423e0e7f9d7f779c635446e8de2a3beb83d3518f8a
4
+ sha256: 6296e3d9fc7bb06a26340bb4453b5f0970d6b55452903546ba2c762820e2df5b
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threadedstack/wmd-web",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "license": "MIT",
5
5
  "homepage": "https://github.com/threadedstack/wmd-releases",
6
6
  "repository": {
@@ -9,9 +9,9 @@
9
9
  },
10
10
  "description": "Browser host for wmd's guest vm.wasm (Web Workers + shared memory + WASI preview1)",
11
11
  "wmd": {
12
- "tag": "v0.1.17",
13
- "commit": "fa2b21cfd0fd0bf679498e6feeefc26c33f17372",
14
- "web": "0.3.2"
12
+ "tag": "v0.1.18",
13
+ "commit": "cb2d3874a3d65236337c76c3c59147fe152a010c",
14
+ "web": "0.4.0"
15
15
  },
16
16
  "type": "module",
17
17
  "main": "./src/boot.js",
package/relay/hosted.ts CHANGED
@@ -26,7 +26,7 @@ export type HostedOptions = {
26
26
  now?: () => number // ms since the epoch (tests)
27
27
  warn?: (line: string) => void // one-line operator notices (stderr)
28
28
  sites?: string[] // A: page origin patterns served (required: none listed serves no page)
29
- sitesPerAddrPerHour?: number // B: different sites per address group per hour (200: plan §16)
29
+ sitesPerAddrPerHour?: number // B: different sites per address group per hour (200: plan §16); a publish identity three times that
30
30
  sitesPerNetPerHour?: number // B: per network (800)
31
31
  monthBytes?: number // C: bytes relayed per UTC month (the operator's figure from the provider's free outbound data); 0 = no cap
32
32
  usageFile?: string // C: the month's counter survives a restart here
@@ -57,6 +57,7 @@ export type Handle = { ip: string; grp: string; origin: string; end: (why: Why)
57
57
  /** What a session holds from the governor until it ends. */
58
58
  export type Admission = {
59
59
  grp: string
60
+ publish: boolean
60
61
  net: string
61
62
  handle: Handle
62
63
  fair: Bucket | null
@@ -403,7 +404,7 @@ export function createGovernor(o: HostedOptions) {
403
404
  return m
404
405
  }
405
406
  const oldest = (m: Map<string, number>) => Math.min(...m.values()) + HOUR
406
- function sites(grp: string, host: string): Why | null {
407
+ function sites(grp: string, host: string, publish: boolean): Why | null {
407
408
  const t = now()
408
409
  const site = host.toLowerCase().replace(/\.$/, '')
409
410
  const net = coarseKey(grp)
@@ -414,8 +415,9 @@ export function createGovernor(o: HostedOptions) {
414
415
  fresh(a, t)
415
416
  fresh(n, t)
416
417
  if (!a.has(site)) {
417
- if (a.size >= c.sitesPerAddrPerHour) {
418
- return { rule: 'too-many-sites', words: `${c.sitesPerAddrPerHour} different sites in an hour from this address: a new site waits until one ages out; sites already reached keep working`, until: oldest(a) }
418
+ const max = publish ? 3 * c.sitesPerAddrPerHour : c.sitesPerAddrPerHour
419
+ if (a.size >= max) {
420
+ return { rule: 'too-many-sites', words: `${max} different sites in an hour from this address${publish ? ' while publishing' : ''}: a new site waits until one ages out; sites already reached keep working`, until: oldest(a) }
419
421
  }
420
422
  if (!n.has(site) && n.size >= c.sitesPerNetPerHour) {
421
423
  return { rule: 'too-many-sites', words: `${c.sitesPerNetPerHour} different sites in an hour from this network: a new site waits until one ages out; sites already reached keep working`, until: oldest(n) }
@@ -452,7 +454,7 @@ export function createGovernor(o: HostedOptions) {
452
454
  const grpOf = (s: Session): string => s.hx?.grp ?? s.p.grp
453
455
 
454
456
  /** A session from `ip` (address group `grp`) with this page origin is about to open. */
455
- function admitSession(i: { ip: string; grp: string; origin: string; end: (why: Why) => void; tell?: Tell; claimed?: boolean }): { ok: true; adm: Admission } | ({ ok: false } & Why) {
457
+ function admitSession(i: { ip: string; grp: string; origin: string; publish?: boolean; end: (why: Why) => void; tell?: Tell; claimed?: boolean }): { ok: true; adm: Admission } | ({ ok: false } & Why) {
456
458
  const no = (w: Why) => ({ ok: false as const, ...w })
457
459
  const denied = deniedClient(i.ip, i.origin)
458
460
  if (denied) return no(denied)
@@ -467,7 +469,7 @@ export function createGovernor(o: HostedOptions) {
467
469
  live.add(handle)
468
470
  let done = false
469
471
  const adm: Admission = {
470
- grp: i.grp, net: coarseKey(i.grp), handle, fair: c.linkBytesPerSec ? new Bucket(Infinity) : null, sent: 0, waiting: false,
472
+ grp: i.grp, publish: !!i.publish, net: coarseKey(i.grp), handle, fair: c.linkBytesPerSec ? new Bucket(Infinity) : null, sent: 0, waiting: false,
471
473
  release() {
472
474
  if (done) return
473
475
  done = true
@@ -488,7 +490,7 @@ export function createGovernor(o: HostedOptions) {
488
490
  function admitConnect(s: Session, host: string, _port: number): Why | { waitMs: number } | null {
489
491
  const closed = notAccepting()
490
492
  if (closed) return closed
491
- const crawl = sites(grpOf(s), host)
493
+ const crawl = sites(grpOf(s), host, !!s.hx?.publish)
492
494
  if (crawl) return crawl
493
495
  return dialGate(s)
494
496
  }
@@ -691,7 +693,7 @@ export function createGovernor(o: HostedOptions) {
691
693
  blockedAddr,
692
694
  liveStreams: () => used,
693
695
  limitsLine: () =>
694
- `only listed sites; ${c.sitesPerAddrPerHour} different sites an hour per address, ${c.sitesPerNetPerHour} per network; failed dials slow the sender` +
696
+ `only listed pages may use this relay; ${c.sitesPerAddrPerHour} different sites an hour per address, ${c.sitesPerNetPerHour} per network; failed dials slow the sender` +
695
697
  (c.monthBytes ? `; ${Math.round(c.monthBytes / 1e9)} GB a month, heavy users slowed first when the month runs ahead` : ''),
696
698
  stop() {
697
699
  clearInterval(ticker)
package/relay/main.ts CHANGED
@@ -53,7 +53,7 @@ Flags:
53
53
  --allow-loopback-ports <list> guests may reach this computer's loopback on these ports
54
54
  --block-ports <list> destination ports refused (none)
55
55
  --allow-hosts <list> only these names (name[:port|port], a leading . for subdomains)
56
- --hosted the public hosted profile (docs/relay-hosted.md): only listed sites, no crawling,
56
+ --hosted the public hosted profile (docs/relay-hosted.md): only listed pages may use this relay, no crawling,
57
57
  failed dials slowed, the month's data cap, host limits, deny lists, a control
58
58
  socket, /healthz, /readyz
59
59
  --abuse-contact <text> required: an address or URL, shown in the MOTD, /healthz and every refusal
@@ -326,7 +326,7 @@ export function relayOptions(c: Conf, env: Record<string, string | undefined> =
326
326
  if (c.behindTls && !isPrivateBind(host)) throw new UsageError(`--hosted --behind-tls binds only to loopback or a private address (the proxy\'s side), not ${host}: a public bind would let anyone skip the proxy and its TLS`)
327
327
  if (!tls && !c.behindTls && !isPrivateBind(host)) throw new UsageError(`--hosted refuses plain ws:// on the public address ${host}: give --tls-cert and --tls-key, or put a TLS proxy in front with --behind-tls (bound to loopback)`)
328
328
  if (c.localTls) throw new UsageError('--local-tls is for a person\'s own computer, not --hosted')
329
- if (!c.sitesFile) throw new UsageError('--hosted needs --sites-file <file>: the page origins it serves (JSON {"sites": [...]}); a hosted relay serves only listed sites')
329
+ if (!c.sitesFile) throw new UsageError('--hosted needs --sites-file <file>: the page origins it serves (JSON {"sites": [...]}); only listed pages may use this relay')
330
330
  }
331
331
  const w = c.wisp ?? {}
332
332
  const secret = env.WMD_RELAY_SECRET || randomBytes(32).toString('base64url') // tokens (wmn's issuer) only when the secret is shared
package/relay/relay.ts CHANGED
@@ -132,6 +132,7 @@ export function createRelay(o: RelayOptions) {
132
132
  const r = health(url.pathname)
133
133
  return req.method === 'HEAD' ? new Response(null, { status: r.status, headers: r.headers }) : r // same status and headers, no body
134
134
  }
135
+ if (gov && wisp && url.pathname === '/publish-token') return wisp.publishToken(req, srv, url)
135
136
  if (url.pathname === '/relay') {
136
137
  const protos = req.headers.get('sec-websocket-protocol') || ''
137
138
  if (wisp && protos.includes('wisp-v2') && !protos.includes(PROTOCOL)) return new Response(`this is the wmd-relay.v1 endpoint; Wisp is at ${wisp.path}\n`, { status: 400 })
package/relay/token.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // P-B10 relay: session tokens. A token is "v1.<payload>.<mac>", base64url without padding,
2
- // where the payload is JSON {sid, exp (ms since the epoch), uid?, grp?} and the mac is
2
+ // where the payload is JSON {sid, exp (ms since the epoch), uid?, grp?, kind?} and the mac is
3
3
  // HMAC-SHA256 of "v1.<payload>" with the relay's secret. Whoever serves the page
4
4
  // (test/serve.ts, or wmn's server) mints them with the same secret (TokenIssuer below); the
5
5
  // browser presents one as a WebSocket subprotocol "t.<token>" (not in the URL, so it stays
@@ -9,6 +9,8 @@
9
9
  // uid the guest identity the relay keeps caps for, across reconnects and fresh tokens
10
10
  // (one guest machine of one page); defaults to sid
11
11
  // grp the client the issuer saw (an address, a user): the relay caps sessions per grp
12
+ // kind "publish": a publish identity (TokenIssuer.issuePublish), which a hosted relay gives a higher sites-an-hour
13
+ // count (hosted.ts). Only the signature makes it one: the relay never takes a client's word for it
12
14
  //
13
15
  // Parsing is strict: a token is exactly one encoding of its payload and MAC, so the same
14
16
  // token never verifies under two spellings.
@@ -19,19 +21,20 @@ const b64u = (b: Buffer) => b.toString('base64url')
19
21
  const B64U = /^[A-Za-z0-9_-]+$/
20
22
  const ID = /^[A-Za-z0-9_.:\/-]{1,128}$/
21
23
 
22
- export type TokenFields = { sid?: string; uid?: string; grp?: string; ttlMs?: number; now?: number }
24
+ export type TokenFields = { sid?: string; uid?: string; grp?: string; kind?: string; ttlMs?: number; now?: number }
23
25
 
24
- export function mintToken(secret: string, { sid = b64u(randomBytes(12)), uid, grp, ttlMs = 60 * 60_000, now = Date.now() }: TokenFields = {}): string {
26
+ export function mintToken(secret: string, { sid = b64u(randomBytes(12)), uid, grp, kind, ttlMs = 60 * 60_000, now = Date.now() }: TokenFields = {}): string {
25
27
  if (!secret || secret.length < 16) throw new Error('relay secret: at least 16 characters')
26
28
  const body: Record<string, unknown> = { sid, exp: now + ttlMs }
27
29
  if (uid) body.uid = uid
28
30
  if (grp) body.grp = grp
31
+ if (kind) body.kind = kind
29
32
  const payload = b64u(Buffer.from(JSON.stringify(body)))
30
33
  const mac = createHmac('sha256', secret).update(`v1.${payload}`).digest()
31
34
  return `v1.${payload}.${b64u(mac)}`
32
35
  }
33
36
 
34
- export type TokenInfo = { sid: string; exp: number; uid?: string; grp?: string }
37
+ export type TokenInfo = { sid: string; exp: number; uid?: string; grp?: string; kind?: 'publish' }
35
38
 
36
39
  /** The token's session, or an error string. */
37
40
  export function verifyToken(secret: string, token: string, now = Date.now()): TokenInfo | string {
@@ -52,8 +55,9 @@ export function verifyToken(secret: string, token: string, now = Date.now()): To
52
55
  }
53
56
  if (typeof info !== 'object' || !info || typeof info.sid !== 'string' || !ID.test(info.sid) || typeof info.exp !== 'number') return 'bad token'
54
57
  for (const k of ['uid', 'grp'] as const) if (info[k] !== undefined && (typeof info[k] !== 'string' || !ID.test(info[k]!))) return 'bad token'
58
+ if (info.kind !== undefined && info.kind !== 'publish') return 'bad token'
55
59
  if (info.exp <= now) return 'token expired'
56
- return { sid: info.sid, exp: info.exp, uid: info.uid, grp: info.grp }
60
+ return { sid: info.sid, exp: info.exp, uid: info.uid, grp: info.grp, ...(info.kind ? { kind: info.kind } : {}) }
57
61
  }
58
62
 
59
63
  /** The key a client's limits are kept under: IPv6 addresses by their /64 (one subscriber holds 2^64 of them). */
@@ -79,6 +83,7 @@ export function clientKey(client: string): string {
79
83
  * an hour. At most `maxClients` clients are remembered (the least recently used go first).
80
84
  */
81
85
  export const MIN_TTL_MS = 60_000
86
+ export const PUBLISH_TTL_MS = 60 * 60_000
82
87
  export class TokenIssuer {
83
88
  perMinute: number
84
89
  instancesPerHour: number
@@ -86,6 +91,7 @@ export class TokenIssuer {
86
91
  maxClients: number
87
92
  minTtlMs: number
88
93
  clients = new Map<string, { times: number[]; instances: Map<string, number> }>() // least recently used first
94
+ publishing = new Map<string, { instance: string; token: string; until: number }>() // per client: its live publish identity, in the order they end
89
95
  constructor(public secret: string, { perMinute = 60, instancesPerHour = 20, maxTtlMs = 60 * 60_000, maxClients = 10_000, minTtlMs = MIN_TTL_MS } = {}) {
90
96
  this.perMinute = perMinute
91
97
  this.instancesPerHour = instancesPerHour
@@ -113,4 +119,27 @@ export class TokenIssuer {
113
119
  const ttl = Math.min(Math.max(ttlMs, this.minTtlMs), this.maxTtlMs)
114
120
  return { token: mintToken(this.secret, { uid: `${key}/${instance}`, grp: key, ttlMs: ttl, now }) }
115
121
  }
122
+ /** A publish identity for this client, for one hour; at most one per client at a time. The page that holds it gets the
123
+ same token again (a reconnect); another page of the client is refused until its hour is over. When `maxClients`
124
+ identities are live, a new one is refused (503) until the first ends: a live one is never dropped. */
125
+ issuePublish(client: string, instance: string, now = Date.now()): { token: string; until: number } | { error: string; status: number; until?: number } {
126
+ if (!ID.test(client)) return { error: 'bad client', status: 400 }
127
+ if (!/^[A-Za-z0-9_-]{8,64}$/.test(instance || '')) return { error: 'instance: 8-64 characters [A-Za-z0-9_-]', status: 400 }
128
+ const key = clientKey(client)
129
+ for (const [k, p] of this.publishing) {
130
+ if (p.until > now) break
131
+ this.publishing.delete(k)
132
+ }
133
+ const live = this.publishing.get(key)
134
+ if (live && live.instance === instance) return { token: live.token, until: live.until }
135
+ if (live) return { error: `this address already has a publish identity; another can be had in ${Math.ceil((live.until - now) / 60_000)} min`, status: 429, until: live.until }
136
+ if (this.publishing.size >= this.maxClients) {
137
+ const first = this.publishing.values().next().value!
138
+ return { error: `the relay is giving out as many publish identities as it can hold; try again in ${Math.ceil((first.until - now) / 60_000)} min`, status: 503, until: first.until }
139
+ }
140
+ const until = now + PUBLISH_TTL_MS
141
+ const token = mintToken(this.secret, { uid: `${key}/publish.${instance}`, grp: key, kind: 'publish', ttlMs: PUBLISH_TTL_MS, now })
142
+ this.publishing.set(key, { instance, token, until })
143
+ return { token, until }
144
+ }
116
145
  }
package/relay/wisp.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  // W59: the Wisp v2 adapter (src/net/wisp.js, docs/wisp-wmd-extensions.md) over the relay engine.
2
2
  //
3
3
  // Who may use it (plan §5):
4
- // - token: the HMAC `t.<token>` subprotocol, as wmd-relay.v1 (wmn's bundled relay)
4
+ // - token: the HMAC `t.<token>` subprotocol, as wmd-relay.v1 (wmn's bundled relay); on a hosted relay, the publish
5
+ // identity it issues itself (relay.ts /publish-token), used only from the address it was given to
5
6
  // - open: no key (the standalone relay's default); identity = the client's instance id (0xE7)
6
7
  // plus its address group, bandwidth shared per address
7
8
  // - keys (optional): Wisp password auth, scrypt hashes; a relay with keys refuses keyless clients
@@ -23,7 +24,7 @@ import { type AllowEntry, type Engine, type Principal, type Session, type Shared
23
24
  import { type Governor, type Why, resolveClient } from './hosted.ts'
24
25
  import { type OwnerKey, verifySecret } from './keys.ts'
25
26
  import type { Cidr } from './policy.ts'
26
- import { clientKey, verifyToken } from './token.ts'
27
+ import { TokenIssuer, clientKey, verifyToken } from './token.ts'
27
28
 
28
29
  export type WispOptions = {
29
30
  enabled?: boolean
@@ -184,6 +185,32 @@ export function createWisp(engine: Engine, o: { secret?: string; port: () => num
184
185
  return reply(body, status)
185
186
  }
186
187
 
188
+ const issuer = w.hosted && o.secret ? new TokenIssuer(o.secret) : null
189
+
190
+ /** Hosted: GET /publish-token?instance=ID from a listed page gives {token, until}, a publish identity for this address
191
+ (token.ts issuePublish), or {error, until?} in words. The page's origin may read the answer. */
192
+ function publishToken(req: Request, srv: any, url: URL): Response {
193
+ const origin = req.headers.get('origin') || ''
194
+ if (!issuer || origin.length > 256 || !w.hosted!.siteAllowed(origin)) {
195
+ log({ ev: 'publish-token-refused', why: 'not-allowed-site' })
196
+ return reply('this page is not one of the sites this relay serves\n', 403)
197
+ }
198
+ const headers = { 'access-control-allow-origin': origin, vary: 'Origin', 'cache-control': 'no-store' }
199
+ if (req.method !== 'GET') return new Response(`GET only\n${w.hosted!.contactLine}`, { status: 405, headers })
200
+ const ip = clientIp(req, srv)
201
+ if (typeof ip !== 'string') {
202
+ for (const [k, v] of Object.entries(headers)) ip.headers.set(k, v)
203
+ return ip
204
+ }
205
+ const r = issuer.issuePublish(ip, url.searchParams.get('instance') || '')
206
+ if ('token' in r) {
207
+ log({ ev: 'publish-token', addr: clientKey(ip), origin })
208
+ return Response.json(r, { headers })
209
+ }
210
+ log({ ev: 'publish-token-refused', why: r.error, addr: clientKey(ip) })
211
+ return Response.json({ error: r.error, ...(r.until ? { until: r.until } : {}), abuseContact: w.hosted!.contact }, { status: r.status, headers })
212
+ }
213
+
187
214
  async function fetch(req: Request, srv: any, url: URL): Promise<Response | undefined> {
188
215
  const ip = clientIp(req, srv)
189
216
  if (typeof ip !== 'string') return ip
@@ -210,11 +237,13 @@ export function createWisp(engine: Engine, o: { secret?: string; port: () => num
210
237
  let uid = ''
211
238
  let grp = ''
212
239
  let exp = Infinity
240
+ let publish = false
213
241
  let p: Principal | null = null
214
242
  const tok = protos.find(x => x.startsWith('t.'))
215
243
  if (tok && o.secret) {
216
244
  const info = verifyToken(o.secret, tok.slice(2))
217
245
  if (typeof info === 'string') return refuse(401, `${info}\n`, info, { origin })
246
+ if (w.hosted && info.grp !== addr) return refuse(401, 'this token was given to another address\n', 'token of another address', { addr })
218
247
  if (engine.hasSession(info.sid)) {
219
248
  stats.refusedSessions++
220
249
  return reply('session already connected\n', 409)
@@ -224,7 +253,9 @@ export function createWisp(engine: Engine, o: { secret?: string; port: () => num
224
253
  uid = info.uid || info.sid
225
254
  grp = info.grp || uid
226
255
  exp = info.exp
227
- const a = engine.admit(uid, grp, exp, { proto: 'wisp' })
256
+ publish = info.kind === 'publish'
257
+ // a hosted publish session shares its address's bandwidth with the address's open sessions
258
+ const a = engine.admit(uid, grp, exp, { proto: 'wisp' }, w.hosted && publish ? sharedFor(grp) : undefined)
228
259
  if (!a.ok) return reply(a.body, a.status)
229
260
  p = a.p
230
261
  } else if (rest) {
@@ -271,7 +302,7 @@ export function createWisp(engine: Engine, o: { secret?: string; port: () => num
271
302
  const full = w.hosted?.claimHandshake() ?? null
272
303
  const headers: Record<string, string> = {}
273
304
  if (v2 && protos.length) headers['Sec-WebSocket-Protocol'] = protos.includes(SUBPROTOCOL) ? SUBPROTOCOL : protos[0]
274
- const data = { kind: 'wisp', v2, mode, addr, ip, sid, uid, grp, exp, p, key, origin, hs: null as any, hsTimer: null as any, pre, claimed: !!w.hosted && !full, refuse: full as Why | null, at: performance.now() }
305
+ const data = { kind: 'wisp', v2, mode, addr, ip, sid, uid, grp, exp, publish, p, key, origin, hs: null as any, hsTimer: null as any, pre, claimed: !!w.hosted && !full, refuse: full as Why | null, at: performance.now() }
275
306
  const ok = srv.upgrade(req, Object.keys(headers).length ? { data, headers } : { data }) // Bun refuses an empty headers object
276
307
  if (!ok) {
277
308
  release(data)
@@ -417,7 +448,7 @@ export function createWisp(engine: Engine, o: { secret?: string; port: () => num
417
448
  tell(REASON_KIND.REFUSED, why.rule, why.until ?? 0, why.words)
418
449
  ws.close(4011, why.words.slice(0, 120))
419
450
  }
420
- adm = w.hosted.admitSession({ ip: d.ip, grp: d.grp, origin: d.origin, end, tell, claimed: d.claimed })
451
+ adm = w.hosted.admitSession({ ip: d.ip, grp: d.grp, origin: d.origin, publish: d.publish, end, tell, claimed: d.claimed })
421
452
  if (!adm.ok) return refuseHs(ws, client, adm)
422
453
  d.claimed = false // the session holds the socket's count now
423
454
  d.rel = adm.adm.release
@@ -664,6 +695,7 @@ export function createWisp(engine: Engine, o: { secret?: string; port: () => num
664
695
  ws.close(1013, 'the relay is full')
665
696
  },
666
697
  fetch,
698
+ publishToken,
667
699
  open: open_,
668
700
  message,
669
701
  close,
package/src/boot.js CHANGED
@@ -51,9 +51,11 @@ import {
51
51
  } from './host/ckpt.js'
52
52
  import { captureJit, jitSnapStats, modulesOf, ownersNeedingHub, restoreStore } from './host/jit-snap.js'
53
53
  export { CheckpointError, memoryStore, opfsStore } from './host/ckpt.js'
54
+ export { DiskError, deleteDisk, openDisk, sweepDisks } from './host/disk.js'
54
55
  export { NetSwitch } from './net/switch.js'
55
56
  export { ImageFiles, openImage, parseGroup, parsePasswd, resolveUser } from './image-files.js'
56
57
  export { P9_CACHE_DEFAULT, p9InitCmdline, p9MountOptions } from './host/p9mount.js'
58
+ export { loadImageTar } from './registry/load-tar.js'
57
59
 
58
60
  const PAGE = 65536
59
61
  /** A trap with the memory this close to its maximum is reported as out of memory. */
@@ -414,12 +416,30 @@ function toShared(bytes) {
414
416
  return sab
415
417
  }
416
418
 
419
+ /** vm.wasm's arguments for a machine with `disk`: --swap <MiB> and wmd.disk=data appended to --append. */
420
+ export function diskArgs(args, disk) {
421
+ if (args.includes('--swap')) throw new TypeError('--swap and a disk: give one')
422
+ const mib = disk.bytes / 2 ** 20
423
+ if (!Number.isInteger(mib) || mib < 1) throw new TypeError(`disk size ${disk.bytes} is not whole MiB`)
424
+ const out = [...args, '--swap', String(mib)]
425
+ const i = out.indexOf('--append')
426
+ if (i < 0 || i + 1 >= out.length) throw new TypeError('a disk needs --append (the kernel line gets wmd.disk=data)')
427
+ // the disk holds data: a wmd.disk= of any other value would let the guest init write a swap header over it
428
+ const given = out[i + 1].match(/(?:^| )wmd\.disk(?:=(\S*))?(?= |$)/g)
429
+ if (given && given.some(g => g.trim() !== 'wmd.disk=data')) throw new TypeError(`a disk needs wmd.disk=data on the kernel line, not ${given.map(g => g.trim()).join(' ')}`)
430
+ if (!given) out[i + 1] = `${out[i + 1]} wmd.disk=data`
431
+ return out
432
+ }
433
+
417
434
  /**
418
435
  * @param {object} o
419
436
  * @param {string|Uint8Array|ArrayBuffer} o.vmUrl URL of vm.wasm, or its bytes
420
437
  * @param {Record<string,string|Uint8Array>} o.files preopen contents: path → URL or bytes
421
438
  * @param {string[]} o.args vm.wasm arguments (after argv[0])
422
439
  * @param {string[]} [o.env] "K=V" (the runner passes only WMD_*)
440
+ * @param {object} [o.disk] a disk from openDisk (host/disk.js): the guest gets it as virtio-blk
441
+ * /dev/vda (vm.wasm's --swap MiB sizes the device) with wmd.disk=data on the kernel line, so guest init leaves
442
+ * it alone. A machine with a disk takes no checkpoints: guest memory and the disk would not match
423
443
  * @param {boolean} [o.heapReserve] W21 (default true, false in mobile WebKit: heapReserveDefault, D-516): vm.wasm grows its memory to the maximum
424
444
  * before any thread starts (WMD_HEAP_RESERVE=1), so memory never grows under running
425
445
  * vCPUs. Chromium checks memory.fill/copy against a per-Worker size that lags another
@@ -520,6 +540,10 @@ export async function bootWmd(o) {
520
540
  o.onStderr?.(text)
521
541
  }
522
542
  const preopen = o.preopen || '/g'
543
+ if (o.disk) {
544
+ if (o.checkpoint || o.restore || o.restoreLazy) throw new TypeError('a machine with a disk takes no checkpoints')
545
+ o = { ...o, args: diskArgs(o.args, o.disk) }
546
+ }
523
547
  const argv = ['vm.wasm', ...o.args]
524
548
  const env = vmEnv(o.env, o.heapReserve ?? heapReserveDefault())
525
549
  // the default Worker is started from a literal new URL(..., import.meta.url) so bundlers (Vite, webpack)
@@ -1550,6 +1574,7 @@ export async function bootWmd(o) {
1550
1574
  tableMin,
1551
1575
  memory,
1552
1576
  ram: ramMem,
1577
+ blk: o.disk?.mailbox || null,
1553
1578
  ctlSab,
1554
1579
  files,
1555
1580
  preopen,