@sysce/stomp 2.0.0 → 4.2.1-nightly.202611100524.gd963224

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +222 -19
  2. package/dist/Controller.js +2 -0
  3. package/dist/Controller.js.map +7 -0
  4. package/dist/SearchBuilder.js +2 -0
  5. package/dist/SearchBuilder.js.map +7 -0
  6. package/dist/controller.mjs +2 -0
  7. package/dist/controller.mjs.map +7 -0
  8. package/dist/controllerFrame.html +35 -0
  9. package/dist/controllerFrame.js +2 -0
  10. package/dist/controllerFrame.js.map +7 -0
  11. package/dist/injectDocument.js +99 -0
  12. package/dist/injectDocument.js.map +7 -0
  13. package/dist/injectWorker.js +85 -0
  14. package/dist/injectWorker.js.map +7 -0
  15. package/dist/rewriteWorker.js +53 -0
  16. package/dist/rewriteWorker.js.map +7 -0
  17. package/dist/serviceWorker.js +125 -0
  18. package/dist/serviceWorker.js.map +7 -0
  19. package/dist/transportWorker.js +2 -0
  20. package/dist/transportWorker.js.map +7 -0
  21. package/dist/types/Codecs.d.ts +29 -0
  22. package/dist/types/StompURL.d.ts +12 -0
  23. package/dist/types/acceptLanguage.d.ts +1 -0
  24. package/dist/types/bootstrapper/SearchBuilder.d.ts +5 -0
  25. package/dist/types/config.d.ts +109 -0
  26. package/dist/types/controller/history.d.ts +26 -0
  27. package/dist/types/controller/id.d.ts +4 -0
  28. package/dist/types/controller/index.d.ts +347 -0
  29. package/dist/types/controller/messages.d.ts +111 -0
  30. package/dist/types/controller/remote.d.ts +145 -0
  31. package/dist/types/controller/rewriteWorkers.d.ts +19 -0
  32. package/dist/types/helperNames.d.ts +53 -0
  33. package/dist/types/hooks.d.ts +81 -0
  34. package/dist/types/transport/RemoteTransport.d.ts +21 -0
  35. package/dist/types/transport/TransportWorker.d.ts +26 -0
  36. package/dist/types/transport/bodyPort.d.ts +11 -0
  37. package/dist/types/transport/construct.d.ts +13 -0
  38. package/dist/types/transport/http3/Http3Transport.d.ts +66 -0
  39. package/dist/types/transport/http3/altSvc.d.ts +51 -0
  40. package/dist/types/transport/http3/dnsHttps.d.ts +17 -0
  41. package/dist/types/transport/messages.d.ts +378 -0
  42. package/dist/types/transport/rpc.d.ts +25 -0
  43. package/dist/types/transport/serveTransport.d.ts +13 -0
  44. package/dist/types/version.d.ts +1 -0
  45. package/package.json +71 -46
  46. package/dist/bootstrapper.js +0 -2
  47. package/dist/bootstrapper.js.map +0 -1
  48. package/dist/client.js +0 -3
  49. package/dist/client.js.LICENSE.txt +0 -14
  50. package/dist/client.js.map +0 -1
  51. package/dist/worker.js +0 -3
  52. package/dist/worker.js.LICENSE.txt +0 -14
  53. package/dist/worker.js.map +0 -1
package/README.md CHANGED
@@ -1,38 +1,241 @@
1
- # SystemYA's TOMP implementation (STomp)
1
+ # @sysce/stomp
2
2
 
3
- This repository implements the proxy model found [here](https://github.com/tomphttp/specifications/blob/master/ProxyModel.md).
3
+ A rewrite of the Stomp proxy.
4
4
 
5
- - [Demo](https://tomp.sys32.dev/)
6
- - [Frontend](https://github.com/sysce/tomp-demo)
5
+ Original implementation of https://github.com/tomphttp/specifications.
7
6
 
8
- ## Usage
7
+ Stomp rewrites HTML, CSS and JavaScript inside a service worker and tunnels the
8
+ actual HTTP and WebSocket traffic through a
9
+ [proxy-transports](https://github.com/MercuryWorkshop/proxy-transports)
10
+ transport (for example [epoxy](https://github.com/MercuryWorkshop/EpoxyTransport)
11
+ or [libcurl](https://github.com/MercuryWorkshop/CurlTransport)) that lives in
12
+ the page that owns the proxy. Proxied content is shown in frames.
9
13
 
10
- We provide a command-line interface for compiling the code.
14
+ ## Usage
11
15
 
12
- For more features, specify the `--help` option when running the CLI.
16
+ Serve `dist/` (its path is exported from `@sysce/stomp/path` as `stompPath`)
17
+ from a directory of your site, register the service worker from that directory
18
+ (`register()` uses it as the scope and resolves once the worker is activated),
19
+ and hand the registration to a controller together with a transport:
13
20
 
14
- ## Wiki
21
+ ```js
22
+ import EpoxyTransport from '@mercuryworkshop/epoxy-transport';
23
+ import { StompController } from '@sysce/stomp';
15
24
 
16
- Documentation for components such as the Bootstrapper can be found in [the wiki](https://github.com/sysce/tomp/wiki).
25
+ const registration = await StompController.register('/stomp/');
17
26
 
18
- ### Quickstart
27
+ const controller = new StompController({
28
+ serviceworker: registration,
29
+ transport: new EpoxyTransport({ wisp: 'wss://example.com/wisp/' }),
30
+ directory: '/stomp/',
31
+ codec: 'xor',
32
+ });
19
33
 
20
- 1. Clone the repository locally
34
+ await controller.wait();
21
35
 
22
- ```sh
23
- $ git clone https://github.com/sysce/stomp.git
24
- > Cloning into stomp...
25
- $ cd stomp
36
+ const frame = controller.createFrame(document.querySelector('iframe'));
37
+ await frame.go('https://example.com');
26
38
  ```
27
39
 
28
- 2. Install dependencies
40
+ `dist/Controller.js` and `dist/SearchBuilder.js` expose the same API as classic
41
+ scripts through the `StompController` and `SearchBuilder` globals.
42
+
43
+ Any `ProxyTransport` works. In testing against a `wisp-js` server, epoxy was
44
+ the better choice for HTTP-heavy sites and libcurl for WebSockets; see the
45
+ changelog's known limitations before picking one.
46
+
47
+ ## API
48
+
49
+ `StompController` (also `window.StompController` from `dist/Controller.js`):
50
+
51
+ | Member | Description |
52
+ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
53
+ | `StompController.register(directory?, { assets, updateViaCache })` | static: registers the service worker served from `directory` (the constructor's default when omitted; `assets.serviceWorker` names the file) with that scope and `updateViaCache: 'none'` (overridable), resolves with the registration once its worker is activated, 30 s at the most; a worker stuck installing never hangs the host |
54
+ | `new StompController(init)` | `init.serviceworker` (a `ServiceWorker`, or a `ServiceWorkerRegistration` whose active, waiting or installing worker is taken and the handshake made once it is activated), `init.transport` (an instance or a descriptor, next row), optional `codec`, `directory` |
55
+ | `init.transport` | a `ProxyTransport` instance (served from this page) or a `TransportDescriptor` `{ module, options, host? }`: the controller imports and constructs it in a dedicated Worker (`transportWorker.js` from `directory`), off the page's thread, so a proxied frame's synchronous XHR that needs the network completes; `host: 'page'` imports it in the page instead. The module is an ES module importable in a worker whose default export is a ProxyTransport class (epoxy and libcurl qualify) |
56
+ | `init.transport.http3` | optional, off by default: HTTP/3 over QUIC for origins that offer it (`Alt-Svc`, an HTTPS DNS record with `dns`, or `force`), carried by the transport's wisp UDP streams; `true` or `{ force, dns, timeout, wait, module, pem }`. Needs epoxy (`client.connect_udp`) and a wisp server that relays UDP; until a handshake has worked, requests go over the transport as before, and a failure falls back with no wait. Works in `RemoteController` descriptors too. See docs/http3.md |
57
+ | `transportHost` | `worker` or `page`: where the transport runs; `page` for an instance, `host: 'page'`, or after the worker fell back |
58
+ | `transporthost` event | `detail: { host: 'page', error }`: the worker could not start or could not import the module, so the page imported it instead (when that fails too, requests fail at once until `setTransport()`) |
59
+ | `init.allowInvalidJs` | a script the rewriter cannot parse runs as it was (default); `false` makes it fail with a `SyntaxError` naming the rewriter error, so a broken rewrite is loud |
60
+ | `init.membrane` | the window membrane, on unless `false`: every window and every object of another window's realm reaches a page through a proxy that checks on each use which site the window shows now, and acts as the browser's cross-origin window when it is another proxied site. `false` turns it off for a site it breaks. The setting reaches every document; the membrane itself is being built, and until it lands both values behave alike |
61
+ | `init.rewriteWorkers` | how many dedicated Workers (`rewriteWorker.js` from `directory`) the controller starts to rewrite scripts for the service worker, whose own thread rewrites one at a time; default half the cores, one to four, `0` for none. A worker is used once it says it is ready; whenever none is (none started, one failed to load or crashed, this page closed) the service worker rewrites the script itself, the same output |
62
+ | `init.rewriteTimeout` | milliseconds the service worker waits for a rewrite worker before it rewrites the script itself and stops using that worker; default 10000 |
63
+ | `init.assets` | optional names of the served files (`serviceWorker`, `injectDocument`, `injectWorker`, `controllerFrame`, `controllerFrameScript`, `transportWorker`, `rewriteWorker`), default the dist names; a host that vendors the bundle under neutral names tells stomp what it called them, and no requested URL then carries the package name |
64
+ | `init.names` | optional global names the rewriter and the inject bundles use, a deployment's own instead of `$s$j` (access API), `$s$L` and `$s$N` (stand-in markers) and the client's, which the controller mints (16 random characters) when unset: `{ access, client, globalProxy, globalName, error }` (`error` is the global the error page reads, default `stompError`; a host whose obfuscator renames it sets this), each an identifier no plain window has; a bad one is a `TypeError` from the constructor. Leave `client` unset: it names the object that holds the real windows, and a fixed name is one every page knows. `DEFAULT_NAMES` exports the defaults |
65
+ | `init.hooks` | optional host code run inside every proxied realm (the page, nested frames, about:blank children, popups, srcdoc frames; workers with `hooks.workers`): `hooks.init.pre` before the inject installs its hooks, `hooks.init.post` after, before the page's first script. Each is `{ source }` or `{ url }` (fetched once at construction; its text is what travels, so a changed file needs a new controller). Raw host code with the realm's real globals, one argument `{ url, origin, isTop, nested, kind, phase, names, frameId, window }`; what it leaves on the window the page can reach. A throw is logged in the realm and reported, never thrown at the page. See `examples/hooks/hide-ads.js` |
66
+ | `build` | the build this controller bundle came from (version plus commit, or the package version), the same string the module exports as `BUILD`; the worker's is `config.build` and `stale` reports a mismatch |
67
+ | `unroute(url)` | the real URL a routed one stands for (the inverse of `route()`, this controller's codec), null for anything that is not a route |
68
+ | `onHookError(info, frame)`, `hookerror` event, `hookErrors` | a hook threw in a realm (`phase` `pre` or `post`, `kind`, the page `url`, `message`, `stack`) or a hook URL could not be fetched (`phase: 'url'`, the hook `url`); the controller serves without that hook |
69
+ | `init.id` | optional, this controller's id, 1 to 32 characters of `[A-Za-z0-9_-]` (a TypeError otherwise), 8 random ones by default. It names the controller's address under `directory` (`/stomp/<id>/`), so two host tabs on one directory run two controllers side by side; the same id passed again replaces the earlier controller. A host that keeps tabs stores one id per tab (sessionStorage) and passes it back, so the tab's history entries, reloads and copied links keep resolving. A routed URL whose controller is gone is redirected to the newest live controller of the same codec; with none, the error page answers with kind `controller` |
70
+ | `id`, `directory` | `id` is the id in use (given or minted). `directory` is the service worker's scope and the directory `dist/` is served from (`/stomp/`); routed URLs are `${directory}${id}/${kind}/${encoded}`, and the inject bundles and the transport worker are fetched from the directory itself (`config.directory` is the address, `config.scope` the directory). The cookie jar, the HTTP cache and the storage partitions are shared by every controller of a directory: a controller is a tab, not a browser |
71
+ | `init.rules` | optional `RequestRule[]`: declarative request rules applied in the service worker to the real URL, first match wins: `match` (URLPattern `url`, `method`, `destination`, `site`), `action` `'block'` or `{ requestHeaders, responseHeaders, redirect, status }` (`null` deletes a header); applied after stomp rebuilds Origin, Referer and cookies, so a host rule wins. URLPattern is Chrome-only; a missing constructor or an invalid pattern skips that rule with one console warning |
72
+ | `init.cache` | `cache` (default `true`): HTTP cache in the service worker, explicit freshness only, revalidated when stale; a script's rewritten output is kept beside it (keyed by the body, URL, script type, config and build; never for `no-store`, `private`, Authorization or a cookie without `public`; 300 entries) and cleared with it by `clearCache()`; `false` turns both off |
73
+ | `init.onRequest` | optional `onRequest(info)`: one report per proxied request: real `url` (a CSS image without the rewriter's `#url(...)` fragment), `method`, `destination` (`fetch` for fetch, XHR and keepalive beacons), `status` (0 with `error` and `kind` on a failure the proxy produced), `ms`, `cached`, `bytes`, for a script `rewriteCache` (`hit`, `miss`, or `skip` when it may not be kept) and `rewriteIn` (`pool` or `service-worker`, when it was rewritten now), and `frame`: the document's own report and head subresources wait up to 1.5 s for the page's client id, so they carry their frame too; also dispatched as a `request` CustomEvent on the controller (`detail` is the report); nothing is sent when unset |
74
+ | `init.onNavigate` | optional `onNavigate(info, frame)`: the page reported where it is (`{ url, title, canGoBack, canGoForward }`); also dispatched as a `navigate` CustomEvent on the frame and on the controller (`detail: { info, frame }`) |
75
+ | `NavigateInfo.icon` | the page's icon as it declared it (first `link rel="icon"` / `shortcut icon`, else `apple-touch-icon`), resolved against the real URL, or null; reported again when the page swaps it |
76
+ | `init.onOpen` | optional `onOpen(url, frame)`: called when a page wants a new tab (`window.open`, a link with `target="_blank"` or a name, a modifier or middle click); the page's `window.open` then returns `null`, like a popup blocker; also dispatched as an `open` CustomEvent on the controller (`detail: { url, frame }`). Without it popups open natively as bare proxied tabs |
77
+ | `init.errorPage` | optional: the host's own page for a document the proxy failed to produce (unreachable origin, timeout, routing error, blocked rule, the refusal to proxy its own origin). A same-origin URL; served under the frame's URL with the failure's status, never cached, with `globalThis.stompError = { url, status, error, kind }` in its head; `location.reload()` retries. In isolation mode a path on the proxy host, where the worker runs. See `examples/error.html` |
78
+ | `wait()` | resolves when the service worker has a server for this controller; a build mismatch is healed first (update, wait for activation, rebind) and otherwise resolves on the worker at hand with `stale` set |
79
+ | `stale`, `stale` / `rebind` events | `{ controller, worker }` builds while an older worker is still in charge; the controller rebinds on its own when the new one activates and dispatches `rebind` (`{ build }`) |
80
+ | `createFrame(iframe?)` | returns a `Frame`; creates an `<iframe>` when none is given |
81
+ | `setTransport(transport)` | hot-swap the transport: a descriptor on a worker host (the worker imports it; in-flight requests finish on the old one), an instance on a page host, a TypeError otherwise; resolves once the new transport is in place |
82
+ | `rebind(worker?)` | talk to another service worker (default: the registration's active one) and shake hands again; a build mismatch does this on its own before `wait()` would reject |
83
+ | `clearCache()` | drop the HTTP cache |
84
+ | `setRules(rules)` | replace the request rules at run time; `onRequest` reports the index of the rule applied as `info.rule` |
85
+ | `cookies.get(url)` / `.set(url, setCookie)` / `.list(origin?)` / `.clear(origin?)` | the jar as the host sees it (host-only: pages never reach this port) |
86
+ | `route(url)` | the proxied URL for `url`, if you need it without a `Frame` |
87
+ | `transport`, `frames` | current transport and the frames created so far |
88
+
89
+ `Frame`:
90
+
91
+ | Member | Description |
92
+ | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
93
+ | `go(url)` | navigate the frame to `url` through the proxy; resolves when the new document has reported (URL, title, history state in), a second after the frame's `load` event for a document that never reports, 30 s at the most. The first report comes at document start, so wait for the frame's `load` event (listened for before `go()`) before reading the page's DOM |
94
+ | `back()` / `forward()` | step the frame's own history; resolves to `false` when there is no entry in that direction (never walks the host page's history) |
95
+ | `canGoBack` / `canGoForward` | whether the frame's own history has an entry in that direction; `null` when the browser has no Navigation API |
96
+ | `reload()` | reload the proxied page; resolves like `go()`, `false` when there was nothing to reload |
97
+ | `url` | the real URL of the page the frame shows, or `null` |
98
+ | `navigate` event, `title`, `lastNavigate` | the page's own reports (load, pushState, hashchange, title changes); no polling needed |
99
+ | `element` | the underlying `<iframe>` |
100
+
101
+ `SearchBuilder(template)` turns free text into a URL: hosts and URLs pass
102
+ through, everything else is substituted into `template` at `%s`.
103
+
104
+ `RemoteController` (also `window.StompRemoteController` from
105
+ `dist/Controller.js`) is the host-side controller for origin isolation; it has
106
+ the same shape as `StompController` but runs the real controller in a hidden
107
+ frame on the proxy origin:
108
+
109
+ | Member | Description |
110
+ | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
111
+ | `new RemoteController(init)` | `init.proxyOrigin`, `init.transport` (`{ module, options, host? }`, hosted in the controller frame's transport worker unless `host: 'page'`), optional `directory`, `codec`, `sandbox`, `pollInterval` |
112
+ | `init.allowInvalidJs` | as on `StompController` |
113
+ | `init.membrane` | as on `StompController` |
114
+ | `init.rewriteWorkers`, `init.rewriteTimeout` | as on `StompController`; the controller frame starts the workers |
115
+ | `init.assets` | as on `StompController`; the controller frame is loaded as `<directory><controllerFrame>?script=<controllerFrameScript>` and learns its script name from that query |
116
+ | `init.names` | as on `StompController`, passed to the controller frame |
117
+ | `init.hooks` | as on `StompController`; a `{ url }` is fetched by the controller frame from the proxy origin |
118
+ | `build` | the build this controller bundle came from (version plus commit, or the package version), the same string the module exports as `BUILD`; the worker's is `config.build` and `stale` reports a mismatch |
119
+ | `unroute(url)` | the real URL a routed one stands for (the inverse of `route()`, this controller's codec), null for anything that is not a route |
120
+ | `init.id` | as on `StompController`, minted here (`id`) and handed to the controller frame, so the host knows the address before the frame answers; two host tabs run two controller frames and two controllers on one proxy origin |
121
+ | `init.rules` | as on `StompController`, relayed through the controller frame |
122
+ | `init.cache` | as on `StompController` |
123
+ | `init.onRequest` | as on `StompController`, relayed through the controller frame |
124
+ | `init.onNavigate` | as on `StompController`; the reports arrive from the proxy origin and are accepted only from this controller's own frames |
125
+ | `NavigateInfo.icon` | the page's icon as it declared it (first `link rel="icon"` / `shortcut icon`, else `apple-touch-icon`), resolved against the real URL, or null; reported again when the page swaps it |
126
+ | `init.onOpen` | as on `StompController`: the request reaches the host from the proxy origin and is accepted only from this controller's own frames |
127
+ | `wait()` | resolves once the controller frame has registered the service worker and loaded the transport |
128
+ | `createFrame(iframe?)` | returns a `RemoteFrame`; applies the sandbox attribute |
129
+ | `setTransport(descriptor)` | hot-swap the transport (loaded inside the controller frame) |
130
+ | `clearCache()` | drop the HTTP cache (inside the controller frame) |
131
+ | `setRules(rules)` | as on `StompController` |
132
+ | `cookies.*` | as on `StompController`, relayed through the controller frame |
133
+ | `route(url)` | the proxied URL for `url` on the proxy origin |
134
+ | `frames`, `config`, `element` | frames created so far, the controller's config, the hidden controller iframe |
135
+ | `stale`, `stale` / `rebind` events | as on `StompController`, mirrored from the controller frame |
136
+
137
+ `RemoteFrame`: `go(url)`, `back()`, `forward()`, `reload()` (the last three
138
+ are relayed through the controller frame, which is same-origin with the
139
+ proxied page), `url`, `title`, `canGoBack` and `canGoForward` (last known,
140
+ refreshed every `pollInterval` ms), `element`.
141
+
142
+ A transport descriptor is `{ module: '/epoxy/index.mjs', options: [{ wisp }] }`:
143
+ the module URL must be on the proxy origin and its default export is the
144
+ transport class, constructed with `...options`.
145
+
146
+ ## Origin isolation
147
+
148
+ `StompController` keeps everything on one origin, so a proxied page is
149
+ same-origin with the page that embeds it. Browsers grant same-origin frames
150
+ full access to each other: `parent.document`, `top.location = ...`,
151
+ `frameElement`, cookies and storage of the host. Stomp's hooks hide most of
152
+ that from the page's own (rewritten) JavaScript, but hooks are not a security
153
+ boundary. **Same-origin mode is for development only.**
154
+
155
+ Origin isolation serves `dist/` and all proxied content from a second origin
156
+ (for example `app.example.com` hosting the UI and `proxy.example.com` serving
157
+ the proxy). The host page creates a `RemoteController`, which embeds
158
+ `dist/controllerFrame.html` from the proxy origin; that frame registers the
159
+ service worker, loads the transport and runs the real `StompController`.
160
+ Proxied pages live in sandboxed, cross-origin iframes (`allow-same-origin`
161
+ so the service worker controls them, no `allow-top-navigation`), so the
162
+ browser itself refuses them any access to the host, and the service worker
163
+ sends `Content-Security-Policy: frame-ancestors <host origin>` so nothing
164
+ else can embed the proxy origin. On top of that the inject bundle makes the
165
+ page believe it is top-level: `top`, `parent`, `frameElement`, `window.name`,
166
+ `outerWidth`/`outerHeight`, `screenX`/`screenY`, `document.referrer` and
167
+ `location.ancestorOrigins` all answer as they would for a real top window,
168
+ and `message` events from outside the proxied world never reach the page.
169
+
170
+ ## Embedding
171
+
172
+ Install the package from npm. Nightly builds of master are published as
173
+ `@sysce/stomp@nightly` (versions like `4.2.1-nightly.202611100717.g1a2b3c4`:
174
+ the next patch, the UTC time, the commit), each only after CI passed on that
175
+ commit, with npm provenance:
29
176
 
30
177
  ```sh
31
- $ npm install
178
+ npm install @sysce/stomp@nightly
32
179
  ```
33
180
 
34
- 3. Build
181
+ Pin the exact version a host was tested with. `stompPath` from
182
+ `@sysce/stomp/path` is the directory to serve.
183
+
184
+ [`examples/minimal-host.html`](examples/minimal-host.html) is the smallest
185
+ host application: plain HTML and one module script, with a comment per step.
186
+ The demo server serves it at `/examples/minimal-host.html`
187
+ (`npm run demo`), and `test/suites/host-api.mjs` runs the host API checklist
188
+ against it. The deployment settings sit in one object at the top of the
189
+ script (`directory`, the transport module, the wisp URL, `codec`).
190
+
191
+ The boot sequence it shows: `StompController.register(directory)` for the
192
+ directory `dist/` is served from (that scope, `updateViaCache: 'none'`,
193
+ resolves once the worker is activated); import the transport module and
194
+ construct it with the wisp URL; create the `StompController` with the
195
+ registration, the transport, the directory, the codec and the callbacks; `await controller.wait()`; then `createFrame()` and
196
+ place its `<iframe>`. From there `frame.go(url)`, `back()`, `forward()` and
197
+ `reload()` drive the frame, and `canGoBack` / `canGoForward` tell the host
198
+ what to enable. `controller.stale` and the `stale` / `rebind` events say
199
+ whether an older service worker is still in charge after a deploy.
200
+
201
+ The three callbacks are the host's view of the pages: `onNavigate` reports
202
+ URL, title and history state whenever the page changes them (the example
203
+ updates its address input and title from it), `onOpen` receives the URL a
204
+ page wanted in a new tab (the page's `window.open` returns `null`; the
205
+ example opens it in place, a real host opens a tab), and `onRequest` reports
206
+ every proxied request with its real URL, status and the `Frame` it belongs
207
+ to (the example appends one line per request to a log).
208
+
209
+ The example runs the same-origin `StompController`, which is not a security
210
+ boundary; a deployment that must keep proxied pages away from the host uses
211
+ `RemoteController` on a second origin, see "Origin isolation" above.
212
+
213
+ Transport: use epoxy for HTTP-heavy sites. Pages that rely on WebSockets are
214
+ the exception (incoming frames stop after the first round trip against
215
+ wisp-js 0.5, see the changelog's known limitations and the transports note
216
+ in `AGENTS.md`); the demo's transport selector switches to libcurl for
217
+ those, which in turn stops answering HTTP after some tens of requests.
218
+
219
+ Use the epoxy build this repository pins (`deploy/epoxy/`), not the npm
220
+ release: upstream epoxy loses one of two requests started together on a cold
221
+ transport (two frames of one page navigating at once, the first never loads)
222
+ and hangs on a pooled connection the server has already closed for idling.
223
+ The pinned build patches both (`epoxy-concurrency` suite).
224
+
225
+ ## Demo
226
+
227
+ `npm run demo` serves a complete host application on http://localhost:8080,
228
+ including a wisp server and both transports. It is documented in
229
+ [`demo/README.md`](demo/README.md) and is the quickest way to see the API above
230
+ in use. It is live at https://stomp.fracit.al, deployed from `master` by
231
+ `.github/workflows/deploy.yml`.
232
+
233
+ ## Development
35
234
 
36
235
  ```sh
37
- $ npm build
236
+ npm install
237
+ npm run build # type-check, bundle with esbuild, emit declarations
238
+ npm run dev # rebuild on change
239
+ npm run demo # http://localhost:8080 with a wisp server, epoxy and libcurl
240
+ npm run lint
38
241
  ```