@threadedstack/wmd-web 0.1.16 → 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 +48 -2
- package/RELEASE.json +41 -56
- package/guest/arm64/Image +2 -2
- package/guest/arm64/Image-4k +1 -1
- package/guest/arm64/initramfs.cpio.gz +0 -0
- package/guest/arm64/vm.wasm +0 -0
- package/package.json +4 -4
- package/relay/hosted.ts +10 -8
- package/relay/main.ts +2 -2
- package/relay/relay.ts +1 -0
- package/relay/token.ts +34 -5
- package/relay/wisp.ts +37 -5
- package/src/boot.js +53 -9
- package/src/build/builder.js +791 -0
- package/src/build/context.js +349 -0
- package/src/build/dockerfile.js +1055 -0
- package/src/build/dockerignore.js +270 -0
- package/src/build/fast.js +1187 -0
- package/src/build/inspect.js +78 -0
- package/src/build/pin.js +6 -0
- package/src/build/tar.js +304 -0
- package/src/host/ckpt-point.js +55 -10
- package/src/host/ckpt.js +35 -2
- package/src/host/ctl.js +17 -0
- package/src/host/disk-map.js +330 -0
- package/src/host/disk-worker.js +125 -0
- package/src/host/disk.js +219 -0
- package/src/host/wmd-imports.js +3 -3
- package/src/image-files.js +2 -1
- package/src/registry/hub.js +113 -44
- package/src/registry/load-tar.js +473 -0
- package/src/registry/pull.js +5 -3
- package/src/worker.js +5 -1
- package/wmdpack.wasm +0 -0
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
|
|
@@ -2594,8 +2638,10 @@ engines' memory64 limit), passes `WMD_RAM_FLAT=1` to vm.wasm and hands the memor
|
|
|
2594
2638
|
slack (`flatMaxPages`), whatever the guest's RAM. Leave `maxPages` out for such a boot.
|
|
2595
2639
|
- `flatRam: true|false` forces the layout on or off; unset, it is chosen for `--ram` above 1536 when the engine
|
|
2596
2640
|
has memory64 and `allowLargeRam` is not set. Safari has no memory64: the old limit and its message stay.
|
|
2597
|
-
-
|
|
2598
|
-
|
|
2641
|
+
- Checkpoints and restores work as for any guest: a checkpoint also holds the RAM memory's written pages (sections
|
|
2642
|
+
`ram-index` and `ram`, header `ramPages`) and restores only into a boot whose RAM memory has the same size.
|
|
2643
|
+
- A checkpoint, flat or not, reads only the RAM pages the guest has written (vm.wasm's `vm_touched_map`, a bit per
|
|
2644
|
+
64 KiB set before the first write): a page never written is not read, so the browser never backs it with memory.
|
|
2599
2645
|
|
|
2600
2646
|
Measured in Chromium (page/b7.js, `ram=`): 4096 shows `Mem: 3976`; 8192 with 4 vCPUs held 5366 MB in six
|
|
2601
2647
|
processes with every byte checked; the same work at 1536 (small) and 4096 (flat) took 35 s and 39 s.
|
package/RELEASE.json
CHANGED
|
@@ -1,38 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wmd-web",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"commit": "
|
|
5
|
-
"previous":
|
|
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": "
|
|
27
|
+
"vmSources": "7a707b82c43d3f1eb999476cad68e7ce79cc9cb5",
|
|
55
28
|
"guest": {
|
|
56
29
|
"arch": "arm64",
|
|
57
30
|
"files": {
|
|
58
31
|
"vm.wasm": {
|
|
59
|
-
"sha256": "
|
|
60
|
-
"bytes":
|
|
32
|
+
"sha256": "40299e37e788189e6d3bea9d9cdaefa241a5acf27931e6aae14117c100b036ec",
|
|
33
|
+
"bytes": 1561499
|
|
61
34
|
},
|
|
62
35
|
"Image": {
|
|
63
|
-
"sha256": "
|
|
64
|
-
"bytes":
|
|
36
|
+
"sha256": "1d296c86b1fee0a10d0c2571b7e19854fe85ca0250cc9fadfe75eb268e48be7b",
|
|
37
|
+
"bytes": 39049224
|
|
65
38
|
},
|
|
66
39
|
"Image-4k": {
|
|
67
|
-
"sha256": "
|
|
40
|
+
"sha256": "6296e3d9fc7bb06a26340bb4453b5f0970d6b55452903546ba2c762820e2df5b",
|
|
68
41
|
"bytes": 38828040
|
|
69
42
|
},
|
|
70
43
|
"initramfs.cpio.gz": {
|
|
71
|
-
"sha256": "
|
|
72
|
-
"bytes":
|
|
44
|
+
"sha256": "f008aa9e0f6b4e913242f9982d3b194dba7dc39042a24bc89be64630672125af",
|
|
45
|
+
"bytes": 4685204
|
|
73
46
|
}
|
|
74
47
|
}
|
|
75
48
|
},
|
|
76
49
|
"files": {
|
|
77
|
-
"README.md": "
|
|
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": "
|
|
83
|
-
"guest/arm64/Image-4k": "
|
|
84
|
-
"guest/arm64/initramfs.cpio.gz": "
|
|
85
|
-
"guest/arm64/vm.wasm": "
|
|
86
|
-
"package.json": "
|
|
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": "
|
|
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",
|
|
@@ -106,11 +87,14 @@
|
|
|
106
87
|
"src/host/chan.js": "43104dcdd4d17d13f55a71906b398bdb52d9e96c6fca239310f2ee1ccbb4d4ad",
|
|
107
88
|
"src/host/ckpt-file.js": "da7938fa640a062e77bdfb59caad5b3b1fcbf6150e3220de3be23f253f4c4497",
|
|
108
89
|
"src/host/ckpt-io.js": "3fbe74f0254c597f10d088c85e8ba72725502d86ae4dd8c6d2bf0821a93c873f",
|
|
109
|
-
"src/host/ckpt-point.js": "
|
|
90
|
+
"src/host/ckpt-point.js": "42bc16b3f754de3cd4e39f37a0feca2ffd8b4231cd72ec32bd6e1ea734c3c8c7",
|
|
110
91
|
"src/host/ckpt-save.js": "e222571033ae74a4439596c5e90e03b2aa9a5516665dd290f8dd243712294523",
|
|
111
|
-
"src/host/ckpt.js": "
|
|
92
|
+
"src/host/ckpt.js": "2155b5c8e6f609478805eb8a0b5d5210d47cdf35a80f79e0b9ae42b2c3170923",
|
|
112
93
|
"src/host/clock.js": "aba6af3a8b1a985ae340892222e4b482d33afe20f5c8b3a4c7403ce87d1e247c",
|
|
113
|
-
"src/host/ctl.js": "
|
|
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": "
|
|
114
|
+
"src/host/wmd-imports.js": "3a00562f7c9c7787ab3841b14dde5d903a78f43ed19c43a2f5e31fd8a14c09d2",
|
|
131
115
|
"src/host/yamux.js": "f4044f14f7e836224bdb4b1e53e59c565ee478b83b2e428fffb9f7a048768f79",
|
|
132
|
-
"src/image-files.js": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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:
|
|
4
|
-
sha256:
|
|
3
|
+
size: 39049224 bytes
|
|
4
|
+
sha256: 1d296c86b1fee0a10d0c2571b7e19854fe85ca0250cc9fadfe75eb268e48be7b
|
package/guest/arm64/Image-4k
CHANGED
|
Binary file
|
package/guest/arm64/vm.wasm
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@threadedstack/wmd-web",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
13
|
-
"commit": "
|
|
14
|
-
"web": "0.
|
|
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
|
-
|
|
418
|
-
|
|
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
|
|
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
|
|
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": [...]});
|
|
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
|
-
|
|
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,
|