@openparachute/vault 0.6.5-rc.12 → 0.6.5-rc.14
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 +201 -0
- package/src/module-manifest.ts +8 -0
- package/src/routing.ts +17 -5
- package/src/self-register.test.ts +19 -0
- package/src/self-register.ts +6 -1
- package/src/server.ts +25 -0
- package/src/services-manifest.ts +8 -0
- package/src/subscriptions.ts +100 -42
- 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/src/subscribe.test.ts +0 -609
- package/src/subscribe.ts +0 -248
package/.parachute/module.json
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live-query frame PARITY + the pure WS helpers (WS-hibernation migration,
|
|
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 message folds
|
|
7
|
+
* the event name into a `type` discriminator (plus the snapshot `done` chunk
|
|
8
|
+
* flag) over an inner payload (`notes` / `note` / `id`) that serializes
|
|
9
|
+
* byte-IDENTICALLY to the corpus. Because the corpus AND the `wsFrame` formatter
|
|
10
|
+
* are byte-shaped identical to cloud's, this transitively proves: self-host WS
|
|
11
|
+
* bytes === corpus === cloud WS bytes. (SSE was the original transport; it was
|
|
12
|
+
* removed in Phase 5 — WebSocket is now the sole live transport, polling the
|
|
13
|
+
* client floor.)
|
|
14
|
+
*
|
|
15
|
+
* Plus unit coverage of the pure helpers that back the Bun.serve integration:
|
|
16
|
+
* snapshot chunking, first-message parsing, verb-rank, and the query rejects.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, it, expect } from "bun:test";
|
|
19
|
+
import { wsFrame, WsSink } from "./subscriptions.ts";
|
|
20
|
+
import {
|
|
21
|
+
buildSnapshotFrames,
|
|
22
|
+
parseClientMessage,
|
|
23
|
+
vaultVerbRank,
|
|
24
|
+
sameTagScope,
|
|
25
|
+
validateWsSubscribeQuery,
|
|
26
|
+
subscriptionCapResponse,
|
|
27
|
+
urlFromQuery,
|
|
28
|
+
SNAPSHOT_CHUNK_MAX_NOTES,
|
|
29
|
+
WS_CLOSE,
|
|
30
|
+
} from "./ws-subscribe.ts";
|
|
31
|
+
import { unsupportedSubscriptionReason } from "./live-match.ts";
|
|
32
|
+
import { FRAME_CORPUS, NOTE_A } from "./test-support/live-frame-corpus.ts";
|
|
33
|
+
|
|
34
|
+
describe("live-frame parity — self-host WS bytes === corpus === cloud WS bytes", () => {
|
|
35
|
+
for (const c of FRAME_CORPUS) {
|
|
36
|
+
it(`${c.name}: WS message folds the event into \`type\` over the corpus payload`, () => {
|
|
37
|
+
const ws = wsFrame(c.event, c.data);
|
|
38
|
+
|
|
39
|
+
// WS message is `{ type, ...data }` — the event name folds into `type`.
|
|
40
|
+
const parsedWs = JSON.parse(ws) as Record<string, unknown>;
|
|
41
|
+
expect(parsedWs.type).toBe(c.event);
|
|
42
|
+
const { type, ...rest } = parsedWs;
|
|
43
|
+
void type;
|
|
44
|
+
expect(rest).toEqual(c.data);
|
|
45
|
+
|
|
46
|
+
// BYTE parity of the inner payload: whatever value the event carries
|
|
47
|
+
// (`notes` / `note` / `id`) serializes IDENTICALLY to the corpus
|
|
48
|
+
// serialization (the cross-door invariant).
|
|
49
|
+
const innerKey = c.event === "snapshot" ? "notes" : c.event === "upsert" ? "note" : "id";
|
|
50
|
+
const innerBytes = JSON.stringify((c.data as Record<string, unknown>)[innerKey]);
|
|
51
|
+
expect(ws.includes(innerBytes)).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("sink classes render through the ONE formatter", () => {
|
|
57
|
+
it("WsSink.send emits exactly wsFrame(event, data)", () => {
|
|
58
|
+
const sent: string[] = [];
|
|
59
|
+
const sink = new WsSink({ send: (d: string) => sent.push(d), close: () => {} });
|
|
60
|
+
for (const c of FRAME_CORPUS) {
|
|
61
|
+
sink.send(c.event, c.data);
|
|
62
|
+
expect(sent.at(-1)).toBe(wsFrame(c.event, c.data));
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("buildSnapshotFrames — chunking + done flag", () => {
|
|
68
|
+
it("empty set → exactly one done:true frame", () => {
|
|
69
|
+
const frames = buildSnapshotFrames([]);
|
|
70
|
+
expect(frames.length).toBe(1);
|
|
71
|
+
expect(JSON.parse(frames[0]!)).toEqual({ type: "snapshot", notes: [], done: true });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("small set → one done:true frame carrying every note", () => {
|
|
75
|
+
const frames = buildSnapshotFrames([NOTE_A as any, NOTE_A as any]);
|
|
76
|
+
expect(frames.length).toBe(1);
|
|
77
|
+
const f = JSON.parse(frames[0]!);
|
|
78
|
+
expect(f.done).toBe(true);
|
|
79
|
+
expect(f.notes.length).toBe(2);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("> SNAPSHOT_CHUNK_MAX_NOTES → multiple frames, only the last done:true, concat = full set", () => {
|
|
83
|
+
const many = Array.from({ length: SNAPSHOT_CHUNK_MAX_NOTES + 5 }, (_, i) => ({ ...NOTE_A, id: `n-${i}` }));
|
|
84
|
+
const frames = buildSnapshotFrames(many as any);
|
|
85
|
+
expect(frames.length).toBeGreaterThan(1);
|
|
86
|
+
const parsed = frames.map((f) => JSON.parse(f));
|
|
87
|
+
expect(parsed.filter((p) => p.done).length).toBe(1);
|
|
88
|
+
expect(parsed[parsed.length - 1]!.done).toBe(true);
|
|
89
|
+
parsed.slice(0, -1).forEach((p) => expect(p.done).toBe(false));
|
|
90
|
+
const concat = parsed.flatMap((p) => p.notes);
|
|
91
|
+
expect(concat.map((n: any) => n.id)).toEqual(many.map((n) => n.id));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("each frame stays under the WS message ceiling with large notes", () => {
|
|
95
|
+
const big = Array.from({ length: 8 }, (_, i) => ({ ...NOTE_A, id: `big-${i}`, content: "x".repeat(200_000) }));
|
|
96
|
+
const frames = buildSnapshotFrames(big as any);
|
|
97
|
+
expect(frames.length).toBeGreaterThan(1);
|
|
98
|
+
for (const f of frames) expect(new TextEncoder().encode(f).byteLength).toBeLessThan(1_000_000);
|
|
99
|
+
expect(frames.flatMap((f) => JSON.parse(f).notes).length).toBe(8);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("parseClientMessage", () => {
|
|
104
|
+
it("valid auth", () => {
|
|
105
|
+
expect(parseClientMessage(JSON.stringify({ type: "auth", token: "t" }))).toEqual({ kind: "auth", token: "t" });
|
|
106
|
+
});
|
|
107
|
+
it("auth without a token → malformed", () => {
|
|
108
|
+
expect(parseClientMessage(JSON.stringify({ type: "auth" })).kind).toBe("malformed");
|
|
109
|
+
expect(parseClientMessage(JSON.stringify({ type: "auth", token: "" })).kind).toBe("malformed");
|
|
110
|
+
});
|
|
111
|
+
it("non-json / non-object → malformed", () => {
|
|
112
|
+
expect(parseClientMessage("not json").kind).toBe("malformed");
|
|
113
|
+
expect(parseClientMessage(JSON.stringify([1, 2])).kind).toBe("malformed");
|
|
114
|
+
});
|
|
115
|
+
it("other typed message → other", () => {
|
|
116
|
+
expect(parseClientMessage(JSON.stringify({ type: "hello" }))).toEqual({ kind: "other", type: "hello" });
|
|
117
|
+
});
|
|
118
|
+
it("decodes a Uint8Array frame", () => {
|
|
119
|
+
const bytes = new TextEncoder().encode(JSON.stringify({ type: "auth", token: "b" }));
|
|
120
|
+
expect(parseClientMessage(bytes)).toEqual({ kind: "auth", token: "b" });
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("vaultVerbRank — narrow-or-equal ordering", () => {
|
|
125
|
+
it("ranks read < write < admin, -1 for none/other-vault", () => {
|
|
126
|
+
expect(vaultVerbRank(["vault:v:read"], "v")).toBe(0);
|
|
127
|
+
expect(vaultVerbRank(["vault:v:write"], "v")).toBe(1);
|
|
128
|
+
expect(vaultVerbRank(["vault:v:admin"], "v")).toBe(2);
|
|
129
|
+
expect(vaultVerbRank(["vault:other:read"], "v")).toBe(-1);
|
|
130
|
+
expect(vaultVerbRank([], "v")).toBe(-1);
|
|
131
|
+
});
|
|
132
|
+
it("a widen is strictly greater (the 4403 trigger)", () => {
|
|
133
|
+
expect(vaultVerbRank(["vault:v:admin"], "v") > vaultVerbRank(["vault:v:read"], "v")).toBe(true);
|
|
134
|
+
expect(vaultVerbRank(["vault:v:read"], "v") > vaultVerbRank(["vault:v:admin"], "v")).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("sameTagScope — the WS re-auth tag-scope-delta gate", () => {
|
|
139
|
+
it("both unscoped (null) → same", () => {
|
|
140
|
+
expect(sameTagScope(null, null)).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
it("null vs scoped → differ (a drop/gain of scoping)", () => {
|
|
143
|
+
expect(sameTagScope(null, ["work/eng"])).toBe(false);
|
|
144
|
+
expect(sameTagScope(["work/eng"], null)).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
it("same set (order/dupe-insensitive) → same", () => {
|
|
147
|
+
expect(sameTagScope(["a", "b"], ["b", "a"])).toBe(true);
|
|
148
|
+
expect(sameTagScope(["a", "a", "b"], ["b", "a"])).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
it("different membership → differ", () => {
|
|
151
|
+
expect(sameTagScope(["work/eng"], ["work/sales"])).toBe(false);
|
|
152
|
+
expect(sameTagScope(["a"], ["a", "b"])).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("validateWsSubscribeQuery — same rejects as the SSE route (byte-identical bodies)", () => {
|
|
157
|
+
const reject = async (q: string) => {
|
|
158
|
+
const v = validateWsSubscribeQuery(new URL(`http://x/vault/v/api/subscribe?${q}`));
|
|
159
|
+
expect("error" in v).toBe(true);
|
|
160
|
+
const body = await (v as { error: Response }).error.json();
|
|
161
|
+
expect((v as { error: Response }).error.status).toBe(400);
|
|
162
|
+
expect(body.code).toBe("UNSUPPORTED_SUBSCRIPTION_QUERY");
|
|
163
|
+
};
|
|
164
|
+
it("rejects the URL-reachable unsupported shapes: search / near / cursor / has_links", async () => {
|
|
165
|
+
await reject("search=hi");
|
|
166
|
+
await reject("near[note_id]=abc");
|
|
167
|
+
await reject("cursor=xyz");
|
|
168
|
+
await reject("has_links=true");
|
|
169
|
+
});
|
|
170
|
+
it("accepts a supported query", () => {
|
|
171
|
+
const v = validateWsSubscribeQuery(new URL("http://x/vault/v/api/subscribe?tag=chat&path_prefix=meetings/"));
|
|
172
|
+
expect("queryOpts" in v).toBe(true);
|
|
173
|
+
});
|
|
174
|
+
it("shares the SSE route's queryOpts-level guard (cursor/has_links/date filters)", () => {
|
|
175
|
+
// The belt-and-suspenders layer both doors + the SSE route route through:
|
|
176
|
+
// a date filter isn't expressible as a flat URL param (removed 0.6.4), but
|
|
177
|
+
// if one ever reaches queryOpts it is rejected identically.
|
|
178
|
+
expect(unsupportedSubscriptionReason({ cursor: "x" } as any)).toBeTruthy();
|
|
179
|
+
expect(unsupportedSubscriptionReason({ hasLinks: true } as any)).toBeTruthy();
|
|
180
|
+
expect(unsupportedSubscriptionReason({ dateFrom: "2026-01-01" } as any)).toBeTruthy();
|
|
181
|
+
expect(unsupportedSubscriptionReason({ dateFilter: { field: "created_at" } } as any)).toBeTruthy();
|
|
182
|
+
expect(unsupportedSubscriptionReason({ tags: ["chat"] } as any)).toBeNull();
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("misc pure helpers", () => {
|
|
187
|
+
it("subscriptionCapResponse → 503 SUBSCRIPTION_CAP_REACHED", async () => {
|
|
188
|
+
const res = subscriptionCapResponse();
|
|
189
|
+
expect(res.status).toBe(503);
|
|
190
|
+
expect((await res.json()).code).toBe("SUBSCRIPTION_CAP_REACHED");
|
|
191
|
+
});
|
|
192
|
+
it("urlFromQuery reconstructs searchParams from a raw query string", () => {
|
|
193
|
+
expect(urlFromQuery("?tag=chat&a=b").searchParams.get("tag")).toBe("chat");
|
|
194
|
+
expect(urlFromQuery("").search).toBe("");
|
|
195
|
+
});
|
|
196
|
+
it("WS_CLOSE carries the four application close codes", () => {
|
|
197
|
+
expect([WS_CLOSE.PROTOCOL, WS_CLOSE.UNAUTHORIZED, WS_CLOSE.FORBIDDEN, WS_CLOSE.AUTH_TIMEOUT]).toEqual([
|
|
198
|
+
4400, 4401, 4403, 4408,
|
|
199
|
+
]);
|
|
200
|
+
});
|
|
201
|
+
});
|
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
|
/**
|
package/src/routing.ts
CHANGED
|
@@ -73,7 +73,6 @@ import {
|
|
|
73
73
|
type TagScopeCtx,
|
|
74
74
|
type WriteCtx,
|
|
75
75
|
} from "./routes.ts";
|
|
76
|
-
import { handleSubscribe } from "./subscribe.ts";
|
|
77
76
|
import { handleTriggers } from "./triggers-api.ts";
|
|
78
77
|
import { expandTokenTagScope } from "./tag-scope.ts";
|
|
79
78
|
import {
|
|
@@ -883,10 +882,23 @@ export async function route(
|
|
|
883
882
|
};
|
|
884
883
|
|
|
885
884
|
if (apiPath.startsWith("/notes")) return handleNotes(req, store, apiPath.slice(6), vaultName, tagScope, writeCtx);
|
|
886
|
-
// Live-query SSE
|
|
887
|
-
//
|
|
888
|
-
//
|
|
889
|
-
|
|
885
|
+
// Live-query SSE transport REMOVED (WS-hibernation migration Phase 5). The
|
|
886
|
+
// WebSocket binding (an `Upgrade: websocket` GET /subscribe, handled in
|
|
887
|
+
// server.ts before this pipeline) is now the SOLE live transport; polling
|
|
888
|
+
// GET /notes is the client floor beneath it. A non-WS GET /subscribe — a
|
|
889
|
+
// straggler on a cached pre-WS notes-ui bundle — gets a clean 410 Gone
|
|
890
|
+
// pointing at the WS binding so its consumer degrades to polling gracefully;
|
|
891
|
+
// NEVER a 500 or an unhandled path.
|
|
892
|
+
if (apiPath === "/subscribe") {
|
|
893
|
+
return Response.json(
|
|
894
|
+
{
|
|
895
|
+
error:
|
|
896
|
+
"The live-query SSE transport has been removed. Reconnect with an `Upgrade: websocket` request to GET /api/subscribe, or poll GET /notes.",
|
|
897
|
+
code: "SSE_TRANSPORT_REMOVED",
|
|
898
|
+
},
|
|
899
|
+
{ status: 410 },
|
|
900
|
+
);
|
|
901
|
+
}
|
|
890
902
|
if (apiPath.startsWith("/tags")) return handleTags(req, store, apiPath.slice(5), tagScope);
|
|
891
903
|
if (apiPath === "/find-path") return handleFindPath(req, store, tagScope);
|
|
892
904
|
if (apiPath === "/vault") {
|
|
@@ -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,22 @@ 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. a
|
|
531
|
+
// straggler non-WS GET /subscribe, which now gets a 410 Gone in routing.ts —
|
|
532
|
+
// the SSE transport was removed in Phase 5) fall straight through unchanged.
|
|
533
|
+
if (isWebSocketUpgrade(req)) {
|
|
534
|
+
const verdict = subscribeWs.tryUpgrade(req, server, path);
|
|
535
|
+
if (verdict.kind === "upgraded") return undefined; // Bun owns the socket now
|
|
536
|
+
if (verdict.kind === "response") {
|
|
537
|
+
for (const [k, v] of Object.entries(corsHeaders)) {
|
|
538
|
+
verdict.response.headers.set(k, v);
|
|
539
|
+
}
|
|
540
|
+
return verdict.response;
|
|
541
|
+
}
|
|
542
|
+
// "pass" → not a subscribe WS upgrade; fall through to the pipeline.
|
|
543
|
+
}
|
|
544
|
+
|
|
520
545
|
try {
|
|
521
546
|
const start = Date.now();
|
|
522
547
|
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/subscriptions.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Live-query subscription registry
|
|
3
|
-
*
|
|
2
|
+
* Live-query subscription registry — fans out post-commit note events to live
|
|
3
|
+
* WebSocket subscriptions (originally an SSE fan-out — design
|
|
4
|
+
* `design/2026-06-08-live-query-sse.md`; the SSE transport was removed in
|
|
5
|
+
* Phase 5 of the WS-hibernation migration).
|
|
4
6
|
*
|
|
5
7
|
* A second consumer of the post-commit hook dispatcher (`core/src/hooks.ts`),
|
|
6
8
|
* alongside the durable webhook-trigger sink. Where a trigger survives across
|
|
@@ -8,6 +10,24 @@
|
|
|
8
10
|
* connection-scoped, driving a live UI. Same event source, same predicate,
|
|
9
11
|
* different durability.
|
|
10
12
|
*
|
|
13
|
+
* ## The live transport: WebSocket (WS-hibernation migration)
|
|
14
|
+
*
|
|
15
|
+
* The manager is transport-agnostic: it emits abstract `(event, data)` tuples
|
|
16
|
+
* and a {@link SubscriptionSink} renders them for its wire.
|
|
17
|
+
* - {@link WsSink} renders a WebSocket message `{ type: event, ...data }`. The
|
|
18
|
+
* inner payload (`note` / `id` / `notes`) is pinned byte-for-byte against the
|
|
19
|
+
* shared frame-corpus fixture and is congruent with the cloud door
|
|
20
|
+
* (`parachute-cloud/workers/vault/src/live/subscriptions.ts`). See
|
|
21
|
+
* `parachute-cloud/workers/vault/docs/live-query-ws.md`.
|
|
22
|
+
*
|
|
23
|
+
* WebSocket is the SOLE live transport as of Phase 5 — the earlier SSE binding
|
|
24
|
+
* was removed once cached notes-ui bundles converged, and polling (client-side)
|
|
25
|
+
* is the floor beneath live. All subscriptions share ONE process-wide
|
|
26
|
+
* {@link SubscriptionManager}. Self-host has no hibernation (a self-run Bun box
|
|
27
|
+
* has no per-connection duration bill) — the WS binding gives bidirectionality +
|
|
28
|
+
* contract-congruence with cloud, nothing more. The Bun.serve WS integration
|
|
29
|
+
* lives in `ws-server.ts` + `ws-subscribe.ts`.
|
|
30
|
+
*
|
|
11
31
|
* ## How fan-out works
|
|
12
32
|
*
|
|
13
33
|
* All vault stores share the process-wide `defaultHookRegistry`
|
|
@@ -54,16 +74,19 @@ export const DEFAULT_MAX_SUBSCRIPTIONS_PER_VAULT = 100;
|
|
|
54
74
|
* Default bound on a single subscription's pending (unflushed) event buffer.
|
|
55
75
|
* If a slow client lets the buffer grow past this, the stream is closed — it
|
|
56
76
|
* reconnects and re-snapshots rather than the server growing memory unbounded.
|
|
77
|
+
* Only enforced for flush-tracking sinks (SSE); the WS transport delegates
|
|
78
|
+
* outgoing-buffer bounds to the Bun runtime.
|
|
57
79
|
*/
|
|
58
80
|
export const DEFAULT_MAX_BUFFERED_EVENTS = 1000;
|
|
59
81
|
|
|
60
|
-
/**
|
|
61
|
-
|
|
62
|
-
|
|
82
|
+
/**
|
|
83
|
+
* The abstract sink an event is rendered to. The manager calls `send(event,
|
|
84
|
+
* data)`; each transport formats for its wire. Returns `false` when the sink is
|
|
85
|
+
* gone (the manager then drops the subscription).
|
|
86
|
+
*/
|
|
63
87
|
export interface SubscriptionSink {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
/** Close the underlying stream (teardown). Idempotent. */
|
|
88
|
+
send(event: string, data: unknown): boolean;
|
|
89
|
+
/** Close the underlying stream/socket (teardown). Idempotent. */
|
|
67
90
|
close(): void;
|
|
68
91
|
}
|
|
69
92
|
|
|
@@ -75,14 +98,56 @@ interface Subscription {
|
|
|
75
98
|
/** Raw root-tag allowlist (null = unscoped) — `noteWithinTagScope` arg. */
|
|
76
99
|
readonly tagScopeRaw: string[] | null;
|
|
77
100
|
readonly sink: SubscriptionSink;
|
|
78
|
-
/**
|
|
101
|
+
/** SSE tracks unflushed frames to bound memory; WS delegates to the runtime. */
|
|
102
|
+
readonly tracksFlush: boolean;
|
|
103
|
+
/** Whether this sub counts against the per-vault manager cap (SSE) — the WS
|
|
104
|
+
* transport enforces its own cap via the live-socket count at upgrade. */
|
|
105
|
+
readonly countsTowardCap: boolean;
|
|
106
|
+
/** Pending unflushed frame count (backpressure bound; SSE only). */
|
|
79
107
|
buffered: number;
|
|
80
108
|
readonly maxBuffered: number;
|
|
81
109
|
closed: boolean;
|
|
82
110
|
}
|
|
83
111
|
|
|
84
|
-
|
|
85
|
-
|
|
112
|
+
/** WebSocket message: `{ type: <event>, ...data }` — the event name becomes the
|
|
113
|
+
* `type` discriminator and the inner payload (`note` / `id` / `notes`)
|
|
114
|
+
* serializes as-is. The ONE place WS bytes are formatted. */
|
|
115
|
+
export function wsFrame(event: string, data: unknown): string {
|
|
116
|
+
const body = data && typeof data === "object" && !Array.isArray(data) ? (data as Record<string, unknown>) : {};
|
|
117
|
+
return JSON.stringify({ type: event, ...body });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Minimal structural shape of a Bun `ServerWebSocket` (or a test double) that
|
|
121
|
+
* {@link WsSink} needs — keeps this module free of a hard Bun-server import. */
|
|
122
|
+
export interface WsLike {
|
|
123
|
+
send(data: string): unknown;
|
|
124
|
+
close(code?: number, reason?: string): unknown;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** WebSocket sink: renders `(event, data)` to a `{ type, ...data }` message and
|
|
128
|
+
* sends it on the socket. `sendRaw` is for pre-formatted frames (the chunked
|
|
129
|
+
* snapshot the WS binding builds directly). A send on a dead socket returns
|
|
130
|
+
* false → the manager drops the sub. */
|
|
131
|
+
export class WsSink implements SubscriptionSink {
|
|
132
|
+
constructor(private readonly ws: WsLike) {}
|
|
133
|
+
send(event: string, data: unknown): boolean {
|
|
134
|
+
return this.sendRaw(wsFrame(event, data));
|
|
135
|
+
}
|
|
136
|
+
sendRaw(frame: string): boolean {
|
|
137
|
+
try {
|
|
138
|
+
this.ws.send(frame);
|
|
139
|
+
return true;
|
|
140
|
+
} catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
close(): void {
|
|
145
|
+
try {
|
|
146
|
+
this.ws.close(1011, "subscription closed");
|
|
147
|
+
} catch {
|
|
148
|
+
/* already closing/closed */
|
|
149
|
+
}
|
|
150
|
+
}
|
|
86
151
|
}
|
|
87
152
|
|
|
88
153
|
export class SubscriptionManager {
|
|
@@ -140,9 +205,15 @@ export class SubscriptionManager {
|
|
|
140
205
|
tagScopeRaw: string[] | null;
|
|
141
206
|
sink: SubscriptionSink;
|
|
142
207
|
maxBuffered?: number;
|
|
208
|
+
/** SSE (default true) tracks unflushed frames; WS passes false. */
|
|
209
|
+
tracksFlush?: boolean;
|
|
210
|
+
/** SSE (default true) counts against the per-vault manager cap; WS passes
|
|
211
|
+
* false — the WS transport caps via the live-socket count at upgrade. */
|
|
212
|
+
countsTowardCap?: boolean;
|
|
143
213
|
}): SubscriptionHandle | null {
|
|
214
|
+
const countsTowardCap = args.countsTowardCap ?? true;
|
|
144
215
|
const current = this.countForVault(args.vaultName);
|
|
145
|
-
if (current >= this.maxPerVault) return null;
|
|
216
|
+
if (countsTowardCap && current >= this.maxPerVault) return null;
|
|
146
217
|
|
|
147
218
|
this.ensureHooks();
|
|
148
219
|
|
|
@@ -152,12 +223,14 @@ export class SubscriptionManager {
|
|
|
152
223
|
tagScopeAllowed: args.tagScopeAllowed,
|
|
153
224
|
tagScopeRaw: args.tagScopeRaw,
|
|
154
225
|
sink: args.sink,
|
|
226
|
+
tracksFlush: args.tracksFlush ?? true,
|
|
227
|
+
countsTowardCap,
|
|
155
228
|
buffered: 0,
|
|
156
229
|
maxBuffered: args.maxBuffered ?? DEFAULT_MAX_BUFFERED_EVENTS,
|
|
157
230
|
closed: false,
|
|
158
231
|
};
|
|
159
232
|
this.subs.add(sub);
|
|
160
|
-
this.perVaultCount.set(args.vaultName, current + 1);
|
|
233
|
+
if (countsTowardCap) this.perVaultCount.set(args.vaultName, current + 1);
|
|
161
234
|
|
|
162
235
|
return {
|
|
163
236
|
/** Note that a previously-buffered frame flushed to the wire. */
|
|
@@ -172,9 +245,11 @@ export class SubscriptionManager {
|
|
|
172
245
|
if (sub.closed) return;
|
|
173
246
|
sub.closed = true;
|
|
174
247
|
this.subs.delete(sub);
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
248
|
+
if (sub.countsTowardCap) {
|
|
249
|
+
const n = this.perVaultCount.get(sub.vaultName) ?? 0;
|
|
250
|
+
if (n <= 1) this.perVaultCount.delete(sub.vaultName);
|
|
251
|
+
else this.perVaultCount.set(sub.vaultName, n - 1);
|
|
252
|
+
}
|
|
178
253
|
try {
|
|
179
254
|
sub.sink.close();
|
|
180
255
|
} catch {
|
|
@@ -182,33 +257,21 @@ export class SubscriptionManager {
|
|
|
182
257
|
}
|
|
183
258
|
}
|
|
184
259
|
|
|
185
|
-
/**
|
|
186
|
-
*
|
|
187
|
-
|
|
188
|
-
* so it never counts against the buffer bound.
|
|
189
|
-
*/
|
|
190
|
-
keepaliveAll(): void {
|
|
191
|
-
for (const sub of this.subs) {
|
|
192
|
-
if (sub.closed) continue;
|
|
193
|
-
const ok = sub.sink.write(":\n\n");
|
|
194
|
-
if (!ok) this.remove(sub);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/** Emit a frame to one subscription, enforcing the buffer bound. */
|
|
199
|
-
private emit(sub: Subscription, frame: SseFrame): void {
|
|
260
|
+
/** Emit an `(event, data)` tuple to one subscription, enforcing the buffer
|
|
261
|
+
* bound for flush-tracking (SSE) sinks. */
|
|
262
|
+
private emit(sub: Subscription, event: string, data: unknown): void {
|
|
200
263
|
if (sub.closed) return;
|
|
201
|
-
if (sub.buffered >= sub.maxBuffered) {
|
|
264
|
+
if (sub.tracksFlush && sub.buffered >= sub.maxBuffered) {
|
|
202
265
|
// Backpressure: client can't keep up. Close → it reconnects + re-snapshots.
|
|
203
266
|
this.remove(sub);
|
|
204
267
|
return;
|
|
205
268
|
}
|
|
206
|
-
const ok = sub.sink.
|
|
269
|
+
const ok = sub.sink.send(event, data);
|
|
207
270
|
if (!ok) {
|
|
208
271
|
this.remove(sub);
|
|
209
272
|
return;
|
|
210
273
|
}
|
|
211
|
-
sub.buffered++;
|
|
274
|
+
if (sub.tracksFlush) sub.buffered++;
|
|
212
275
|
}
|
|
213
276
|
|
|
214
277
|
/** Lazily register the three broad hooks (once per manager). */
|
|
@@ -244,7 +307,7 @@ export class SubscriptionManager {
|
|
|
244
307
|
// the client ignores ids it never held. Documented low-sensitivity
|
|
245
308
|
// existence leak (see design §scope-intersection).
|
|
246
309
|
const ref = payload as DeletedNoteRef;
|
|
247
|
-
this.emit(sub,
|
|
310
|
+
this.emit(sub, "remove", { id: ref.id });
|
|
248
311
|
continue;
|
|
249
312
|
}
|
|
250
313
|
|
|
@@ -257,14 +320,14 @@ export class SubscriptionManager {
|
|
|
257
320
|
const matches = sub.matcher.match(note) && inScope;
|
|
258
321
|
|
|
259
322
|
if (matches) {
|
|
260
|
-
this.emit(sub,
|
|
323
|
+
this.emit(sub, "upsert", { note });
|
|
261
324
|
} else if (event === "updated" && inScope) {
|
|
262
325
|
// Left the set (predicate no longer true) BUT still within this
|
|
263
326
|
// token's scope, so the sub could have held it — idempotent remove
|
|
264
327
|
// (client drops the id if held, no-op otherwise). When the note is
|
|
265
328
|
// OUT of scope the sub never had it; emitting would leak its id, so
|
|
266
329
|
// we stay silent.
|
|
267
|
-
this.emit(sub,
|
|
330
|
+
this.emit(sub, "remove", { id: note.id });
|
|
268
331
|
}
|
|
269
332
|
// created that doesn't match → nothing (it was never in the set).
|
|
270
333
|
}
|
|
@@ -286,10 +349,5 @@ export interface SubscriptionHandle {
|
|
|
286
349
|
close: () => void;
|
|
287
350
|
}
|
|
288
351
|
|
|
289
|
-
/** Serialize a snapshot frame (exported for the route + tests). */
|
|
290
|
-
export function snapshotFrame(notes: Note[]): SseFrame {
|
|
291
|
-
return sseEvent("snapshot", { notes });
|
|
292
|
-
}
|
|
293
|
-
|
|
294
352
|
/** Process-wide manager — shared like `defaultHookRegistry`. */
|
|
295
353
|
export const subscriptionManager = new SubscriptionManager();
|