@autono/pinbox-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -0
- package/dist/auth/verify.d.ts +15 -0
- package/dist/auth/verify.js +2 -0
- package/dist/connectors/github.d.ts +5 -0
- package/dist/connectors/github.js +49 -0
- package/dist/connectors/index.d.ts +37 -0
- package/dist/connectors/index.js +103 -0
- package/dist/context-BHcEpzVb.js +30 -0
- package/dist/delivery/openclaw.d.ts +10 -0
- package/dist/delivery/openclaw.js +50 -0
- package/dist/delivery/resume.d.ts +23 -0
- package/dist/delivery/resume.js +102 -0
- package/dist/delivery/router.d.ts +2 -0
- package/dist/delivery/router.js +262 -0
- package/dist/delivery/webhook.d.ts +9 -0
- package/dist/delivery/webhook.js +52 -0
- package/dist/do.d.ts +67 -0
- package/dist/do.js +713 -0
- package/dist/hub-QYz6OqYQ.js +328 -0
- package/dist/hub-server.d.ts +44 -0
- package/dist/hub-server.js +251 -0
- package/dist/hub.d.ts +125 -0
- package/dist/hub.js +2 -0
- package/dist/markdown.d.ts +14 -0
- package/dist/markdown.js +87 -0
- package/dist/payload-m1DbRWDD.d.ts +6 -0
- package/dist/poll-BrcAuaAz.js +252 -0
- package/dist/proc-BMp_pbPS.js +42 -0
- package/dist/router-D3dDjIaD.d.ts +62 -0
- package/dist/schema-BOTmn5SM.d.ts +297 -0
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +121 -0
- package/dist/schema.json +403 -0
- package/dist/sessions-CJdMBH3C.d.ts +47 -0
- package/dist/sessions-DrCVTMfI.js +129 -0
- package/dist/sessions.d.ts +2 -0
- package/dist/sessions.js +2 -0
- package/dist/store-DHbwWu93.d.ts +105 -0
- package/dist/store-DM8MjB8M.js +479 -0
- package/dist/store.d.ts +2 -0
- package/dist/store.js +3 -0
- package/dist/types-BLNQb7MH.d.ts +28 -0
- package/dist/verify-BDt9d9Np.js +55 -0
- package/dist/ws-protocol.d.ts +61 -0
- package/dist/ws-protocol.js +51 -0
- package/dist/ws.d.ts +7 -0
- package/dist/ws.js +1 -0
- package/package.json +104 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { AttachmentSchema, PinInputSchema, SessionRefSchema } from "./schema.js";
|
|
2
|
+
import { a as NotFoundError, i as ConflictError, o as newId } from "./sessions-DrCVTMfI.js";
|
|
3
|
+
import { t as POLL_OPEN_MS } from "./poll-BrcAuaAz.js";
|
|
4
|
+
import { t as buildInjectionContext } from "./context-BHcEpzVb.js";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
//#region src/routes-links.ts
|
|
7
|
+
const LinkPostSchema = z.object({ connector: z.string() });
|
|
8
|
+
const routeLinks = async (req, url, opts) => {
|
|
9
|
+
const pinId = match(url.pathname, /^\/pins\/([a-z0-9_]+)\/links$/);
|
|
10
|
+
if (pinId === null) return null;
|
|
11
|
+
const { store } = opts;
|
|
12
|
+
if (req.method === "GET") {
|
|
13
|
+
mustGetPin(store, pinId);
|
|
14
|
+
return ok(200, store.links.forPin(pinId));
|
|
15
|
+
}
|
|
16
|
+
if (req.method !== "POST") return null;
|
|
17
|
+
const pin = mustGetPin(store, pinId);
|
|
18
|
+
const body = LinkPostSchema.parse(await readJson(req));
|
|
19
|
+
const connector = opts.connectors?.find((c) => c.name === body.connector);
|
|
20
|
+
if (connector === void 0) return err(502, "E_CONNECTOR", `no connector available: ${body.connector}`, { hint: "run `pinbox doctor` to see which connectors this hub can reach" });
|
|
21
|
+
let link;
|
|
22
|
+
try {
|
|
23
|
+
link = await connector.createItem(pin, store.getThread(pinId));
|
|
24
|
+
} catch (cause) {
|
|
25
|
+
const message = cause instanceof Error ? cause.message : "connector request failed";
|
|
26
|
+
const hint = cause instanceof Error && "hint" in cause && typeof cause.hint === "string" ? cause.hint : void 0;
|
|
27
|
+
return err(502, "E_CONNECTOR", message, hint === void 0 ? void 0 : { hint });
|
|
28
|
+
}
|
|
29
|
+
const updated = store.addLink(pinId, link);
|
|
30
|
+
store.setDueAt(pinId, new Date(Date.now() + POLL_OPEN_MS).toISOString());
|
|
31
|
+
return ok(201, updated);
|
|
32
|
+
};
|
|
33
|
+
//#endregion
|
|
34
|
+
//#region src/routes-sessions.ts
|
|
35
|
+
async function routeSessions(req, url, opts) {
|
|
36
|
+
const { store } = opts;
|
|
37
|
+
const { sessions } = store;
|
|
38
|
+
if (url.pathname === "/sessions") {
|
|
39
|
+
if (req.method === "POST") {
|
|
40
|
+
const ref = SessionRefSchema.parse(await readJson(req));
|
|
41
|
+
return ok(sessions.findByRef(ref) === null ? 201 : 200, sessions.register(ref));
|
|
42
|
+
}
|
|
43
|
+
if (req.method === "GET") return ok(200, sessions.list());
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const injectId = match(url.pathname, /^\/sessions\/([a-z0-9_]+)\/inject$/);
|
|
47
|
+
if (injectId !== null && req.method === "POST") return inject(store, injectId);
|
|
48
|
+
const pendingId = match(url.pathname, /^\/sessions\/([a-z0-9_]+)\/pending$/);
|
|
49
|
+
if (pendingId !== null && req.method === "GET") return pending(store, pendingId);
|
|
50
|
+
const sessionId = match(url.pathname, /^\/sessions\/([a-z0-9_]+)$/);
|
|
51
|
+
if (sessionId !== null && req.method === "DELETE") {
|
|
52
|
+
sessions.end(sessionId);
|
|
53
|
+
return ok(200, mustGetSession(sessions, sessionId));
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The hook pull: touch the session, flip its pending delivery rows to delivered, and
|
|
59
|
+
* ALSO claim unassigned pending rows (assign → deliver — rule 1's "the next session to
|
|
60
|
+
* register receives all unassigned pins", satisfied at first pull). The context always
|
|
61
|
+
* carries ALL open pins: re-injection every turn is the delivery model that made
|
|
62
|
+
* attachments path-only.
|
|
63
|
+
*/
|
|
64
|
+
function inject(store, id) {
|
|
65
|
+
const session = mustGetSession(store.sessions, id);
|
|
66
|
+
const { deliveries } = store;
|
|
67
|
+
store.sessions.touch(session.id);
|
|
68
|
+
let delivered = 0;
|
|
69
|
+
for (const row of deliveries.pendingForSession(session.id)) {
|
|
70
|
+
deliveries.markDelivered(row.id);
|
|
71
|
+
delivered += 1;
|
|
72
|
+
}
|
|
73
|
+
for (const row of deliveries.unassigned()) {
|
|
74
|
+
deliveries.assign(row.id, session.id);
|
|
75
|
+
deliveries.markDelivered(row.id);
|
|
76
|
+
delivered += 1;
|
|
77
|
+
}
|
|
78
|
+
const pins = store.listPins({ status: "open" });
|
|
79
|
+
return ok(200, {
|
|
80
|
+
context: buildInjectionContext(pins),
|
|
81
|
+
pins,
|
|
82
|
+
delivered
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Read-only Stop-hook gate: the OPEN pins referenced by this session's pending
|
|
87
|
+
* delivery rows. Rows whose pin has since been resolved do not hold the agent —
|
|
88
|
+
* `count` is the count of actionable pins, not of ledger rows.
|
|
89
|
+
*/
|
|
90
|
+
function pending(store, id) {
|
|
91
|
+
const session = mustGetSession(store.sessions, id);
|
|
92
|
+
const pins = openPinsFor(store, store.deliveries.pendingForSession(session.id));
|
|
93
|
+
return ok(200, {
|
|
94
|
+
count: pins.length,
|
|
95
|
+
pins
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
function openPinsFor(store, rows) {
|
|
99
|
+
const seen = /* @__PURE__ */ new Set();
|
|
100
|
+
const pins = [];
|
|
101
|
+
for (const row of rows) {
|
|
102
|
+
const event = store.eventsAfter(row.eventSeq - 1)[0];
|
|
103
|
+
if (event === void 0 || event.seq !== row.eventSeq) continue;
|
|
104
|
+
const pinId = pinIdOf(event.payload);
|
|
105
|
+
if (pinId === null || seen.has(pinId)) continue;
|
|
106
|
+
seen.add(pinId);
|
|
107
|
+
const pin = store.getPin(pinId);
|
|
108
|
+
if (pin?.status === "open") pins.push(pin);
|
|
109
|
+
}
|
|
110
|
+
return pins;
|
|
111
|
+
}
|
|
112
|
+
/** pin.created payloads are the Pin (`id`); thread.message payloads carry `pinId`. */
|
|
113
|
+
function pinIdOf(payload) {
|
|
114
|
+
if (typeof payload !== "object" || payload === null) return null;
|
|
115
|
+
const record = payload;
|
|
116
|
+
const id = "pinId" in record ? record["pinId"] : record["id"];
|
|
117
|
+
return typeof id === "string" && id.startsWith("pin_") ? id : null;
|
|
118
|
+
}
|
|
119
|
+
function mustGetSession(sessions, id) {
|
|
120
|
+
const session = sessions.get(id);
|
|
121
|
+
if (!session) throw new NotFoundError(`session not found: ${id}`);
|
|
122
|
+
return session;
|
|
123
|
+
}
|
|
124
|
+
//#endregion
|
|
125
|
+
//#region src/attachments.ts
|
|
126
|
+
const MAX_ATTACHMENT_BYTES = 5242880;
|
|
127
|
+
const sinks = /* @__PURE__ */ new WeakMap();
|
|
128
|
+
function registerAttachmentSink(store, sink) {
|
|
129
|
+
sinks.set(store, sink);
|
|
130
|
+
}
|
|
131
|
+
function attachmentSinkFor(store) {
|
|
132
|
+
return sinks.get(store);
|
|
133
|
+
}
|
|
134
|
+
//#endregion
|
|
135
|
+
//#region src/routes-toolbar.ts
|
|
136
|
+
const VerifyPostSchema = z.object({ outcome: z.enum(["accepted", "reopened"]) });
|
|
137
|
+
const AttachmentKindSchema = z.enum(["screenshot", "file"]);
|
|
138
|
+
const routeToolbar = async (req, url, opts) => {
|
|
139
|
+
const verifyPinId = match(url.pathname, /^\/pins\/([a-z0-9_]+)\/verify$/);
|
|
140
|
+
if (verifyPinId !== null && req.method === "POST") {
|
|
141
|
+
const body = VerifyPostSchema.parse(await readJson(req));
|
|
142
|
+
return ok(200, opts.store.verifyPin(verifyPinId, body.outcome));
|
|
143
|
+
}
|
|
144
|
+
if (url.pathname === "/attachments" && req.method === "POST") return postAttachment(req, url, opts);
|
|
145
|
+
return null;
|
|
146
|
+
};
|
|
147
|
+
async function postAttachment(req, url, opts) {
|
|
148
|
+
const kind = AttachmentKindSchema.parse(url.searchParams.get("kind"));
|
|
149
|
+
const contentType = req.headers.get("content-type");
|
|
150
|
+
if (contentType === null || contentType === "") return err(400, "E_INVALID_INPUT", "content-type header is required", { hint: "send the attachment's MIME type, e.g. content-type: image/webp" });
|
|
151
|
+
const tooLarge = () => err(413, "E_ATTACHMENT", `attachment exceeds ${MAX_ATTACHMENT_BYTES} bytes`, { hint: "attachments are capped at 5 MB; downscale or compress before uploading" });
|
|
152
|
+
const declared = Number(req.headers.get("content-length"));
|
|
153
|
+
if (Number.isFinite(declared) && declared > 5242880) return tooLarge();
|
|
154
|
+
const bytes = new Uint8Array(await req.arrayBuffer());
|
|
155
|
+
if (bytes.byteLength > 5242880) return tooLarge();
|
|
156
|
+
const sink = attachmentSinkFor(opts.store);
|
|
157
|
+
if (sink === void 0) return err(500, "E_INTERNAL", "no attachment sink registered for this store", { hint: "the host must call registerAttachmentSink(store, sink) before serving /attachments" });
|
|
158
|
+
const { attachment, uploadUrl } = await sink.write({
|
|
159
|
+
id: newId("att"),
|
|
160
|
+
kind,
|
|
161
|
+
contentType
|
|
162
|
+
}, bytes);
|
|
163
|
+
return ok(201, uploadUrl === void 0 ? { attachment } : {
|
|
164
|
+
attachment,
|
|
165
|
+
uploadUrl
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
//#endregion
|
|
169
|
+
//#region src/hub.ts
|
|
170
|
+
const HUB_VERSION = "0.1.0";
|
|
171
|
+
const StatusFilterSchema = z.enum(["open", "resolved"]);
|
|
172
|
+
const AfterSchema = z.coerce.number().int().nonnegative();
|
|
173
|
+
const ThreadPostSchema = z.object({
|
|
174
|
+
role: z.enum([
|
|
175
|
+
"human",
|
|
176
|
+
"agent",
|
|
177
|
+
"mirror"
|
|
178
|
+
]),
|
|
179
|
+
text: z.string().min(1),
|
|
180
|
+
attachments: z.array(AttachmentSchema).optional(),
|
|
181
|
+
origin: z.string().optional()
|
|
182
|
+
});
|
|
183
|
+
const ResolvePostSchema = z.object({
|
|
184
|
+
by: z.enum(["human", "agent"]),
|
|
185
|
+
note: z.string().optional(),
|
|
186
|
+
commit: z.string().optional()
|
|
187
|
+
});
|
|
188
|
+
const ROUTES = [
|
|
189
|
+
routeCollections,
|
|
190
|
+
routePinItem,
|
|
191
|
+
routeSessions,
|
|
192
|
+
routeToolbar,
|
|
193
|
+
routeLinks
|
|
194
|
+
];
|
|
195
|
+
function createHubHandler(opts) {
|
|
196
|
+
return async (req) => {
|
|
197
|
+
const url = new URL(req.url);
|
|
198
|
+
if (req.method === "GET" && url.pathname === "/health") return ok(200, {
|
|
199
|
+
version: HUB_VERSION,
|
|
200
|
+
schemaVersion: 1,
|
|
201
|
+
wsProtocol: 1
|
|
202
|
+
});
|
|
203
|
+
if (opts.verify) {
|
|
204
|
+
if (await opts.verify(req) === null) return err(401, "E_AUTH", "request rejected by the configured verifier", { hint: "send a credential the hub's verify strategy accepts" });
|
|
205
|
+
} else if (req.headers.get("authorization") !== `Bearer ${opts.token}`) return err(401, "E_INVALID_INPUT", "missing or invalid bearer token", { hint: "send Authorization: Bearer <token>; the token lives in the XDG state file" });
|
|
206
|
+
try {
|
|
207
|
+
return await route(req, url, opts);
|
|
208
|
+
} catch (cause) {
|
|
209
|
+
return mapError(cause);
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
async function route(req, url, opts) {
|
|
214
|
+
for (const routeModule of ROUTES) {
|
|
215
|
+
const response = await routeModule(req, url, opts);
|
|
216
|
+
if (response) return response;
|
|
217
|
+
}
|
|
218
|
+
throw new NotFoundError(`no route: ${req.method} ${url.pathname}`);
|
|
219
|
+
}
|
|
220
|
+
async function routeCollections(req, url, opts) {
|
|
221
|
+
const { store } = opts;
|
|
222
|
+
switch (`${req.method} ${url.pathname}`) {
|
|
223
|
+
case "GET /summary": {
|
|
224
|
+
const sessions = store.sessions.list().filter((s) => s.endedAt === void 0).length;
|
|
225
|
+
return ok(200, {
|
|
226
|
+
...store.summary(),
|
|
227
|
+
sessions
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
case "GET /events": {
|
|
231
|
+
const after = url.searchParams.get("after");
|
|
232
|
+
return ok(200, store.eventsAfter(after === null ? 0 : AfterSchema.parse(after)));
|
|
233
|
+
}
|
|
234
|
+
case "POST /pins": {
|
|
235
|
+
const input = PinInputSchema.parse(await readJson(req));
|
|
236
|
+
return ok(201, store.createPin(input, opts.enrichEnv?.() ?? {}));
|
|
237
|
+
}
|
|
238
|
+
case "GET /pins": {
|
|
239
|
+
const search = url.searchParams.get("search");
|
|
240
|
+
if (search !== null) return ok(200, store.searchPins(search));
|
|
241
|
+
const status = url.searchParams.get("status");
|
|
242
|
+
const filter = status === null ? void 0 : { status: StatusFilterSchema.parse(status) };
|
|
243
|
+
return ok(200, store.listPins(filter));
|
|
244
|
+
}
|
|
245
|
+
default: return null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
async function routePinItem(req, url, opts) {
|
|
249
|
+
const { store } = opts;
|
|
250
|
+
const { method } = req;
|
|
251
|
+
const path = url.pathname;
|
|
252
|
+
const pinId = match(path, /^\/pins\/([a-z0-9_]+)$/);
|
|
253
|
+
if (pinId !== null && method === "GET") return ok(200, mustGetPin(store, pinId));
|
|
254
|
+
const threadPinId = match(path, /^\/pins\/([a-z0-9_]+)\/thread$/);
|
|
255
|
+
if (threadPinId !== null && method === "GET") {
|
|
256
|
+
mustGetPin(store, threadPinId);
|
|
257
|
+
return ok(200, store.getThread(threadPinId));
|
|
258
|
+
}
|
|
259
|
+
if (threadPinId !== null && method === "POST") {
|
|
260
|
+
const body = ThreadPostSchema.parse(await readJson(req));
|
|
261
|
+
const messageOpts = {
|
|
262
|
+
...body.attachments === void 0 ? {} : { attachments: body.attachments },
|
|
263
|
+
...body.origin === void 0 ? {} : { origin: body.origin }
|
|
264
|
+
};
|
|
265
|
+
const hasOpts = Object.keys(messageOpts).length > 0;
|
|
266
|
+
return ok(201, store.addThreadMessage(threadPinId, body.role, body.text, hasOpts ? messageOpts : void 0));
|
|
267
|
+
}
|
|
268
|
+
const resolvePinId = match(path, /^\/pins\/([a-z0-9_]+)\/resolve$/);
|
|
269
|
+
if (resolvePinId !== null && method === "POST") {
|
|
270
|
+
const body = ResolvePostSchema.parse(await readJson(req));
|
|
271
|
+
return ok(200, store.resolvePin(resolvePinId, body.by, body.note, body.commit));
|
|
272
|
+
}
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
function match(path, pattern) {
|
|
276
|
+
return pattern.exec(path)?.[1] ?? null;
|
|
277
|
+
}
|
|
278
|
+
function mustGetPin(store, id) {
|
|
279
|
+
const pin = store.getPin(id);
|
|
280
|
+
if (!pin) throw new NotFoundError(`pin not found: ${id}`);
|
|
281
|
+
return pin;
|
|
282
|
+
}
|
|
283
|
+
async function readJson(req) {
|
|
284
|
+
try {
|
|
285
|
+
return await req.json();
|
|
286
|
+
} catch {
|
|
287
|
+
throw new BodyNotJsonError();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
var BodyNotJsonError = class extends Error {};
|
|
291
|
+
function json(status, body) {
|
|
292
|
+
return new Response(JSON.stringify(body), {
|
|
293
|
+
status,
|
|
294
|
+
headers: { "content-type": "application/json" }
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
function ok(status, data) {
|
|
298
|
+
return json(status, {
|
|
299
|
+
ok: true,
|
|
300
|
+
data
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
function err(status, code, message, extra) {
|
|
304
|
+
const hint = extra?.hint;
|
|
305
|
+
return json(status, {
|
|
306
|
+
ok: false,
|
|
307
|
+
error: hint === void 0 ? {
|
|
308
|
+
code,
|
|
309
|
+
message
|
|
310
|
+
} : {
|
|
311
|
+
code,
|
|
312
|
+
message,
|
|
313
|
+
hint
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
function mapError(cause) {
|
|
318
|
+
if (cause instanceof NotFoundError) return err(404, "E_NOT_FOUND", cause.message);
|
|
319
|
+
if (cause instanceof ConflictError) return err(409, "E_CONFLICT", cause.message);
|
|
320
|
+
if (cause instanceof z.ZodError) {
|
|
321
|
+
const issue = cause.issues[0];
|
|
322
|
+
return err(400, "E_INVALID_INPUT", issue ? `${issue.path.length > 0 ? `${issue.path.join(".")}: ` : ""}${issue.message}` : "invalid input");
|
|
323
|
+
}
|
|
324
|
+
if (cause instanceof BodyNotJsonError) return err(400, "E_INVALID_INPUT", "request body is not valid JSON", { hint: "send a JSON body with content-type: application/json" });
|
|
325
|
+
return err(500, "E_INTERNAL", cause instanceof Error ? cause.message : "unexpected error");
|
|
326
|
+
}
|
|
327
|
+
//#endregion
|
|
328
|
+
export { mustGetPin as a, registerAttachmentSink as c, match as i, createHubHandler as n, ok as o, err as r, readJson as s, BodyNotJsonError as t };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { t as Attachment } from "./schema-BOTmn5SM.js";
|
|
2
|
+
import { c as PinStore } from "./store-DHbwWu93.js";
|
|
3
|
+
import { HubOptions } from "./hub.js";
|
|
4
|
+
//#region src/attachments.d.ts
|
|
5
|
+
interface AttachmentSink {
|
|
6
|
+
/** Persist bytes; return the finished Attachment (local: path set; cloud R2: url + uploadUrl). */
|
|
7
|
+
write(meta: {
|
|
8
|
+
id: string;
|
|
9
|
+
kind: "screenshot" | "file";
|
|
10
|
+
contentType: string;
|
|
11
|
+
}, bytes: Uint8Array): Promise<{
|
|
12
|
+
attachment: Attachment;
|
|
13
|
+
uploadUrl?: string;
|
|
14
|
+
}>;
|
|
15
|
+
}
|
|
16
|
+
declare function registerAttachmentSink(store: PinStore, sink: AttachmentSink): void;
|
|
17
|
+
//#endregion
|
|
18
|
+
//#region src/attachments-local.d.ts
|
|
19
|
+
/** Bytes to `${dir}/${id}.${ext}`; Bun.write creates the directory tree on demand. */
|
|
20
|
+
declare function localDirSink(dir: string): AttachmentSink;
|
|
21
|
+
//#endregion
|
|
22
|
+
//#region src/git-env.d.ts
|
|
23
|
+
declare function gitEnv(cwd: string): {
|
|
24
|
+
branch?: string;
|
|
25
|
+
commit?: string;
|
|
26
|
+
};
|
|
27
|
+
//#endregion
|
|
28
|
+
//#region src/hub-server.d.ts
|
|
29
|
+
type RealtimeOptions = {
|
|
30
|
+
projectId: string;
|
|
31
|
+
/** Test override for the 5s hello deadline. */
|
|
32
|
+
helloTimeoutMs?: number;
|
|
33
|
+
};
|
|
34
|
+
declare function startHubServer(opts: HubOptions & {
|
|
35
|
+
port?: number;
|
|
36
|
+
idleMs?: number;
|
|
37
|
+
realtime?: RealtimeOptions;
|
|
38
|
+
}): Promise<{
|
|
39
|
+
port: number;
|
|
40
|
+
url: string;
|
|
41
|
+
close: () => Promise<void>;
|
|
42
|
+
}>;
|
|
43
|
+
//#endregion
|
|
44
|
+
export { RealtimeOptions, gitEnv, localDirSink, registerAttachmentSink, startHubServer };
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { c as registerAttachmentSink, n as createHubHandler } from "./hub-QYz6OqYQ.js";
|
|
2
|
+
import { ClientHelloSchema, WS_CLOSE_PROTOCOL, WS_CLOSE_UNAUTHORIZED, WS_TOKEN_SUBPROTOCOL_PREFIX, encodeWsEvent } from "./ws-protocol.js";
|
|
3
|
+
//#region src/attachments-local.ts
|
|
4
|
+
const EXTENSIONS = {
|
|
5
|
+
"image/png": "png",
|
|
6
|
+
"image/webp": "webp",
|
|
7
|
+
"image/jpeg": "jpg",
|
|
8
|
+
"image/gif": "gif",
|
|
9
|
+
"text/plain": "txt"
|
|
10
|
+
};
|
|
11
|
+
function extensionFor(contentType) {
|
|
12
|
+
const essence = (contentType.split(";")[0] ?? "").trim().toLowerCase();
|
|
13
|
+
return EXTENSIONS[essence] ?? "bin";
|
|
14
|
+
}
|
|
15
|
+
/** Bytes to `${dir}/${id}.${ext}`; Bun.write creates the directory tree on demand. */
|
|
16
|
+
function localDirSink(dir) {
|
|
17
|
+
return { async write(meta, bytes) {
|
|
18
|
+
const path = `${dir}/${meta.id}.${extensionFor(meta.contentType)}`;
|
|
19
|
+
await Bun.write(path, bytes);
|
|
20
|
+
return { attachment: {
|
|
21
|
+
id: meta.id,
|
|
22
|
+
kind: meta.kind,
|
|
23
|
+
path,
|
|
24
|
+
contentType: meta.contentType
|
|
25
|
+
} };
|
|
26
|
+
} };
|
|
27
|
+
}
|
|
28
|
+
//#endregion
|
|
29
|
+
//#region src/git-env.ts
|
|
30
|
+
function gitEnv(cwd) {
|
|
31
|
+
const branch = revParse(cwd, "--abbrev-ref", "HEAD");
|
|
32
|
+
const commit = revParse(cwd, "HEAD");
|
|
33
|
+
if (branch === null || commit === null) return {};
|
|
34
|
+
return {
|
|
35
|
+
branch,
|
|
36
|
+
commit
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function revParse(cwd, ...args) {
|
|
40
|
+
try {
|
|
41
|
+
const result = Bun.spawnSync([
|
|
42
|
+
"git",
|
|
43
|
+
"rev-parse",
|
|
44
|
+
...args
|
|
45
|
+
], { cwd });
|
|
46
|
+
if (!result.success) return null;
|
|
47
|
+
const out = result.stdout.toString().trim();
|
|
48
|
+
return out === "" ? null : out;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
//#endregion
|
|
54
|
+
//#region src/hub-server.ts
|
|
55
|
+
const DEFAULT_IDLE_MS = 18e5;
|
|
56
|
+
const DEFAULT_HELLO_TIMEOUT_MS = 5e3;
|
|
57
|
+
const BACKPRESSURE_LIMIT_BYTES = 1048576;
|
|
58
|
+
function startHubServer(opts) {
|
|
59
|
+
const handler = createHubHandler(opts);
|
|
60
|
+
const idleMs = opts.idleMs ?? DEFAULT_IDLE_MS;
|
|
61
|
+
const { realtime } = opts;
|
|
62
|
+
const topic = `project:${realtime?.projectId ?? ""}`;
|
|
63
|
+
const sockets = { count: 0 };
|
|
64
|
+
let idleTimer;
|
|
65
|
+
const touchIdleTimer = () => {
|
|
66
|
+
clearTimeout(idleTimer);
|
|
67
|
+
idleTimer = setTimeout(() => {
|
|
68
|
+
if (sockets.count > 0) {
|
|
69
|
+
touchIdleTimer();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
close();
|
|
73
|
+
}, idleMs);
|
|
74
|
+
idleTimer.unref();
|
|
75
|
+
};
|
|
76
|
+
const server = Bun.serve({
|
|
77
|
+
hostname: "127.0.0.1",
|
|
78
|
+
port: opts.port ?? 0,
|
|
79
|
+
fetch: (req, srv) => {
|
|
80
|
+
touchIdleTimer();
|
|
81
|
+
if (!realtime) return handler(req);
|
|
82
|
+
return realtimeFetch(req, srv, handler, opts.token, topic);
|
|
83
|
+
},
|
|
84
|
+
websocket: wsHandlers(opts.store, topic, realtime?.helloTimeoutMs ?? DEFAULT_HELLO_TIMEOUT_MS, sockets)
|
|
85
|
+
});
|
|
86
|
+
const broadcaster = {
|
|
87
|
+
publish: (t, d) => void server.publish(t, d),
|
|
88
|
+
subscriberCount: (t) => server.subscriberCount(t)
|
|
89
|
+
};
|
|
90
|
+
const unsubscribe = realtime ? opts.store.subscribe((event) => broadcaster.publish(topic, encodeWsEvent(event))) : void 0;
|
|
91
|
+
const close = async () => {
|
|
92
|
+
clearTimeout(idleTimer);
|
|
93
|
+
unsubscribe?.();
|
|
94
|
+
await Promise.race([server.stop(true), Bun.sleep(50)]);
|
|
95
|
+
};
|
|
96
|
+
const { port } = server;
|
|
97
|
+
if (port === void 0) throw new Error("hub server bound without a TCP port");
|
|
98
|
+
touchIdleTimer();
|
|
99
|
+
return Promise.resolve({
|
|
100
|
+
port,
|
|
101
|
+
url: server.url.href,
|
|
102
|
+
close
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
async function realtimeFetch(req, server, handler, token, topic) {
|
|
106
|
+
const url = new URL(req.url);
|
|
107
|
+
if (url.pathname === "/ws") return upgradeWs(req, url, server, token);
|
|
108
|
+
const origin = req.headers.get("origin");
|
|
109
|
+
const gated = corsGate(req, origin);
|
|
110
|
+
if (gated !== null) return gated;
|
|
111
|
+
let res = await handler(req);
|
|
112
|
+
if (req.method === "GET" && url.pathname === "/summary" && res.status === 200) res = await withConnectedToolbars(res, server.subscriberCount(topic));
|
|
113
|
+
return origin === null ? res : withAllowOrigin(res, origin);
|
|
114
|
+
}
|
|
115
|
+
function corsGate(req, origin) {
|
|
116
|
+
if (origin === null) return null;
|
|
117
|
+
if (!isLoopbackOrigin(origin)) return new Response(null, { status: 403 });
|
|
118
|
+
if (req.method === "OPTIONS") return preflight(origin);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
function upgradeWs(req, url, server, token) {
|
|
122
|
+
const entry = (req.headers.get("sec-websocket-protocol") ?? "").split(",").map((part) => part.trim()).find((part) => part.startsWith(WS_TOKEN_SUBPROTOCOL_PREFIX));
|
|
123
|
+
const data = {
|
|
124
|
+
authorized: (entry?.slice(13) ?? url.searchParams.get("token")) === token,
|
|
125
|
+
helloDone: false,
|
|
126
|
+
helloTimer: void 0
|
|
127
|
+
};
|
|
128
|
+
server.timeout(req, 0);
|
|
129
|
+
return server.upgrade(req, {
|
|
130
|
+
data,
|
|
131
|
+
...entry === void 0 ? {} : { headers: { "sec-websocket-protocol": entry } }
|
|
132
|
+
}) ? void 0 : new Response("websocket upgrade failed", { status: 400 });
|
|
133
|
+
}
|
|
134
|
+
function wsHandlers(store, topic, helloTimeoutMs, sockets) {
|
|
135
|
+
return {
|
|
136
|
+
sendPings: true,
|
|
137
|
+
backpressureLimit: BACKPRESSURE_LIMIT_BYTES,
|
|
138
|
+
closeOnBackpressureLimit: true,
|
|
139
|
+
open(ws) {
|
|
140
|
+
sockets.count += 1;
|
|
141
|
+
if (!ws.data.authorized) {
|
|
142
|
+
ws.close(WS_CLOSE_UNAUTHORIZED, "missing or invalid token");
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
ws.subscribe(topic);
|
|
146
|
+
ws.data.helloTimer = setTimeout(() => {
|
|
147
|
+
ws.close(WS_CLOSE_PROTOCOL, "no hello within deadline");
|
|
148
|
+
}, helloTimeoutMs);
|
|
149
|
+
},
|
|
150
|
+
message(ws, raw) {
|
|
151
|
+
onWsMessage(store, ws, raw);
|
|
152
|
+
},
|
|
153
|
+
close(ws) {
|
|
154
|
+
sockets.count -= 1;
|
|
155
|
+
clearTimeout(ws.data.helloTimer);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function onWsMessage(store, ws, raw) {
|
|
160
|
+
if (ws.data.helloDone) {
|
|
161
|
+
ws.send(errorFrame("unexpected client frame: no client messages exist after hello"));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const hello = parseHello(raw);
|
|
165
|
+
if (hello === null) {
|
|
166
|
+
ws.send(errorFrame("malformed hello"));
|
|
167
|
+
ws.close(WS_CLOSE_PROTOCOL, "malformed hello");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (hello.protocol < 1) {
|
|
171
|
+
ws.send(errorFrame(`protocol ${hello.protocol} below minimum 1`));
|
|
172
|
+
ws.close(WS_CLOSE_PROTOCOL, "protocol below minimum");
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
clearTimeout(ws.data.helloTimer);
|
|
176
|
+
ws.data.helloDone = true;
|
|
177
|
+
const events = store.eventsAfter(hello.lastSeq).map((event) => ({
|
|
178
|
+
type: "event",
|
|
179
|
+
seq: event.seq,
|
|
180
|
+
eventType: event.type,
|
|
181
|
+
at: event.at,
|
|
182
|
+
payload: event.payload
|
|
183
|
+
}));
|
|
184
|
+
ws.send(JSON.stringify({
|
|
185
|
+
type: "catch-up",
|
|
186
|
+
protocol: 1,
|
|
187
|
+
minProtocol: 1,
|
|
188
|
+
lastSeq: store.summary().lastEventSeq,
|
|
189
|
+
events
|
|
190
|
+
}));
|
|
191
|
+
store.cursors.set(hello.consumerId, hello.lastSeq);
|
|
192
|
+
}
|
|
193
|
+
function parseHello(raw) {
|
|
194
|
+
try {
|
|
195
|
+
const parsed = ClientHelloSchema.safeParse(JSON.parse(String(raw)));
|
|
196
|
+
return parsed.success ? parsed.data : null;
|
|
197
|
+
} catch {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function errorFrame(message) {
|
|
202
|
+
return JSON.stringify({
|
|
203
|
+
type: "error",
|
|
204
|
+
code: "E_WS_PROTOCOL",
|
|
205
|
+
message
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
|
|
209
|
+
"localhost",
|
|
210
|
+
"127.0.0.1",
|
|
211
|
+
"[::1]",
|
|
212
|
+
"::1"
|
|
213
|
+
]);
|
|
214
|
+
function isLoopbackOrigin(origin) {
|
|
215
|
+
try {
|
|
216
|
+
return LOOPBACK_HOSTS.has(new URL(origin).hostname);
|
|
217
|
+
} catch {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function preflight(origin) {
|
|
222
|
+
return new Response(null, {
|
|
223
|
+
status: 204,
|
|
224
|
+
headers: {
|
|
225
|
+
"access-control-allow-origin": origin,
|
|
226
|
+
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
227
|
+
"access-control-allow-headers": "authorization, content-type",
|
|
228
|
+
"access-control-max-age": "600",
|
|
229
|
+
vary: "origin"
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
function withAllowOrigin(res, origin) {
|
|
234
|
+
const headers = new Headers(res.headers);
|
|
235
|
+
headers.set("access-control-allow-origin", origin);
|
|
236
|
+
headers.set("vary", "origin");
|
|
237
|
+
return new Response(res.body, {
|
|
238
|
+
status: res.status,
|
|
239
|
+
headers
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
async function withConnectedToolbars(res, connectedToolbars) {
|
|
243
|
+
const body = await res.json();
|
|
244
|
+
body.data["connectedToolbars"] = connectedToolbars;
|
|
245
|
+
return new Response(JSON.stringify(body), {
|
|
246
|
+
status: res.status,
|
|
247
|
+
headers: { "content-type": "application/json" }
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
//#endregion
|
|
251
|
+
export { gitEnv, localDirSink, registerAttachmentSink, startHubServer };
|