@openparachute/vault 0.6.5-rc.12 → 0.6.5-rc.13
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/.parachute/module.json +1 -0
- package/package.json +1 -1
- package/src/live-frame-parity.test.ts +226 -0
- package/src/module-manifest.ts +8 -0
- package/src/self-register.test.ts +19 -0
- package/src/self-register.ts +6 -1
- package/src/server.ts +24 -0
- package/src/services-manifest.ts +8 -0
- package/src/subscribe.ts +23 -22
- package/src/subscriptions.ts +125 -37
- package/src/test-support/live-frame-corpus.ts +72 -0
- package/src/ws-server.ts +408 -0
- package/src/ws-subscribe.test.ts +474 -0
- package/src/ws-subscribe.ts +242 -0
package/.parachute/module.json
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live-query frame PARITY + the pure WS helpers (WS-hibernation migration
|
|
3
|
+
* Phase 3, self-host door).
|
|
4
|
+
*
|
|
5
|
+
* The load-bearing CROSS-DOOR conformance check: for the shared frame corpus
|
|
6
|
+
* (mirrored byte-for-byte from the cloud door), the self-host WS payload and the
|
|
7
|
+
* self-host SSE `data:` body carry a byte-IDENTICAL serialization of the inner
|
|
8
|
+
* payload (`notes` / `note` / `id`) — the ONLY difference is the `type`
|
|
9
|
+
* discriminator the WS message folds in (plus the snapshot `done` chunk flag).
|
|
10
|
+
* Because the corpus AND the `sseFrame`/`wsFrame` formatters are byte-shaped
|
|
11
|
+
* identical to cloud's, this transitively proves: self-host WS bytes === corpus
|
|
12
|
+
* === self-host SSE bytes === cloud WS/SSE bytes.
|
|
13
|
+
*
|
|
14
|
+
* Plus unit coverage of the pure helpers that back the Bun.serve integration:
|
|
15
|
+
* snapshot chunking, first-message parsing, verb-rank, and the query rejects.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, it, expect } from "bun:test";
|
|
18
|
+
import { sseFrame, wsFrame, SseSink, WsSink, snapshotFrame } from "./subscriptions.ts";
|
|
19
|
+
import {
|
|
20
|
+
buildSnapshotFrames,
|
|
21
|
+
parseClientMessage,
|
|
22
|
+
vaultVerbRank,
|
|
23
|
+
sameTagScope,
|
|
24
|
+
validateWsSubscribeQuery,
|
|
25
|
+
subscriptionCapResponse,
|
|
26
|
+
urlFromQuery,
|
|
27
|
+
SNAPSHOT_CHUNK_MAX_NOTES,
|
|
28
|
+
WS_CLOSE,
|
|
29
|
+
} from "./ws-subscribe.ts";
|
|
30
|
+
import { unsupportedSubscriptionReason } from "./live-match.ts";
|
|
31
|
+
import { FRAME_CORPUS, NOTE_A } from "./test-support/live-frame-corpus.ts";
|
|
32
|
+
|
|
33
|
+
/** Strip the SSE `data:` body out of a single-event SSE frame. */
|
|
34
|
+
function sseDataBody(frame: string): string {
|
|
35
|
+
const line = frame.split("\n").find((l) => l.startsWith("data:"))!;
|
|
36
|
+
return line.slice("data:".length).replace(/^ /, "");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("live-frame parity — self-host WS bytes === corpus === SSE data body", () => {
|
|
40
|
+
for (const c of FRAME_CORPUS) {
|
|
41
|
+
it(`${c.name}: identical inner payload across transports`, () => {
|
|
42
|
+
const sse = sseFrame(c.event, c.data);
|
|
43
|
+
const ws = wsFrame(c.event, c.data);
|
|
44
|
+
|
|
45
|
+
// SSE frame shape (byte-identical to the pre-WS-migration contract).
|
|
46
|
+
expect(sse).toBe(`event: ${c.event}\ndata: ${JSON.stringify(c.data)}\n\n`);
|
|
47
|
+
// SSE data body parses back to exactly the payload.
|
|
48
|
+
expect(JSON.parse(sseDataBody(sse))).toEqual(c.data);
|
|
49
|
+
|
|
50
|
+
// WS message is `{ type, ...data }` — the event name folds into `type`.
|
|
51
|
+
const parsedWs = JSON.parse(ws) as Record<string, unknown>;
|
|
52
|
+
expect(parsedWs.type).toBe(c.event);
|
|
53
|
+
const { type, ...rest } = parsedWs;
|
|
54
|
+
void type;
|
|
55
|
+
expect(rest).toEqual(c.data);
|
|
56
|
+
|
|
57
|
+
// BYTE parity of the inner payload: whatever value the event carries
|
|
58
|
+
// (`notes` / `note` / `id`) serializes IDENTICALLY in both frames AND
|
|
59
|
+
// equals the corpus serialization (the cross-door invariant).
|
|
60
|
+
const innerKey = c.event === "snapshot" ? "notes" : c.event === "upsert" ? "note" : "id";
|
|
61
|
+
const innerBytes = JSON.stringify((c.data as Record<string, unknown>)[innerKey]);
|
|
62
|
+
expect(sse.includes(innerBytes)).toBe(true);
|
|
63
|
+
expect(ws.includes(innerBytes)).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
it("snapshotFrame() is the SSE snapshot frame for a note set", () => {
|
|
68
|
+
expect(snapshotFrame([NOTE_A as any])).toBe(sseFrame("snapshot", { notes: [NOTE_A] }));
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("sink classes render through the ONE formatter each", () => {
|
|
73
|
+
it("WsSink.send emits exactly wsFrame(event, data)", () => {
|
|
74
|
+
const sent: string[] = [];
|
|
75
|
+
const sink = new WsSink({ send: (d: string) => sent.push(d), close: () => {} });
|
|
76
|
+
for (const c of FRAME_CORPUS) {
|
|
77
|
+
sink.send(c.event, c.data);
|
|
78
|
+
expect(sent.at(-1)).toBe(wsFrame(c.event, c.data));
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("SseSink.send emits exactly sseFrame(event, data); comment() emits `:\\n\\n`", () => {
|
|
83
|
+
const pushed: string[] = [];
|
|
84
|
+
const sink = new SseSink((f) => (pushed.push(f), true), () => {});
|
|
85
|
+
sink.send("upsert", { note: NOTE_A });
|
|
86
|
+
expect(pushed.at(-1)).toBe(sseFrame("upsert", { note: NOTE_A }));
|
|
87
|
+
sink.comment();
|
|
88
|
+
expect(pushed.at(-1)).toBe(":\n\n");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("buildSnapshotFrames — chunking + done flag", () => {
|
|
93
|
+
it("empty set → exactly one done:true frame", () => {
|
|
94
|
+
const frames = buildSnapshotFrames([]);
|
|
95
|
+
expect(frames.length).toBe(1);
|
|
96
|
+
expect(JSON.parse(frames[0]!)).toEqual({ type: "snapshot", notes: [], done: true });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("small set → one done:true frame carrying every note", () => {
|
|
100
|
+
const frames = buildSnapshotFrames([NOTE_A as any, NOTE_A as any]);
|
|
101
|
+
expect(frames.length).toBe(1);
|
|
102
|
+
const f = JSON.parse(frames[0]!);
|
|
103
|
+
expect(f.done).toBe(true);
|
|
104
|
+
expect(f.notes.length).toBe(2);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("> SNAPSHOT_CHUNK_MAX_NOTES → multiple frames, only the last done:true, concat = full set", () => {
|
|
108
|
+
const many = Array.from({ length: SNAPSHOT_CHUNK_MAX_NOTES + 5 }, (_, i) => ({ ...NOTE_A, id: `n-${i}` }));
|
|
109
|
+
const frames = buildSnapshotFrames(many as any);
|
|
110
|
+
expect(frames.length).toBeGreaterThan(1);
|
|
111
|
+
const parsed = frames.map((f) => JSON.parse(f));
|
|
112
|
+
expect(parsed.filter((p) => p.done).length).toBe(1);
|
|
113
|
+
expect(parsed[parsed.length - 1]!.done).toBe(true);
|
|
114
|
+
parsed.slice(0, -1).forEach((p) => expect(p.done).toBe(false));
|
|
115
|
+
const concat = parsed.flatMap((p) => p.notes);
|
|
116
|
+
expect(concat.map((n: any) => n.id)).toEqual(many.map((n) => n.id));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("each frame stays under the WS message ceiling with large notes", () => {
|
|
120
|
+
const big = Array.from({ length: 8 }, (_, i) => ({ ...NOTE_A, id: `big-${i}`, content: "x".repeat(200_000) }));
|
|
121
|
+
const frames = buildSnapshotFrames(big as any);
|
|
122
|
+
expect(frames.length).toBeGreaterThan(1);
|
|
123
|
+
for (const f of frames) expect(new TextEncoder().encode(f).byteLength).toBeLessThan(1_000_000);
|
|
124
|
+
expect(frames.flatMap((f) => JSON.parse(f).notes).length).toBe(8);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("parseClientMessage", () => {
|
|
129
|
+
it("valid auth", () => {
|
|
130
|
+
expect(parseClientMessage(JSON.stringify({ type: "auth", token: "t" }))).toEqual({ kind: "auth", token: "t" });
|
|
131
|
+
});
|
|
132
|
+
it("auth without a token → malformed", () => {
|
|
133
|
+
expect(parseClientMessage(JSON.stringify({ type: "auth" })).kind).toBe("malformed");
|
|
134
|
+
expect(parseClientMessage(JSON.stringify({ type: "auth", token: "" })).kind).toBe("malformed");
|
|
135
|
+
});
|
|
136
|
+
it("non-json / non-object → malformed", () => {
|
|
137
|
+
expect(parseClientMessage("not json").kind).toBe("malformed");
|
|
138
|
+
expect(parseClientMessage(JSON.stringify([1, 2])).kind).toBe("malformed");
|
|
139
|
+
});
|
|
140
|
+
it("other typed message → other", () => {
|
|
141
|
+
expect(parseClientMessage(JSON.stringify({ type: "hello" }))).toEqual({ kind: "other", type: "hello" });
|
|
142
|
+
});
|
|
143
|
+
it("decodes a Uint8Array frame", () => {
|
|
144
|
+
const bytes = new TextEncoder().encode(JSON.stringify({ type: "auth", token: "b" }));
|
|
145
|
+
expect(parseClientMessage(bytes)).toEqual({ kind: "auth", token: "b" });
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("vaultVerbRank — narrow-or-equal ordering", () => {
|
|
150
|
+
it("ranks read < write < admin, -1 for none/other-vault", () => {
|
|
151
|
+
expect(vaultVerbRank(["vault:v:read"], "v")).toBe(0);
|
|
152
|
+
expect(vaultVerbRank(["vault:v:write"], "v")).toBe(1);
|
|
153
|
+
expect(vaultVerbRank(["vault:v:admin"], "v")).toBe(2);
|
|
154
|
+
expect(vaultVerbRank(["vault:other:read"], "v")).toBe(-1);
|
|
155
|
+
expect(vaultVerbRank([], "v")).toBe(-1);
|
|
156
|
+
});
|
|
157
|
+
it("a widen is strictly greater (the 4403 trigger)", () => {
|
|
158
|
+
expect(vaultVerbRank(["vault:v:admin"], "v") > vaultVerbRank(["vault:v:read"], "v")).toBe(true);
|
|
159
|
+
expect(vaultVerbRank(["vault:v:read"], "v") > vaultVerbRank(["vault:v:admin"], "v")).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("sameTagScope — the WS re-auth tag-scope-delta gate", () => {
|
|
164
|
+
it("both unscoped (null) → same", () => {
|
|
165
|
+
expect(sameTagScope(null, null)).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
it("null vs scoped → differ (a drop/gain of scoping)", () => {
|
|
168
|
+
expect(sameTagScope(null, ["work/eng"])).toBe(false);
|
|
169
|
+
expect(sameTagScope(["work/eng"], null)).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
it("same set (order/dupe-insensitive) → same", () => {
|
|
172
|
+
expect(sameTagScope(["a", "b"], ["b", "a"])).toBe(true);
|
|
173
|
+
expect(sameTagScope(["a", "a", "b"], ["b", "a"])).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
it("different membership → differ", () => {
|
|
176
|
+
expect(sameTagScope(["work/eng"], ["work/sales"])).toBe(false);
|
|
177
|
+
expect(sameTagScope(["a"], ["a", "b"])).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("validateWsSubscribeQuery — same rejects as the SSE route (byte-identical bodies)", () => {
|
|
182
|
+
const reject = async (q: string) => {
|
|
183
|
+
const v = validateWsSubscribeQuery(new URL(`http://x/vault/v/api/subscribe?${q}`));
|
|
184
|
+
expect("error" in v).toBe(true);
|
|
185
|
+
const body = await (v as { error: Response }).error.json();
|
|
186
|
+
expect((v as { error: Response }).error.status).toBe(400);
|
|
187
|
+
expect(body.code).toBe("UNSUPPORTED_SUBSCRIPTION_QUERY");
|
|
188
|
+
};
|
|
189
|
+
it("rejects the URL-reachable unsupported shapes: search / near / cursor / has_links", async () => {
|
|
190
|
+
await reject("search=hi");
|
|
191
|
+
await reject("near[note_id]=abc");
|
|
192
|
+
await reject("cursor=xyz");
|
|
193
|
+
await reject("has_links=true");
|
|
194
|
+
});
|
|
195
|
+
it("accepts a supported query", () => {
|
|
196
|
+
const v = validateWsSubscribeQuery(new URL("http://x/vault/v/api/subscribe?tag=chat&path_prefix=meetings/"));
|
|
197
|
+
expect("queryOpts" in v).toBe(true);
|
|
198
|
+
});
|
|
199
|
+
it("shares the SSE route's queryOpts-level guard (cursor/has_links/date filters)", () => {
|
|
200
|
+
// The belt-and-suspenders layer both doors + the SSE route route through:
|
|
201
|
+
// a date filter isn't expressible as a flat URL param (removed 0.6.4), but
|
|
202
|
+
// if one ever reaches queryOpts it is rejected identically.
|
|
203
|
+
expect(unsupportedSubscriptionReason({ cursor: "x" } as any)).toBeTruthy();
|
|
204
|
+
expect(unsupportedSubscriptionReason({ hasLinks: true } as any)).toBeTruthy();
|
|
205
|
+
expect(unsupportedSubscriptionReason({ dateFrom: "2026-01-01" } as any)).toBeTruthy();
|
|
206
|
+
expect(unsupportedSubscriptionReason({ dateFilter: { field: "created_at" } } as any)).toBeTruthy();
|
|
207
|
+
expect(unsupportedSubscriptionReason({ tags: ["chat"] } as any)).toBeNull();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("misc pure helpers", () => {
|
|
212
|
+
it("subscriptionCapResponse → 503 SUBSCRIPTION_CAP_REACHED", async () => {
|
|
213
|
+
const res = subscriptionCapResponse();
|
|
214
|
+
expect(res.status).toBe(503);
|
|
215
|
+
expect((await res.json()).code).toBe("SUBSCRIPTION_CAP_REACHED");
|
|
216
|
+
});
|
|
217
|
+
it("urlFromQuery reconstructs searchParams from a raw query string", () => {
|
|
218
|
+
expect(urlFromQuery("?tag=chat&a=b").searchParams.get("tag")).toBe("chat");
|
|
219
|
+
expect(urlFromQuery("").search).toBe("");
|
|
220
|
+
});
|
|
221
|
+
it("WS_CLOSE carries the four application close codes", () => {
|
|
222
|
+
expect([WS_CLOSE.PROTOCOL, WS_CLOSE.UNAUTHORIZED, WS_CLOSE.FORBIDDEN, WS_CLOSE.AUTH_TIMEOUT]).toEqual([
|
|
223
|
+
4400, 4401, 4403, 4408,
|
|
224
|
+
]);
|
|
225
|
+
});
|
|
226
|
+
});
|
package/src/module-manifest.ts
CHANGED
|
@@ -48,6 +48,14 @@ export interface VaultModuleManifest {
|
|
|
48
48
|
readonly paths: readonly string[];
|
|
49
49
|
readonly health: string;
|
|
50
50
|
readonly stripPrefix?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* When `true`, vault's daemon accepts WebSocket upgrades (the live-query WS
|
|
53
|
+
* binding) and the hub's ws-bridge forwards `Upgrade: websocket` requests on
|
|
54
|
+
* vault's mounts. DENY BY DEFAULT on the hub side. Carried onto the
|
|
55
|
+
* self-registered services.json row too (`ServiceEntry.websocket`); the hub
|
|
56
|
+
* honors either source. See `parachute-hub/src/ws-bridge.ts`.
|
|
57
|
+
*/
|
|
58
|
+
readonly websocket?: boolean;
|
|
51
59
|
}
|
|
52
60
|
|
|
53
61
|
/**
|
|
@@ -382,6 +382,25 @@ describe("self-register", () => {
|
|
|
382
382
|
});
|
|
383
383
|
});
|
|
384
384
|
|
|
385
|
+
test("websocket capability flows from manifest to row when set (hub ws-bridge gate)", () => {
|
|
386
|
+
withParachuteHome((home) => {
|
|
387
|
+
const { log, warn } = captureLogs();
|
|
388
|
+
selfRegister({
|
|
389
|
+
version: "0.4.8-rc.3",
|
|
390
|
+
log,
|
|
391
|
+
warn,
|
|
392
|
+
readManifest: () => ({ ...TEST_MANIFEST, websocket: true }),
|
|
393
|
+
resolvePackageRoot: () => "/fake/install/dir",
|
|
394
|
+
listVaults: () => [],
|
|
395
|
+
readGlobalConfig: () => ({ port: 1940 }),
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const raw = readFileSync(join(home, "services.json"), "utf8");
|
|
399
|
+
const parsed = JSON.parse(raw) as { services: Record<string, unknown>[] };
|
|
400
|
+
expect(parsed.services[0]!.websocket).toBe(true);
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
385
404
|
test("changed=true when installDir differs from prior row", () => {
|
|
386
405
|
withParachuteHome(() => {
|
|
387
406
|
const { log, warn } = captureLogs();
|
package/src/self-register.ts
CHANGED
|
@@ -191,6 +191,7 @@ export function selfRegister(deps: SelfRegisterDeps): SelfRegisterResult {
|
|
|
191
191
|
displayName?: string;
|
|
192
192
|
tagline?: string;
|
|
193
193
|
stripPrefix?: boolean;
|
|
194
|
+
websocket?: boolean;
|
|
194
195
|
} = {
|
|
195
196
|
name: manifest.manifestName,
|
|
196
197
|
port,
|
|
@@ -202,6 +203,9 @@ export function selfRegister(deps: SelfRegisterDeps): SelfRegisterResult {
|
|
|
202
203
|
if (manifest.displayName !== undefined) entry.displayName = manifest.displayName;
|
|
203
204
|
if (manifest.tagline !== undefined) entry.tagline = manifest.tagline;
|
|
204
205
|
if (manifest.stripPrefix !== undefined) entry.stripPrefix = manifest.stripPrefix;
|
|
206
|
+
// Carry the live-query WS capability onto the row so the hub ws-bridge admits
|
|
207
|
+
// upgrades without having to read module.json (the row is its first source).
|
|
208
|
+
if (manifest.websocket !== undefined) entry.websocket = manifest.websocket;
|
|
205
209
|
|
|
206
210
|
// Detect whether the existing row already matches (no-op idempotency
|
|
207
211
|
// signal). We don't gate the write on this — `upsertService` itself is
|
|
@@ -232,7 +236,8 @@ export function selfRegister(deps: SelfRegisterDeps): SelfRegisterResult {
|
|
|
232
236
|
priorRow.health !== health ||
|
|
233
237
|
(priorRow as { displayName?: string }).displayName !== manifest.displayName ||
|
|
234
238
|
(priorRow as { tagline?: string }).tagline !== manifest.tagline ||
|
|
235
|
-
(priorRow as { stripPrefix?: boolean }).stripPrefix !== manifest.stripPrefix
|
|
239
|
+
(priorRow as { stripPrefix?: boolean }).stripPrefix !== manifest.stripPrefix ||
|
|
240
|
+
(priorRow as { websocket?: boolean }).websocket !== manifest.websocket;
|
|
236
241
|
|
|
237
242
|
try {
|
|
238
243
|
upsertImpl(entry);
|
package/src/server.ts
CHANGED
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
} from "./mirror-config.ts";
|
|
62
62
|
import { GLOBAL_CONFIG_PATH } from "./config.ts";
|
|
63
63
|
import { selfRegister } from "./self-register.ts";
|
|
64
|
+
import { createSubscribeWsBinding, isWebSocketUpgrade } from "./ws-server.ts";
|
|
64
65
|
import { warnLegacyGlobalApiKeys } from "./auth.ts";
|
|
65
66
|
import pkg from "../package.json" with { type: "json" };
|
|
66
67
|
|
|
@@ -490,10 +491,18 @@ const hostname = resolveBindHostname();
|
|
|
490
491
|
}
|
|
491
492
|
}
|
|
492
493
|
|
|
494
|
+
// Live-query WebSocket binding (WS-hibernation migration Phase 3). Same
|
|
495
|
+
// subscription contract as the SSE route, WebSocket transport. Handlers +
|
|
496
|
+
// upgrade decision live in ws-server.ts; the per-vault WS cap is closure state
|
|
497
|
+
// there. Advertised via `"websocket": true` in `.parachute/module.json` so the
|
|
498
|
+
// hub ws-bridge admits the upgrade.
|
|
499
|
+
const subscribeWs = createSubscribeWsBinding();
|
|
500
|
+
|
|
493
501
|
const server = Bun.serve({
|
|
494
502
|
port,
|
|
495
503
|
hostname,
|
|
496
504
|
idleTimeout: 120, // seconds — webhook triggers can take a while
|
|
505
|
+
websocket: subscribeWs.handlers,
|
|
497
506
|
async fetch(req, server) {
|
|
498
507
|
const url = new URL(req.url);
|
|
499
508
|
const path = url.pathname;
|
|
@@ -517,6 +526,21 @@ const server = Bun.serve({
|
|
|
517
526
|
return new Response(null, { status: 204, headers: corsHeaders });
|
|
518
527
|
}
|
|
519
528
|
|
|
529
|
+
// Live-query WebSocket upgrade — detected BEFORE the fetch pipeline because
|
|
530
|
+
// WS upgrades don't traverse `route()`. Non-upgrade requests (incl. the SSE
|
|
531
|
+
// fallback, which has no Upgrade header) fall straight through unchanged.
|
|
532
|
+
if (isWebSocketUpgrade(req)) {
|
|
533
|
+
const verdict = subscribeWs.tryUpgrade(req, server, path);
|
|
534
|
+
if (verdict.kind === "upgraded") return undefined; // Bun owns the socket now
|
|
535
|
+
if (verdict.kind === "response") {
|
|
536
|
+
for (const [k, v] of Object.entries(corsHeaders)) {
|
|
537
|
+
verdict.response.headers.set(k, v);
|
|
538
|
+
}
|
|
539
|
+
return verdict.response;
|
|
540
|
+
}
|
|
541
|
+
// "pass" → not a subscribe WS upgrade; fall through to the pipeline.
|
|
542
|
+
}
|
|
543
|
+
|
|
520
544
|
try {
|
|
521
545
|
const start = Date.now();
|
|
522
546
|
const response = await route(req, path);
|
package/src/services-manifest.ts
CHANGED
|
@@ -15,6 +15,14 @@ export interface ServiceEntry {
|
|
|
15
15
|
paths: string[];
|
|
16
16
|
health: string;
|
|
17
17
|
version: string;
|
|
18
|
+
/**
|
|
19
|
+
* When `true`, the hub's ws-bridge forwards `Upgrade: websocket` requests on
|
|
20
|
+
* this module's mounts to it (deny-by-default). Carried from the module's
|
|
21
|
+
* `.parachute/module.json` `websocket` field by self-registration; the hub
|
|
22
|
+
* honors either source. Optional + free-riding — extra fields survive the
|
|
23
|
+
* read/merge via the `...e` spread in `validateEntry` / `upsertService`.
|
|
24
|
+
*/
|
|
25
|
+
websocket?: boolean;
|
|
18
26
|
}
|
|
19
27
|
|
|
20
28
|
export interface ServicesManifest {
|
package/src/subscribe.ts
CHANGED
|
@@ -24,8 +24,8 @@ import { buildLiveMatcher, unsupportedSubscriptionReason } from "./live-match.ts
|
|
|
24
24
|
import {
|
|
25
25
|
snapshotFrame,
|
|
26
26
|
subscriptionManager,
|
|
27
|
+
SseSink,
|
|
27
28
|
type SubscriptionManager,
|
|
28
|
-
type SubscriptionSink,
|
|
29
29
|
} from "./subscriptions.ts";
|
|
30
30
|
|
|
31
31
|
/** Keepalive interval — `:` comment every ~25s to defeat idle-proxy timeouts. */
|
|
@@ -160,27 +160,28 @@ export async function handleSubscribe(
|
|
|
160
160
|
}
|
|
161
161
|
};
|
|
162
162
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if (cancelled) return;
|
|
172
|
-
cancelled = true;
|
|
173
|
-
if (keepalive) {
|
|
174
|
-
clearInterval(keepalive);
|
|
175
|
-
keepalive = null;
|
|
176
|
-
}
|
|
177
|
-
try {
|
|
178
|
-
controllerRef?.close();
|
|
179
|
-
} catch {
|
|
180
|
-
/* already closed */
|
|
181
|
-
}
|
|
182
|
-
},
|
|
163
|
+
// SSE sink over the stream queue. `send(event, data)` and the `:` keepalive
|
|
164
|
+
// comment both route through this same push → the emitted bytes are identical
|
|
165
|
+
// to the pre-WS-migration SSE contract (surface-client parses them unchanged).
|
|
166
|
+
const push = (frame: string): boolean => {
|
|
167
|
+
if (cancelled) return false;
|
|
168
|
+
queue.push(frame);
|
|
169
|
+
flushQueue();
|
|
170
|
+
return true;
|
|
183
171
|
};
|
|
172
|
+
const sink = new SseSink(push, () => {
|
|
173
|
+
if (cancelled) return;
|
|
174
|
+
cancelled = true;
|
|
175
|
+
if (keepalive) {
|
|
176
|
+
clearInterval(keepalive);
|
|
177
|
+
keepalive = null;
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
controllerRef?.close();
|
|
181
|
+
} catch {
|
|
182
|
+
/* already closed */
|
|
183
|
+
}
|
|
184
|
+
});
|
|
184
185
|
|
|
185
186
|
const stream = new ReadableStream<Uint8Array>({
|
|
186
187
|
start(controller) {
|
|
@@ -219,7 +220,7 @@ export async function handleSubscribe(
|
|
|
219
220
|
// 3. Keepalive comments.
|
|
220
221
|
keepalive = setInterval(() => {
|
|
221
222
|
if (cancelled) return;
|
|
222
|
-
sink.
|
|
223
|
+
sink.comment(); // `:\n\n` — byte-identical keepalive comment.
|
|
223
224
|
}, KEEPALIVE_MS);
|
|
224
225
|
},
|
|
225
226
|
pull() {
|