@fairfox/polly 0.72.0 → 0.73.1
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/dist/src/elysia/index.js +464 -4
- package/dist/src/elysia/index.js.map +6 -4
- package/dist/src/peer.d.ts +2 -0
- package/dist/src/peer.js +468 -4
- package/dist/src/peer.js.map +8 -5
- package/dist/src/polly-ui/ActionInput.d.ts +2 -1
- package/dist/src/polly-ui/ActionSelect.d.ts +2 -1
- package/dist/src/polly-ui/Button.d.ts +4 -0
- package/dist/src/polly-ui/Cluster.d.ts +2 -1
- package/dist/src/polly-ui/Code.d.ts +2 -1
- package/dist/src/polly-ui/Dropdown.d.ts +6 -0
- package/dist/src/polly-ui/Surface.d.ts +12 -1
- package/dist/src/polly-ui/Text.d.ts +23 -11
- package/dist/src/polly-ui/index.css +44 -18
- package/dist/src/polly-ui/index.js +118 -12
- package/dist/src/polly-ui/index.js.map +12 -11
- package/dist/src/polly-ui/internal/passthrough.d.ts +25 -0
- package/dist/src/polly-ui/styles.css +59 -18
- package/dist/src/polly-ui/theme.css +1 -0
- package/dist/src/shared/lib/peer-repo-server.d.ts +18 -0
- package/dist/src/shared/lib/sweep-sealed.d.ts +111 -0
- package/dist/tools/test/src/browser/run.js +42 -33
- package/dist/tools/test/src/browser/run.js.map +6 -5
- package/dist/tools/test/src/browser/runner-core.d.ts +32 -0
- package/dist/tools/test/src/e2e-mesh/index.js +193 -171
- package/dist/tools/test/src/e2e-mesh/index.js.map +4 -4
- package/dist/tools/test/src/visual/index.js +248 -229
- package/dist/tools/test/src/visual/index.js.map +5 -5
- package/dist/tools/verify/specs/tla/MeshSeed.cfg +27 -0
- package/dist/tools/verify/specs/tla/MeshSeed.tla +179 -0
- package/dist/tools/verify/specs/tla/README.md +11 -1
- package/dist/tools/verify/src/cli.js +79 -2
- package/dist/tools/verify/src/cli.js.map +7 -6
- package/dist/tools/visualize/src/cli.js +179 -3
- package/dist/tools/visualize/src/cli.js.map +6 -6
- package/package.json +3 -2
|
@@ -104,6 +104,15 @@
|
|
|
104
104
|
min-inline-size: 0;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/* data-polly-wrap — full, unbounded wrapping of a long unbreakable
|
|
108
|
+
* string: a pairing URL, a recovery blob, a hex id. Where truncate and
|
|
109
|
+
* clamp HIDE overflow, wrap breaks the string so all of it stays
|
|
110
|
+
* visible. min-inline-size: 0 lets it shrink inside a Layout grid track. */
|
|
111
|
+
[data-polly-wrap] {
|
|
112
|
+
overflow-wrap: anywhere;
|
|
113
|
+
min-inline-size: 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
107
116
|
/* Honour reduced-motion by zeroing every motion token. The components
|
|
108
117
|
* read only these tokens for their transitions, so a consumer preference
|
|
109
118
|
* of "reduce" makes every animation instant. */
|
|
@@ -132,6 +141,12 @@
|
|
|
132
141
|
min-block-size: 44px;
|
|
133
142
|
}
|
|
134
143
|
|
|
144
|
+
/* Pointer affordance for non-<Button> clickables — whole-row click
|
|
145
|
+
* targets and the like. Applies regardless of hit-target sizing. */
|
|
146
|
+
[data-polly-interactive] {
|
|
147
|
+
cursor: pointer;
|
|
148
|
+
}
|
|
149
|
+
|
|
135
150
|
/* Scroll lock — toggled by OverlayRoot when the overlay stack is
|
|
136
151
|
* non-empty. The padding-right compensates for the scrollbar so content
|
|
137
152
|
* behind the overlay does not reflow. */
|
|
@@ -232,10 +247,9 @@
|
|
|
232
247
|
}
|
|
233
248
|
|
|
234
249
|
.menu_HX48zA {
|
|
235
|
-
position:
|
|
250
|
+
position: fixed;
|
|
236
251
|
inset: unset;
|
|
237
252
|
z-index: var(--polly-z-raised);
|
|
238
|
-
margin: var(--polly-space-xs) 0 0;
|
|
239
253
|
padding: var(--polly-space-xs) 0;
|
|
240
254
|
border: var(--polly-border-width-default) solid var(--polly-border);
|
|
241
255
|
border-radius: var(--polly-radius-md);
|
|
@@ -245,13 +259,7 @@
|
|
|
245
259
|
overflow-y: auto;
|
|
246
260
|
min-width: 160px;
|
|
247
261
|
max-height: 280px;
|
|
248
|
-
|
|
249
|
-
left: 0;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
.alignRight_HX48zA {
|
|
253
|
-
left: auto;
|
|
254
|
-
right: 0;
|
|
262
|
+
margin: 0;
|
|
255
263
|
}
|
|
256
264
|
}
|
|
257
265
|
|
|
@@ -561,18 +569,22 @@
|
|
|
561
569
|
--s-radius: 0;
|
|
562
570
|
--s-border-color: transparent;
|
|
563
571
|
--s-border-width: 0;
|
|
572
|
+
--s-border-style: solid;
|
|
564
573
|
--s-shadow: none;
|
|
565
574
|
--s-w: auto;
|
|
566
575
|
--s-h: auto;
|
|
567
576
|
--s-mh: auto;
|
|
577
|
+
--s-maxh: none;
|
|
568
578
|
--s-mis: none;
|
|
579
|
+
--s-overflow: visible;
|
|
569
580
|
--s-position: static;
|
|
570
581
|
--s-inset: auto;
|
|
582
|
+
--s-transform: none;
|
|
571
583
|
--s-z: auto;
|
|
572
584
|
box-sizing: border-box;
|
|
573
585
|
padding: var(--s-p);
|
|
574
586
|
background: var(--s-bg);
|
|
575
|
-
border-style:
|
|
587
|
+
border-style: var(--s-border-style);
|
|
576
588
|
border-color: var(--s-border-color);
|
|
577
589
|
border-width: var(--s-border-width);
|
|
578
590
|
border-radius: var(--s-radius);
|
|
@@ -580,9 +592,12 @@
|
|
|
580
592
|
inline-size: var(--s-w);
|
|
581
593
|
block-size: var(--s-h);
|
|
582
594
|
min-block-size: var(--s-mh);
|
|
595
|
+
max-block-size: var(--s-maxh);
|
|
583
596
|
max-inline-size: var(--s-mis);
|
|
597
|
+
overflow: var(--s-overflow);
|
|
584
598
|
position: var(--s-position);
|
|
585
599
|
inset: var(--s-inset);
|
|
600
|
+
transform: var(--s-transform);
|
|
586
601
|
z-index: var(--s-z);
|
|
587
602
|
}
|
|
588
603
|
|
|
@@ -593,34 +608,32 @@
|
|
|
593
608
|
|
|
594
609
|
.sides-block-start_pQCFqA {
|
|
595
610
|
border-style: none;
|
|
596
|
-
border-block-start-style:
|
|
611
|
+
border-block-start-style: var(--s-border-style);
|
|
597
612
|
}
|
|
598
613
|
|
|
599
614
|
.sides-block-end_pQCFqA {
|
|
600
615
|
border-style: none;
|
|
601
|
-
border-block-end-style:
|
|
616
|
+
border-block-end-style: var(--s-border-style);
|
|
602
617
|
}
|
|
603
618
|
|
|
604
619
|
.sides-inline-start_pQCFqA {
|
|
605
620
|
border-style: none;
|
|
606
|
-
border-inline-start-style:
|
|
621
|
+
border-inline-start-style: var(--s-border-style);
|
|
607
622
|
}
|
|
608
623
|
|
|
609
624
|
.sides-inline-end_pQCFqA {
|
|
610
625
|
border-style: none;
|
|
611
|
-
border-inline-end-style:
|
|
626
|
+
border-inline-end-style: var(--s-border-style);
|
|
612
627
|
}
|
|
613
628
|
|
|
614
629
|
.sides-block_pQCFqA {
|
|
615
630
|
border-style: none;
|
|
616
|
-
border-block-
|
|
617
|
-
border-block-end-style: solid;
|
|
631
|
+
border-block-style: var(--s-border-style);
|
|
618
632
|
}
|
|
619
633
|
|
|
620
634
|
.sides-inline_pQCFqA {
|
|
621
635
|
border-style: none;
|
|
622
|
-
border-
|
|
623
|
-
border-right-style: solid;
|
|
636
|
+
border-inline-style: var(--s-border-style);
|
|
624
637
|
}
|
|
625
638
|
}
|
|
626
639
|
|
|
@@ -961,6 +974,34 @@
|
|
|
961
974
|
color: var(--polly-text-muted);
|
|
962
975
|
}
|
|
963
976
|
|
|
977
|
+
.danger_75HKdQ {
|
|
978
|
+
color: var(--polly-status-danger-text);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
.warning_75HKdQ {
|
|
982
|
+
color: var(--polly-status-warning-text);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
.success_75HKdQ {
|
|
986
|
+
color: var(--polly-status-success-text);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
.italic_75HKdQ {
|
|
990
|
+
font-style: italic;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
.tight_75HKdQ {
|
|
994
|
+
line-height: var(--polly-line-height-tight);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
.base_75HKdQ {
|
|
998
|
+
line-height: var(--polly-line-height-base);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
.loose_75HKdQ {
|
|
1002
|
+
line-height: var(--polly-line-height-loose);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
964
1005
|
.xs_75HKdQ {
|
|
965
1006
|
font-size: var(--polly-text-xs);
|
|
966
1007
|
}
|
|
@@ -38,6 +38,7 @@ import type { Repo as RepoType } from "@automerge/automerge-repo/slim";
|
|
|
38
38
|
import type { WebSocketServerAdapter as WebSocketServerAdapterType } from "@automerge/automerge-repo-network-websocket";
|
|
39
39
|
import type { NodeFSStorageAdapter as NodeFSStorageAdapterType } from "@automerge/automerge-repo-storage-nodefs";
|
|
40
40
|
import type * as wsType from "ws";
|
|
41
|
+
import type { SweepResult } from "./sweep-sealed";
|
|
41
42
|
type WebSocketServer = wsType.WebSocketServer;
|
|
42
43
|
export interface CreatePeerRepoServerOptions {
|
|
43
44
|
/** Port to listen on. The factory creates its own `WebSocketServer` and
|
|
@@ -69,6 +70,23 @@ export interface PeerRepoServer {
|
|
|
69
70
|
* underlying WebSocket server. Returns a promise that resolves once the
|
|
70
71
|
* tear-down is complete. */
|
|
71
72
|
close: () => Promise<void>;
|
|
73
|
+
/**
|
|
74
|
+
* Garbage-collect sealed mesh-doc bytes from {@link storage}. Walks the
|
|
75
|
+
* storage adapter, removes documents the `isSealed` predicate
|
|
76
|
+
* recognises as sealed longer ago than `olderThan`, and skips any
|
|
77
|
+
* document with an open handle on {@link repo}. With `dryRun`, reports
|
|
78
|
+
* the candidates without removing anything.
|
|
79
|
+
*
|
|
80
|
+
* Convenience binding of the standalone `sweepSealed` to this server's
|
|
81
|
+
* `repo` and `storage`. See `sweepSealed` for the full contract,
|
|
82
|
+
* including the redirect-index-not-yet-synced hazard that `olderThan`
|
|
83
|
+
* is sized to bound. polly never runs this on a timer — call it
|
|
84
|
+
* explicitly. */
|
|
85
|
+
sweepSealed: (options: {
|
|
86
|
+
isSealed: (doc: unknown) => number | undefined;
|
|
87
|
+
olderThan: number;
|
|
88
|
+
dryRun?: boolean;
|
|
89
|
+
}) => Promise<SweepResult>;
|
|
72
90
|
}
|
|
73
91
|
/**
|
|
74
92
|
* Construct a Polly peer-relay server. Returns a Repo that participates as
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sweepSealed` — storage-adapter garbage collection of sealed mesh-doc
|
|
3
|
+
* bytes (polly#121).
|
|
4
|
+
*
|
|
5
|
+
* When a consumer compacts a `$meshState` document it seeds a fresh
|
|
6
|
+
* successor and leaves an in-band sentinel in the old document. polly's
|
|
7
|
+
* {@link RedirectDetector} follows that sentinel so live wrappers rebind
|
|
8
|
+
* transparently — but the old document's bytes then sit in storage
|
|
9
|
+
* forever, because nothing removes them.
|
|
10
|
+
*
|
|
11
|
+
* `sweepSealed` is that missing GC step. polly owns the walk, the
|
|
12
|
+
* open-handle gate, the age window, the dry-run report and the byte
|
|
13
|
+
* removal. The consumer owns the sentinel shape — it is the very
|
|
14
|
+
* document the consumer's {@link RedirectDetector} already inspects —
|
|
15
|
+
* supplied here as the {@link SweepSealedOptions.isSealed} predicate.
|
|
16
|
+
* polly deliberately does not define a canonical sentinel format: that
|
|
17
|
+
* would compete with the one consumers' detectors already read.
|
|
18
|
+
*
|
|
19
|
+
* polly never runs this on a timer. The consumer calls it explicitly.
|
|
20
|
+
*
|
|
21
|
+
* ## Redirect-index-not-yet-synced hazard
|
|
22
|
+
*
|
|
23
|
+
* A peer whose redirect index (`mesh:document-index`) has not yet synced
|
|
24
|
+
* may still reach for a sealed document by its *old* docId. If the sweep
|
|
25
|
+
* has just removed that document's bytes, the peer gets a
|
|
26
|
+
* missing-document error instead of a redirect. Two gates bound the risk,
|
|
27
|
+
* and the caller must size them deliberately:
|
|
28
|
+
*
|
|
29
|
+
* - `olderThan` — only documents sealed longer ago than this window are
|
|
30
|
+
* swept. Make it comfortably larger than the worst-case time a peer
|
|
31
|
+
* can stay offline-then-resync, so any peer that could still hold the
|
|
32
|
+
* old docId has had the redirect delivered.
|
|
33
|
+
* - the open-handle gate — a document with a live handle on the supplied
|
|
34
|
+
* `repo` is never swept, regardless of age.
|
|
35
|
+
*
|
|
36
|
+
* Neither gate can see a peer that is currently offline; `olderThan` is
|
|
37
|
+
* the only protection there. Choose it conservatively.
|
|
38
|
+
*/
|
|
39
|
+
import type { DocumentId, Repo, StorageAdapterInterface } from "@automerge/automerge-repo/slim";
|
|
40
|
+
export interface SweepSealedOptions {
|
|
41
|
+
/** The Repo whose storage is swept. Its open handles gate removal. */
|
|
42
|
+
repo: Repo;
|
|
43
|
+
/** The storage adapter backing {@link repo}. */
|
|
44
|
+
storage: StorageAdapterInterface;
|
|
45
|
+
/**
|
|
46
|
+
* Predicate that recognises a sealed document. Given a materialised
|
|
47
|
+
* document, returns the epoch-ms timestamp at which it was sealed, or
|
|
48
|
+
* `undefined` if the document is not sealed.
|
|
49
|
+
*
|
|
50
|
+
* This is the same document the consumer's {@link RedirectDetector}
|
|
51
|
+
* inspects: the consumer owns the sentinel shape, polly never defines
|
|
52
|
+
* it. The returned timestamp feeds both the {@link olderThan} filter
|
|
53
|
+
* and the dry-run report.
|
|
54
|
+
*/
|
|
55
|
+
isSealed: (doc: unknown) => number | undefined;
|
|
56
|
+
/** Sweep only documents sealed more than this many milliseconds ago. */
|
|
57
|
+
olderThan: number;
|
|
58
|
+
/**
|
|
59
|
+
* The candidate documents to consider. When omitted, the sweep
|
|
60
|
+
* enumerates the whole adapter via `storage.loadRange([])`.
|
|
61
|
+
*
|
|
62
|
+
* That whole-adapter enumeration works for the IndexedDB and
|
|
63
|
+
* in-memory adapters but **not** the NodeFS adapter, whose `loadRange`
|
|
64
|
+
* requires at least a documentId prefix. Server-side callers should
|
|
65
|
+
* use {@link PeerRepoServer.sweepSealed}, which enumerates the
|
|
66
|
+
* filesystem and supplies this list; per-document `loadRange` then
|
|
67
|
+
* works on every adapter.
|
|
68
|
+
*/
|
|
69
|
+
documentIds?: Iterable<string>;
|
|
70
|
+
/** When true, report candidates without removing anything. */
|
|
71
|
+
dryRun?: boolean;
|
|
72
|
+
/** Clock source, injectable for tests. Defaults to {@link Date.now}. */
|
|
73
|
+
now?: () => number;
|
|
74
|
+
}
|
|
75
|
+
/** A document removed by the sweep — or, under `dryRun`, that would be. */
|
|
76
|
+
export interface SweptDoc {
|
|
77
|
+
documentId: DocumentId;
|
|
78
|
+
/** Epoch-ms the document was sealed, as reported by `isSealed`. */
|
|
79
|
+
sealedAt: number;
|
|
80
|
+
/** Total bytes across the document's storage chunks. */
|
|
81
|
+
byteSize: number;
|
|
82
|
+
}
|
|
83
|
+
/** Why a sealed document was left in place rather than swept. */
|
|
84
|
+
export type KeptReason = "open-handle" | "too-recent";
|
|
85
|
+
/** A sealed document the sweep deliberately did not remove. */
|
|
86
|
+
export interface KeptDoc {
|
|
87
|
+
documentId: DocumentId;
|
|
88
|
+
reason: KeptReason;
|
|
89
|
+
}
|
|
90
|
+
export interface SweepResult {
|
|
91
|
+
/** Documents removed — or, under `dryRun`, that would be removed. */
|
|
92
|
+
swept: SweptDoc[];
|
|
93
|
+
/** Sealed documents deliberately left in place, with the reason. */
|
|
94
|
+
kept: KeptDoc[];
|
|
95
|
+
/** Echoes the `dryRun` flag the sweep ran under. */
|
|
96
|
+
dryRun: boolean;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Garbage-collect sealed mesh-doc bytes from a Repo's storage adapter.
|
|
100
|
+
*
|
|
101
|
+
* Walks every candidate document, materialises it, and asks
|
|
102
|
+
* {@link SweepSealedOptions.isSealed} whether it is sealed. A sealed
|
|
103
|
+
* document is removed only when it has no open handle on the Repo *and*
|
|
104
|
+
* was sealed longer ago than `olderThan`; otherwise it is reported under
|
|
105
|
+
* `kept` with the reason. Unsealed documents are never touched and never
|
|
106
|
+
* reported. With `dryRun`, candidates are reported but nothing is removed.
|
|
107
|
+
*
|
|
108
|
+
* See the module doc comment for the redirect-index-not-yet-synced
|
|
109
|
+
* hazard that `olderThan` and the open-handle gate exist to bound.
|
|
110
|
+
*/
|
|
111
|
+
export declare function sweepSealed(options: SweepSealedOptions): Promise<SweepResult>;
|
|
@@ -146,6 +146,44 @@ function signalingServer(options = {}) {
|
|
|
146
146
|
});
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
// tools/test/src/browser/runner-core.ts
|
|
150
|
+
function isProtocolError(err) {
|
|
151
|
+
return err instanceof Error && err.name === "ProtocolError";
|
|
152
|
+
}
|
|
153
|
+
function errMessage(err) {
|
|
154
|
+
return err instanceof Error ? err.message : String(err);
|
|
155
|
+
}
|
|
156
|
+
async function runSuite(testFiles, runFile, options = {}) {
|
|
157
|
+
const label = options.label ?? ((f) => f);
|
|
158
|
+
const log = options.log ?? console.log;
|
|
159
|
+
let totalPassed = 0;
|
|
160
|
+
let totalFailed = 0;
|
|
161
|
+
for (const testFile of testFiles) {
|
|
162
|
+
log(`
|
|
163
|
+
[browser-runner] running ${label(testFile)}`);
|
|
164
|
+
let result;
|
|
165
|
+
try {
|
|
166
|
+
result = await runFile(testFile);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
if (isProtocolError(err)) {
|
|
169
|
+
log(` ⚠️ protocol error (${errMessage(err)}) — retrying once on a fresh page`);
|
|
170
|
+
try {
|
|
171
|
+
result = await runFile(testFile);
|
|
172
|
+
} catch (retryErr) {
|
|
173
|
+
log(` ❌ retry failed: ${errMessage(retryErr)}`);
|
|
174
|
+
result = { passed: 0, failed: 1 };
|
|
175
|
+
}
|
|
176
|
+
} else {
|
|
177
|
+
log(` ❌ ${errMessage(err)}`);
|
|
178
|
+
result = { passed: 0, failed: 1 };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
totalPassed += result.passed;
|
|
182
|
+
totalFailed += result.failed;
|
|
183
|
+
}
|
|
184
|
+
return { passed: totalPassed, failed: totalFailed };
|
|
185
|
+
}
|
|
186
|
+
|
|
149
187
|
// tools/test/src/browser/run.ts
|
|
150
188
|
var automergeBase64Path = resolve(process.cwd(), "node_modules/@automerge/automerge/dist/mjs/entrypoints/fullfat_base64.js");
|
|
151
189
|
var automergeBase64Plugin = {
|
|
@@ -181,14 +219,6 @@ var browser = await puppeteer.launch({
|
|
|
181
219
|
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
|
182
220
|
protocolTimeout: 30000
|
|
183
221
|
});
|
|
184
|
-
var totalPassed = 0;
|
|
185
|
-
var totalFailed = 0;
|
|
186
|
-
function isProtocolError(err) {
|
|
187
|
-
return err instanceof Error && err.name === "ProtocolError";
|
|
188
|
-
}
|
|
189
|
-
function errMessage(err) {
|
|
190
|
-
return err instanceof Error ? err.message : String(err);
|
|
191
|
-
}
|
|
192
222
|
async function runFile(testFile) {
|
|
193
223
|
const buildResult = await Bun.build({
|
|
194
224
|
entrypoints: [testFile],
|
|
@@ -270,34 +300,13 @@ async function runFile(testFile) {
|
|
|
270
300
|
server.stop();
|
|
271
301
|
}
|
|
272
302
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
[browser-runner] running ${shortName}`);
|
|
277
|
-
let result;
|
|
278
|
-
try {
|
|
279
|
-
result = await runFile(testFile);
|
|
280
|
-
} catch (err) {
|
|
281
|
-
if (isProtocolError(err)) {
|
|
282
|
-
console.log(` ⚠️ protocol error (${errMessage(err)}) — retrying once on a fresh page`);
|
|
283
|
-
try {
|
|
284
|
-
result = await runFile(testFile);
|
|
285
|
-
} catch (retryErr) {
|
|
286
|
-
console.log(` ❌ retry failed: ${errMessage(retryErr)}`);
|
|
287
|
-
result = { passed: 0, failed: 1 };
|
|
288
|
-
}
|
|
289
|
-
} else {
|
|
290
|
-
console.log(` ❌ ${errMessage(err)}`);
|
|
291
|
-
result = { passed: 0, failed: 1 };
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
totalPassed += result.passed;
|
|
295
|
-
totalFailed += result.failed;
|
|
296
|
-
}
|
|
303
|
+
var { passed: totalPassed, failed: totalFailed } = await runSuite(testFiles, runFile, {
|
|
304
|
+
label: (testFile) => testFile.replace(`${testDir}/`, "")
|
|
305
|
+
});
|
|
297
306
|
await browser.close();
|
|
298
307
|
signalingApp.server?.stop?.(true);
|
|
299
308
|
console.log(`
|
|
300
309
|
[browser-runner] ${totalPassed} passed, ${totalFailed} failed`);
|
|
301
310
|
process.exit(totalFailed > 0 ? 1 : 0);
|
|
302
311
|
|
|
303
|
-
//# debugId=
|
|
312
|
+
//# debugId=B8D937D1C5C3E44264756E2164756E21
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
|
-
"sources": ["../tools/test/src/browser/run.ts", "../src/elysia/signaling-server-plugin.ts"],
|
|
3
|
+
"sources": ["../tools/test/src/browser/run.ts", "../src/elysia/signaling-server-plugin.ts", "../tools/test/src/browser/runner-core.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
-
"#!/usr/bin/env bun\n\n/**\n * Browser test runner for Polly applications.\n *\n * Finds all *.browser.ts files in a given directory, bundles each with\n * Bun.build for the browser target (with an internal Automerge WASM fix),\n * serves the bundle on an ephemeral port, opens a Puppeteer page, and\n * polls window.__done for results. Prints pass/fail per test and exits\n * non-zero if any test failed.\n *\n * A signalling server for WebRTC tests starts automatically on a random\n * port. The URL is injected into the bundle via process.env.SIGNALING_URL.\n *\n * Usage (from project root):\n *\n * bun tools/test/src/browser/run.ts [testDir] [filter]\n *\n * Examples:\n *\n * bun tools/test/src/browser/run.ts tests/browser\n * bun tools/test/src/browser/run.ts tests/browser mesh-webrtc\n * HEADLESS=false bun tools/test/src/browser/run.ts tests/browser\n *\n * When invoked without a testDir, defaults to tests/browser relative to cwd.\n */\n\nimport { resolve } from \"node:path\";\nimport { type BunPlugin, Glob } from \"bun\";\nimport { Elysia } from \"elysia\";\nimport puppeteer, { type Page } from \"puppeteer\";\nimport { signalingServer } from \"../../../../src/elysia/signaling-server-plugin\";\n\n// Automerge WASM fix\n// Bun.build's target: \"browser\" picks Automerge's fullfat_bundler.js which\n// does a static .wasm import that Bun can't wire up. Redirect to the\n// base64 variant which embeds the WASM as a string and self-initialises.\n\nconst automergeBase64Path = resolve(\n process.cwd(),\n \"node_modules/@automerge/automerge/dist/mjs/entrypoints/fullfat_base64.js\"\n);\n\nconst automergeBase64Plugin: BunPlugin = {\n name: \"automerge-base64\",\n setup(build) {\n build.onResolve({ filter: /^@automerge\\/automerge(\\/slim)?$/ }, () => {\n return { path: automergeBase64Path };\n });\n },\n};\n\n// Argument parsing\n\nconst testDir = resolve(process.cwd(), process.argv[2] ?? \"tests/browser\");\nconst filter = process.argv[3] ?? \"\";\nconst headless = process.env[\"HEADLESS\"] !== \"false\";\n\nconst glob = new Glob(\"**/*.browser.{ts,tsx}\");\nconst testFiles: string[] = [];\nfor await (const file of glob.scan({ cwd: testDir, absolute: true })) {\n if (file.includes(\"harness\")) continue;\n if (filter && !file.includes(filter)) continue;\n testFiles.push(file);\n}\n\nif (testFiles.length === 0) {\n console.log(`[browser-runner] no test files found${filter ? ` matching \"${filter}\"` : \"\"}`);\n process.exit(0);\n}\n\nconsole.log(`[browser-runner] found ${testFiles.length} test file(s)`);\n\n// Start server-side infrastructure\n\nconst signalingPort = 39000 + Math.floor(Math.random() * 1000);\nconst signalingApp = new Elysia()\n .use(signalingServer({ path: \"/polly/signaling\" }))\n .listen(signalingPort);\nconsole.log(`[browser-runner] signaling server on ws://127.0.0.1:${signalingPort}/polly/signaling`);\n\n// Launch browser\n//\n// protocolTimeout caps how long any single CDP call (e.g. page.evaluate)\n// waits before throwing. Puppeteer's default is 180s, so a stalled\n// renderer hangs the runner for three minutes. 30s fails fast while\n// still tolerating a slow-but-healthy initial sync.\n\nconst browser = await puppeteer.launch({\n headless,\n args: [\"--no-sandbox\", \"--disable-setuid-sandbox\"],\n protocolTimeout: 30_000,\n});\n\
|
|
6
|
-
"// @ts-nocheck - Optional peer dependencies (elysia, @elysiajs/eden)\n/**\n * signalingServer — Phase 2 Elysia plugin that exposes a stateless\n * WebSocket route for SDP/ICE relay between $meshState peers.\n *\n * The mesh transport is a star-of-data-channels: peers establish direct\n * WebRTC connections to each other and exchange document operations\n * peer-to-peer once those channels are open. WebRTC connection setup\n * needs an out-of-band channel for SDP offer/answer and ICE candidate\n * exchange, and that channel is what this plugin provides. The plugin\n * does not own any document state, does not hold any encryption keys,\n * and never inspects the contents of the messages it relays. It is a\n * pure pub-sub by peer id.\n *\n * Once two peers have completed the SDP exchange and opened a direct\n * data channel, the signalling server is no longer on the critical\n * path — the peers talk directly. The signalling server's role is\n * therefore intermittent: peers connect to it only during the brief\n * windows when they are establishing or re-establishing connections.\n *\n * Wire protocol:\n *\n * Client → server (join):\n * { type: \"join\", peerId: \"peer-alice\" }\n *\n * Client → server (signal to another peer):\n * { type: \"signal\", peerId: \"peer-alice\", targetPeerId: \"peer-bob\",\n * payload: { ... } }\n *\n * Server → client (delivered signal):\n * { type: \"signal\", peerId: \"peer-alice\", targetPeerId: \"peer-bob\",\n * payload: { ... } }\n *\n * Server → client (notification of unknown target):\n * { type: \"error\", reason: \"unknown-target\", targetPeerId: \"...\" }\n *\n * The `payload` is opaque to the signalling server — typically it\n * carries an SDP offer, SDP answer, or ICE candidate. Applications can\n * also use the relay for any other peer-to-peer message that needs an\n * intermediary, such as the initial handshake of a pairing flow.\n *\n * @example\n * ```ts\n * import { Elysia } from \"elysia\";\n * import { signalingServer } from \"@fairfox/polly/elysia\";\n *\n * const app = new Elysia()\n * .use(signalingServer({ path: \"/polly/signaling\" }))\n * .listen(8080);\n * ```\n */\n\nimport { Elysia } from \"elysia\";\n\n/** A signalling message. The `type` discriminates between client-to-server\n * requests (join, signal), the error envelope, and the server-to-client\n * discovery frames (peers-present, peer-joined, peer-left) that let\n * peers learn about each other without polling. */\nexport type SignalingMessage =\n | {\n type: \"join\";\n /** The peer registering itself with the signalling server. */\n peerId: string;\n }\n | {\n type: \"signal\";\n /** The peer sending the signal. */\n peerId: string;\n /** The peer the signal is being relayed to. */\n targetPeerId: string;\n /** Opaque payload, typically SDP or ICE. */\n payload: unknown;\n }\n | {\n type: \"error\";\n reason: \"unknown-target\" | \"not-joined\" | \"malformed\";\n targetPeerId?: string;\n }\n | {\n /** Sent to a newcomer immediately after it joins, listing every\n * peer that was already joined at that moment. Empty for a lone\n * newcomer. */\n type: \"peers-present\";\n peerIds: string[];\n }\n | {\n /** Broadcast to every incumbent when a new peer joins. */\n type: \"peer-joined\";\n peerId: string;\n }\n | {\n /** Broadcast to every remaining incumbent when a joined peer\n * closes its socket. Never emitted for a connection that never\n * sent a join frame. */\n type: \"peer-left\";\n peerId: string;\n };\n\n/** A frame whose `type` is outside the built-in signalling vocabulary.\n * Consumers who pass an {@link SignalingServerOptions.onCustomFrame}\n * handler receive these on the server side; everything else — including\n * routing them to a specific peer, storing a session, or rejecting the\n * frame — is the consumer's call. Polly does not touch the body. */\nexport interface CustomSignalingFrame {\n type: string;\n [key: string]: unknown;\n}\n\n/** Minimal surface the custom-frame handler receives in place of the\n * Elysia-specific `ws` object so the plugin stays portable. Exposes the\n * `data` bag (used to stash the authenticated peerId under the existing\n * join handshake) and a `send` method. */\nexport interface CustomFrameSocket {\n data: Record<string, unknown>;\n send: (msg: unknown) => void;\n}\n\nexport interface SignalingServerOptions {\n /** WebSocket route path. Defaults to \"/polly/signaling\". */\n path?: string;\n /** Optional hook for frames whose `type` is outside the built-in\n * vocabulary. The plugin invokes it in place of returning a\n * `malformed` error, so consumers can layer their own application\n * protocol (pairing return tokens, presence pings, etc.) on the\n * existing socket. The `peerId` argument is the sender's\n * authenticated peer id from their prior `join` frame, or\n * `undefined` if they haven't joined yet. */\n onCustomFrame?: (\n socket: CustomFrameSocket,\n frame: CustomSignalingFrame,\n peerId: string | undefined\n ) => void;\n}\n\n/**\n * Construct the signalling-server Elysia plugin. The plugin keeps a\n * per-instance map of peer id → WebSocket connection so that incoming\n * \"signal\" messages can be routed to the right target socket. The map\n * is local to the plugin instance, not shared across processes; for\n * multi-instance deployments behind a load balancer, applications need\n * sticky connection routing or a shared backplane (Redis pub-sub or\n * similar). That is a follow-up.\n */\nexport function signalingServer(options: SignalingServerOptions = {}) {\n const path = options.path ?? \"/polly/signaling\";\n const onCustomFrame = options.onCustomFrame;\n // Per-peer-id map of joined sockets. The inverse mapping is stored\n // directly on ws.data (a mutable property bag that Elysia preserves\n // across message callbacks for a given connection); the webrtc-p2p-chat\n // example in examples/ confirms this pattern is stable under Bun.\n const peerSockets = new Map<string, { send: (msg: unknown) => void }>();\n\n // Intentionally unnamed — Elysia deduplicates plugins by name, and each\n // signalingServer() call needs its own closure-captured peer map.\n const parseMessage = (raw: unknown): SignalingMessage | undefined => {\n try {\n return typeof raw === \"string\" ? JSON.parse(raw) : (raw as unknown as SignalingMessage);\n } catch {\n return undefined;\n }\n };\n\n const handleJoin = (ws: unknown, peerId: string): void => {\n const newcomer = ws as unknown as { send: (m: unknown) => void };\n // Collect the peers that were already joined so we can (a) tell the\n // newcomer who is present and (b) tell each of them about the\n // newcomer. A rejoin with the same peerId replaces the prior entry\n // but is otherwise treated as a fresh arrival.\n const incumbents: Array<{ peerId: string; socket: { send: (m: unknown) => void } }> = [];\n for (const [existingPeerId, existingSocket] of peerSockets) {\n if (existingPeerId === peerId) continue;\n incumbents.push({ peerId: existingPeerId, socket: existingSocket });\n }\n peerSockets.set(peerId, newcomer);\n const wsWithData = ws as unknown as { data: Record<string, unknown> };\n wsWithData.data.peerId = peerId;\n\n newcomer.send({\n type: \"peers-present\",\n peerIds: incumbents.map((i) => i.peerId),\n } as unknown as SignalingMessage);\n\n for (const incumbent of incumbents) {\n try {\n incumbent.socket.send({ type: \"peer-joined\", peerId } as unknown as SignalingMessage);\n } catch {\n // If a send fails we leave the stale socket to its own close\n // handler to evict. Dropping here would open a window where\n // the next signal to this peer still thinks it's alive.\n }\n }\n };\n\n const sendUnknownTarget = (ws: unknown, targetPeerId: string): void => {\n (ws as unknown as { send: (m: unknown) => void }).send({\n type: \"error\",\n reason: \"unknown-target\",\n targetPeerId,\n } as unknown as SignalingMessage);\n };\n\n /** Look up a target socket and confirm it is still open. */\n const findOpenTarget = (targetPeerId: string): { send: (msg: unknown) => void } | undefined => {\n const target = peerSockets.get(targetPeerId);\n if (!target) return undefined;\n const readyState = (target as unknown as { readyState?: number }).readyState;\n const OPEN = 1;\n if (readyState !== undefined && readyState !== OPEN) {\n peerSockets.delete(targetPeerId);\n return undefined;\n }\n return target;\n };\n\n const handleSignal = (ws: unknown, msg: Extract<SignalingMessage, { type: \"signal\" }>): void => {\n const wsWithData = ws as unknown as {\n data: Record<string, unknown>;\n send: (m: unknown) => void;\n };\n const senderId = wsWithData.data.peerId as unknown as string | undefined;\n if (!senderId) {\n wsWithData.send({ type: \"error\", reason: \"not-joined\" } as unknown as SignalingMessage);\n return;\n }\n const target = findOpenTarget(msg.targetPeerId);\n if (!target) {\n sendUnknownTarget(ws, msg.targetPeerId);\n return;\n }\n const relayed: SignalingMessage = {\n type: \"signal\",\n peerId: senderId,\n targetPeerId: msg.targetPeerId,\n payload: msg.payload,\n };\n try {\n target.send(relayed);\n } catch {\n peerSockets.delete(msg.targetPeerId);\n sendUnknownTarget(ws, msg.targetPeerId);\n }\n };\n\n return new Elysia().ws(path, {\n message(ws, raw) {\n const msg = parseMessage(raw);\n if (!msg) {\n ws.send({ type: \"error\", reason: \"malformed\" } as unknown as SignalingMessage);\n return;\n }\n if (msg.type === \"join\") {\n handleJoin(ws, msg.peerId);\n return;\n }\n if (msg.type === \"signal\") {\n handleSignal(ws, msg);\n return;\n }\n // Unknown types route to the consumer's custom-frame hook when\n // one is configured. Without a hook they still fall through to\n // the `malformed` error — same behaviour as before this branch\n // existed.\n if (onCustomFrame !== undefined) {\n const wsWithData = ws as unknown as CustomFrameSocket;\n const senderId = wsWithData.data[\"peerId\"];\n const peerId = typeof senderId === \"string\" ? senderId : undefined;\n onCustomFrame(wsWithData, msg as unknown as CustomSignalingFrame, peerId);\n return;\n }\n ws.send({ type: \"error\", reason: \"malformed\" } as unknown as SignalingMessage);\n },\n\n close(ws) {\n const peerId = (ws.data as unknown as Record<string, unknown>).peerId as unknown as\n | string\n | undefined;\n if (!peerId) {\n // Connection that never sent a join — nothing to broadcast and\n // nothing to evict. A bystander coming and going leaves no trace.\n return;\n }\n // Only evict if the map still points at *this* socket. A stale\n // close after the same peerId rejoined on a new socket should not\n // take the fresh entry with it. The comparison uses the `data` bag\n // Elysia attaches to each connection because it is preserved across\n // message and close callbacks, unlike the `ws` wrapper object which\n // Elysia may or may not reuse.\n const mapped = peerSockets.get(peerId);\n const wsData = (ws as unknown as { data: Record<string, unknown> }).data;\n const mappedData = (mapped as unknown as { data?: Record<string, unknown> } | undefined)\n ?.data;\n if (mapped === undefined || mappedData !== wsData) {\n return;\n }\n peerSockets.delete(peerId);\n for (const [_incumbentId, incumbentSocket] of peerSockets) {\n try {\n incumbentSocket.send({ type: \"peer-left\", peerId } as unknown as SignalingMessage);\n } catch {\n // Incumbent socket is gone; its own close handler will tidy.\n }\n }\n },\n });\n}\n"
|
|
5
|
+
"#!/usr/bin/env bun\n\n/**\n * Browser test runner for Polly applications.\n *\n * Finds all *.browser.ts files in a given directory, bundles each with\n * Bun.build for the browser target (with an internal Automerge WASM fix),\n * serves the bundle on an ephemeral port, opens a Puppeteer page, and\n * polls window.__done for results. Prints pass/fail per test and exits\n * non-zero if any test failed.\n *\n * A signalling server for WebRTC tests starts automatically on a random\n * port. The URL is injected into the bundle via process.env.SIGNALING_URL.\n *\n * Usage (from project root):\n *\n * bun tools/test/src/browser/run.ts [testDir] [filter]\n *\n * Examples:\n *\n * bun tools/test/src/browser/run.ts tests/browser\n * bun tools/test/src/browser/run.ts tests/browser mesh-webrtc\n * HEADLESS=false bun tools/test/src/browser/run.ts tests/browser\n *\n * When invoked without a testDir, defaults to tests/browser relative to cwd.\n */\n\nimport { resolve } from \"node:path\";\nimport { type BunPlugin, Glob } from \"bun\";\nimport { Elysia } from \"elysia\";\nimport puppeteer, { type Page } from \"puppeteer\";\nimport { signalingServer } from \"../../../../src/elysia/signaling-server-plugin\";\nimport { errMessage, type FileTally, runSuite } from \"./runner-core\";\n\n// Automerge WASM fix\n// Bun.build's target: \"browser\" picks Automerge's fullfat_bundler.js which\n// does a static .wasm import that Bun can't wire up. Redirect to the\n// base64 variant which embeds the WASM as a string and self-initialises.\n\nconst automergeBase64Path = resolve(\n process.cwd(),\n \"node_modules/@automerge/automerge/dist/mjs/entrypoints/fullfat_base64.js\"\n);\n\nconst automergeBase64Plugin: BunPlugin = {\n name: \"automerge-base64\",\n setup(build) {\n build.onResolve({ filter: /^@automerge\\/automerge(\\/slim)?$/ }, () => {\n return { path: automergeBase64Path };\n });\n },\n};\n\n// Argument parsing\n\nconst testDir = resolve(process.cwd(), process.argv[2] ?? \"tests/browser\");\nconst filter = process.argv[3] ?? \"\";\nconst headless = process.env[\"HEADLESS\"] !== \"false\";\n\nconst glob = new Glob(\"**/*.browser.{ts,tsx}\");\nconst testFiles: string[] = [];\nfor await (const file of glob.scan({ cwd: testDir, absolute: true })) {\n if (file.includes(\"harness\")) continue;\n if (filter && !file.includes(filter)) continue;\n testFiles.push(file);\n}\n\nif (testFiles.length === 0) {\n console.log(`[browser-runner] no test files found${filter ? ` matching \"${filter}\"` : \"\"}`);\n process.exit(0);\n}\n\nconsole.log(`[browser-runner] found ${testFiles.length} test file(s)`);\n\n// Start server-side infrastructure\n\nconst signalingPort = 39000 + Math.floor(Math.random() * 1000);\nconst signalingApp = new Elysia()\n .use(signalingServer({ path: \"/polly/signaling\" }))\n .listen(signalingPort);\nconsole.log(`[browser-runner] signaling server on ws://127.0.0.1:${signalingPort}/polly/signaling`);\n\n// Launch browser\n//\n// protocolTimeout caps how long any single CDP call (e.g. page.evaluate)\n// waits before throwing. Puppeteer's default is 180s, so a stalled\n// renderer hangs the runner for three minutes. 30s fails fast while\n// still tolerating a slow-but-healthy initial sync.\n\nconst browser = await puppeteer.launch({\n headless,\n args: [\"--no-sandbox\", \"--disable-setuid-sandbox\"],\n protocolTimeout: 30_000,\n});\n\n/**\n * Build, serve, and run one test file on a fresh page. Returns its\n * pass/fail tally. Build failures and test timeouts are reported here\n * (not thrown). A thrown error (e.g. a ProtocolError from a stalled\n * renderer) propagates to the caller so it can retry the file; the\n * page and server are always cleaned up first.\n */\nasync function runFile(testFile: string): Promise<FileTally> {\n const buildResult = await Bun.build({\n entrypoints: [testFile],\n target: \"browser\",\n format: \"esm\",\n minify: false,\n sourcemap: \"inline\",\n plugins: [automergeBase64Plugin],\n define: {\n \"process.env.SIGNALING_URL\": JSON.stringify(\n `ws://127.0.0.1:${signalingPort}/polly/signaling`\n ),\n },\n });\n\n if (!buildResult.success) {\n console.log(\" ❌ build failed:\");\n for (const log of buildResult.logs) {\n console.log(` ${log}`);\n }\n return { passed: 0, failed: 1 };\n }\n\n const jsText = await buildResult.outputs[0]?.text();\n if (!jsText) {\n console.log(\" ❌ build produced no output\");\n return { passed: 0, failed: 1 };\n }\n\n const html = `<!DOCTYPE html>\n<html><head><meta charset=\"utf-8\"></head>\n<body>\n<script type=\"module\">${jsText}</script>\n</body></html>`;\n\n const server = Bun.serve({\n port: 0,\n fetch() {\n return new Response(html, { headers: { \"Content-Type\": \"text/html\" } });\n },\n });\n\n let page: Page | undefined;\n try {\n page = await browser.newPage();\n page.on(\"console\", (msg) => {\n const text = msg.text();\n if (text.includes(\"[test]\")) {\n console.log(` ${text}`);\n }\n });\n page.on(\"pageerror\", (err: unknown) => {\n console.log(` ❌ page error: ${errMessage(err)}`);\n });\n\n await page.goto(`http://127.0.0.1:${server.port}/`, { waitUntil: \"domcontentloaded\" });\n\n const timeout = 15_000;\n const deadline = Date.now() + timeout;\n let finished = false;\n while (Date.now() < deadline) {\n finished = await page.evaluate(\n () => (window as unknown as Record<string, unknown>)[\"__done\"] === true\n );\n if (finished) break;\n await new Promise((r) => setTimeout(r, 100));\n }\n\n if (!finished) {\n console.log(` ❌ timed out after ${timeout}ms`);\n return { passed: 0, failed: 1 };\n }\n\n const results = await page.evaluate(\n () =>\n (window as unknown as Record<string, unknown>)[\"__testResults\"] as unknown as Array<{\n name: string;\n passed: boolean;\n error?: string;\n }>\n );\n\n let passed = 0;\n let failed = 0;\n for (const r of results ?? []) {\n if (r.passed) {\n console.log(` ✅ ${r.name}`);\n passed += 1;\n } else {\n console.log(` ❌ ${r.name}: ${r.error}`);\n failed += 1;\n }\n }\n return { passed, failed };\n } finally {\n // Always run, even on the error path. close() can itself throw a\n // ProtocolError under a stall — swallow it so cleanup completes.\n if (page) {\n await page.close().catch(() => {\n // ignore — page may already be gone after a stall\n });\n }\n server.stop();\n }\n}\n\nconst { passed: totalPassed, failed: totalFailed } = await runSuite(testFiles, runFile, {\n label: (testFile) => testFile.replace(`${testDir}/`, \"\"),\n});\n\nawait browser.close();\n(signalingApp as unknown as { server?: { stop?: (f?: boolean) => void } }).server?.stop?.(true);\n\nconsole.log(`\\n[browser-runner] ${totalPassed} passed, ${totalFailed} failed`);\nprocess.exit(totalFailed > 0 ? 1 : 0);\n",
|
|
6
|
+
"// @ts-nocheck - Optional peer dependencies (elysia, @elysiajs/eden)\n/**\n * signalingServer — Phase 2 Elysia plugin that exposes a stateless\n * WebSocket route for SDP/ICE relay between $meshState peers.\n *\n * The mesh transport is a star-of-data-channels: peers establish direct\n * WebRTC connections to each other and exchange document operations\n * peer-to-peer once those channels are open. WebRTC connection setup\n * needs an out-of-band channel for SDP offer/answer and ICE candidate\n * exchange, and that channel is what this plugin provides. The plugin\n * does not own any document state, does not hold any encryption keys,\n * and never inspects the contents of the messages it relays. It is a\n * pure pub-sub by peer id.\n *\n * Once two peers have completed the SDP exchange and opened a direct\n * data channel, the signalling server is no longer on the critical\n * path — the peers talk directly. The signalling server's role is\n * therefore intermittent: peers connect to it only during the brief\n * windows when they are establishing or re-establishing connections.\n *\n * Wire protocol:\n *\n * Client → server (join):\n * { type: \"join\", peerId: \"peer-alice\" }\n *\n * Client → server (signal to another peer):\n * { type: \"signal\", peerId: \"peer-alice\", targetPeerId: \"peer-bob\",\n * payload: { ... } }\n *\n * Server → client (delivered signal):\n * { type: \"signal\", peerId: \"peer-alice\", targetPeerId: \"peer-bob\",\n * payload: { ... } }\n *\n * Server → client (notification of unknown target):\n * { type: \"error\", reason: \"unknown-target\", targetPeerId: \"...\" }\n *\n * The `payload` is opaque to the signalling server — typically it\n * carries an SDP offer, SDP answer, or ICE candidate. Applications can\n * also use the relay for any other peer-to-peer message that needs an\n * intermediary, such as the initial handshake of a pairing flow.\n *\n * @example\n * ```ts\n * import { Elysia } from \"elysia\";\n * import { signalingServer } from \"@fairfox/polly/elysia\";\n *\n * const app = new Elysia()\n * .use(signalingServer({ path: \"/polly/signaling\" }))\n * .listen(8080);\n * ```\n */\n\nimport { Elysia } from \"elysia\";\n\n/** A signalling message. The `type` discriminates between client-to-server\n * requests (join, signal), the error envelope, and the server-to-client\n * discovery frames (peers-present, peer-joined, peer-left) that let\n * peers learn about each other without polling. */\nexport type SignalingMessage =\n | {\n type: \"join\";\n /** The peer registering itself with the signalling server. */\n peerId: string;\n }\n | {\n type: \"signal\";\n /** The peer sending the signal. */\n peerId: string;\n /** The peer the signal is being relayed to. */\n targetPeerId: string;\n /** Opaque payload, typically SDP or ICE. */\n payload: unknown;\n }\n | {\n type: \"error\";\n reason: \"unknown-target\" | \"not-joined\" | \"malformed\";\n targetPeerId?: string;\n }\n | {\n /** Sent to a newcomer immediately after it joins, listing every\n * peer that was already joined at that moment. Empty for a lone\n * newcomer. */\n type: \"peers-present\";\n peerIds: string[];\n }\n | {\n /** Broadcast to every incumbent when a new peer joins. */\n type: \"peer-joined\";\n peerId: string;\n }\n | {\n /** Broadcast to every remaining incumbent when a joined peer\n * closes its socket. Never emitted for a connection that never\n * sent a join frame. */\n type: \"peer-left\";\n peerId: string;\n };\n\n/** A frame whose `type` is outside the built-in signalling vocabulary.\n * Consumers who pass an {@link SignalingServerOptions.onCustomFrame}\n * handler receive these on the server side; everything else — including\n * routing them to a specific peer, storing a session, or rejecting the\n * frame — is the consumer's call. Polly does not touch the body. */\nexport interface CustomSignalingFrame {\n type: string;\n [key: string]: unknown;\n}\n\n/** Minimal surface the custom-frame handler receives in place of the\n * Elysia-specific `ws` object so the plugin stays portable. Exposes the\n * `data` bag (used to stash the authenticated peerId under the existing\n * join handshake) and a `send` method. */\nexport interface CustomFrameSocket {\n data: Record<string, unknown>;\n send: (msg: unknown) => void;\n}\n\nexport interface SignalingServerOptions {\n /** WebSocket route path. Defaults to \"/polly/signaling\". */\n path?: string;\n /** Optional hook for frames whose `type` is outside the built-in\n * vocabulary. The plugin invokes it in place of returning a\n * `malformed` error, so consumers can layer their own application\n * protocol (pairing return tokens, presence pings, etc.) on the\n * existing socket. The `peerId` argument is the sender's\n * authenticated peer id from their prior `join` frame, or\n * `undefined` if they haven't joined yet. */\n onCustomFrame?: (\n socket: CustomFrameSocket,\n frame: CustomSignalingFrame,\n peerId: string | undefined\n ) => void;\n}\n\n/**\n * Construct the signalling-server Elysia plugin. The plugin keeps a\n * per-instance map of peer id → WebSocket connection so that incoming\n * \"signal\" messages can be routed to the right target socket. The map\n * is local to the plugin instance, not shared across processes; for\n * multi-instance deployments behind a load balancer, applications need\n * sticky connection routing or a shared backplane (Redis pub-sub or\n * similar). That is a follow-up.\n */\nexport function signalingServer(options: SignalingServerOptions = {}) {\n const path = options.path ?? \"/polly/signaling\";\n const onCustomFrame = options.onCustomFrame;\n // Per-peer-id map of joined sockets. The inverse mapping is stored\n // directly on ws.data (a mutable property bag that Elysia preserves\n // across message callbacks for a given connection); the webrtc-p2p-chat\n // example in examples/ confirms this pattern is stable under Bun.\n const peerSockets = new Map<string, { send: (msg: unknown) => void }>();\n\n // Intentionally unnamed — Elysia deduplicates plugins by name, and each\n // signalingServer() call needs its own closure-captured peer map.\n const parseMessage = (raw: unknown): SignalingMessage | undefined => {\n try {\n return typeof raw === \"string\" ? JSON.parse(raw) : (raw as unknown as SignalingMessage);\n } catch {\n return undefined;\n }\n };\n\n const handleJoin = (ws: unknown, peerId: string): void => {\n const newcomer = ws as unknown as { send: (m: unknown) => void };\n // Collect the peers that were already joined so we can (a) tell the\n // newcomer who is present and (b) tell each of them about the\n // newcomer. A rejoin with the same peerId replaces the prior entry\n // but is otherwise treated as a fresh arrival.\n const incumbents: Array<{ peerId: string; socket: { send: (m: unknown) => void } }> = [];\n for (const [existingPeerId, existingSocket] of peerSockets) {\n if (existingPeerId === peerId) continue;\n incumbents.push({ peerId: existingPeerId, socket: existingSocket });\n }\n peerSockets.set(peerId, newcomer);\n const wsWithData = ws as unknown as { data: Record<string, unknown> };\n wsWithData.data.peerId = peerId;\n\n newcomer.send({\n type: \"peers-present\",\n peerIds: incumbents.map((i) => i.peerId),\n } as unknown as SignalingMessage);\n\n for (const incumbent of incumbents) {\n try {\n incumbent.socket.send({ type: \"peer-joined\", peerId } as unknown as SignalingMessage);\n } catch {\n // If a send fails we leave the stale socket to its own close\n // handler to evict. Dropping here would open a window where\n // the next signal to this peer still thinks it's alive.\n }\n }\n };\n\n const sendUnknownTarget = (ws: unknown, targetPeerId: string): void => {\n (ws as unknown as { send: (m: unknown) => void }).send({\n type: \"error\",\n reason: \"unknown-target\",\n targetPeerId,\n } as unknown as SignalingMessage);\n };\n\n /** Look up a target socket and confirm it is still open. */\n const findOpenTarget = (targetPeerId: string): { send: (msg: unknown) => void } | undefined => {\n const target = peerSockets.get(targetPeerId);\n if (!target) return undefined;\n const readyState = (target as unknown as { readyState?: number }).readyState;\n const OPEN = 1;\n if (readyState !== undefined && readyState !== OPEN) {\n peerSockets.delete(targetPeerId);\n return undefined;\n }\n return target;\n };\n\n const handleSignal = (ws: unknown, msg: Extract<SignalingMessage, { type: \"signal\" }>): void => {\n const wsWithData = ws as unknown as {\n data: Record<string, unknown>;\n send: (m: unknown) => void;\n };\n const senderId = wsWithData.data.peerId as unknown as string | undefined;\n if (!senderId) {\n wsWithData.send({ type: \"error\", reason: \"not-joined\" } as unknown as SignalingMessage);\n return;\n }\n const target = findOpenTarget(msg.targetPeerId);\n if (!target) {\n sendUnknownTarget(ws, msg.targetPeerId);\n return;\n }\n const relayed: SignalingMessage = {\n type: \"signal\",\n peerId: senderId,\n targetPeerId: msg.targetPeerId,\n payload: msg.payload,\n };\n try {\n target.send(relayed);\n } catch {\n peerSockets.delete(msg.targetPeerId);\n sendUnknownTarget(ws, msg.targetPeerId);\n }\n };\n\n return new Elysia().ws(path, {\n message(ws, raw) {\n const msg = parseMessage(raw);\n if (!msg) {\n ws.send({ type: \"error\", reason: \"malformed\" } as unknown as SignalingMessage);\n return;\n }\n if (msg.type === \"join\") {\n handleJoin(ws, msg.peerId);\n return;\n }\n if (msg.type === \"signal\") {\n handleSignal(ws, msg);\n return;\n }\n // Unknown types route to the consumer's custom-frame hook when\n // one is configured. Without a hook they still fall through to\n // the `malformed` error — same behaviour as before this branch\n // existed.\n if (onCustomFrame !== undefined) {\n const wsWithData = ws as unknown as CustomFrameSocket;\n const senderId = wsWithData.data[\"peerId\"];\n const peerId = typeof senderId === \"string\" ? senderId : undefined;\n onCustomFrame(wsWithData, msg as unknown as CustomSignalingFrame, peerId);\n return;\n }\n ws.send({ type: \"error\", reason: \"malformed\" } as unknown as SignalingMessage);\n },\n\n close(ws) {\n const peerId = (ws.data as unknown as Record<string, unknown>).peerId as unknown as\n | string\n | undefined;\n if (!peerId) {\n // Connection that never sent a join — nothing to broadcast and\n // nothing to evict. A bystander coming and going leaves no trace.\n return;\n }\n // Only evict if the map still points at *this* socket. A stale\n // close after the same peerId rejoined on a new socket should not\n // take the fresh entry with it. The comparison uses the `data` bag\n // Elysia attaches to each connection because it is preserved across\n // message and close callbacks, unlike the `ws` wrapper object which\n // Elysia may or may not reuse.\n const mapped = peerSockets.get(peerId);\n const wsData = (ws as unknown as { data: Record<string, unknown> }).data;\n const mappedData = (mapped as unknown as { data?: Record<string, unknown> } | undefined)\n ?.data;\n if (mapped === undefined || mappedData !== wsData) {\n return;\n }\n peerSockets.delete(peerId);\n for (const [_incumbentId, incumbentSocket] of peerSockets) {\n try {\n incumbentSocket.send({ type: \"peer-left\", peerId } as unknown as SignalingMessage);\n } catch {\n // Incumbent socket is gone; its own close handler will tidy.\n }\n }\n },\n });\n}\n",
|
|
7
|
+
"/**\n * Suite orchestration for the browser test runner.\n *\n * Extracted from run.ts so the resilience guarantee — a transient CDP\n * timeout on one file never aborts the whole suite — is a pure function\n * that can be unit-tested without launching a browser. run.ts supplies\n * the real per-file `runFile`; the test suite supplies a fake one.\n */\n\n/** Pass/fail tally for one test file or for a whole suite. */\nexport interface FileTally {\n passed: number;\n failed: number;\n}\n\n/** A transient CDP timeout (renderer stall) — retryable, unlike a red test. */\nexport function isProtocolError(err: unknown): boolean {\n return err instanceof Error && err.name === \"ProtocolError\";\n}\n\nexport function errMessage(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\n/**\n * Run every test file, isolating each file's failure.\n *\n * - A thrown `ProtocolError` (renderer stall) is retried once on a fresh\n * page; if the retry also throws, the file is recorded as failed.\n * - Any other thrown error fails only that file.\n * - A file that returns a tally with `failed > 0` (a genuine red test) is\n * reported as-is and never retried.\n *\n * The loop never rejects: a stall in one file can never abort the suite,\n * so remaining files always execute and report.\n */\nexport async function runSuite(\n testFiles: string[],\n runFile: (testFile: string) => Promise<FileTally>,\n options: {\n label?: (testFile: string) => string;\n log?: (msg: string) => void;\n } = {}\n): Promise<FileTally> {\n const label = options.label ?? ((f) => f);\n const log = options.log ?? console.log;\n\n let totalPassed = 0;\n let totalFailed = 0;\n\n for (const testFile of testFiles) {\n log(`\\n[browser-runner] running ${label(testFile)}`);\n\n let result: FileTally;\n try {\n result = await runFile(testFile);\n } catch (err) {\n if (isProtocolError(err)) {\n log(` ⚠️ protocol error (${errMessage(err)}) — retrying once on a fresh page`);\n try {\n result = await runFile(testFile);\n } catch (retryErr) {\n log(` ❌ retry failed: ${errMessage(retryErr)}`);\n result = { passed: 0, failed: 1 };\n }\n } else {\n // A non-protocol error: record the file as failed and keep going,\n // never abort the whole suite.\n log(` ❌ ${errMessage(err)}`);\n result = { passed: 0, failed: 1 };\n }\n }\n\n totalPassed += result.passed;\n totalFailed += result.failed;\n }\n\n return { passed: totalPassed, failed: totalFailed };\n}\n"
|
|
7
8
|
],
|
|
8
|
-
"mappings": "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
|
|
9
|
-
"debugId": "
|
|
9
|
+
"mappings": "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",
|
|
10
|
+
"debugId": "B8D937D1C5C3E44264756E2164756E21",
|
|
10
11
|
"names": []
|
|
11
12
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Suite orchestration for the browser test runner.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from run.ts so the resilience guarantee — a transient CDP
|
|
5
|
+
* timeout on one file never aborts the whole suite — is a pure function
|
|
6
|
+
* that can be unit-tested without launching a browser. run.ts supplies
|
|
7
|
+
* the real per-file `runFile`; the test suite supplies a fake one.
|
|
8
|
+
*/
|
|
9
|
+
/** Pass/fail tally for one test file or for a whole suite. */
|
|
10
|
+
export interface FileTally {
|
|
11
|
+
passed: number;
|
|
12
|
+
failed: number;
|
|
13
|
+
}
|
|
14
|
+
/** A transient CDP timeout (renderer stall) — retryable, unlike a red test. */
|
|
15
|
+
export declare function isProtocolError(err: unknown): boolean;
|
|
16
|
+
export declare function errMessage(err: unknown): string;
|
|
17
|
+
/**
|
|
18
|
+
* Run every test file, isolating each file's failure.
|
|
19
|
+
*
|
|
20
|
+
* - A thrown `ProtocolError` (renderer stall) is retried once on a fresh
|
|
21
|
+
* page; if the retry also throws, the file is recorded as failed.
|
|
22
|
+
* - Any other thrown error fails only that file.
|
|
23
|
+
* - A file that returns a tally with `failed > 0` (a genuine red test) is
|
|
24
|
+
* reported as-is and never retried.
|
|
25
|
+
*
|
|
26
|
+
* The loop never rejects: a stall in one file can never abort the suite,
|
|
27
|
+
* so remaining files always execute and report.
|
|
28
|
+
*/
|
|
29
|
+
export declare function runSuite(testFiles: string[], runFile: (testFile: string) => Promise<FileTally>, options?: {
|
|
30
|
+
label?: (testFile: string) => string;
|
|
31
|
+
log?: (msg: string) => void;
|
|
32
|
+
}): Promise<FileTally>;
|